Compare commits
76 commits
7cff550749
...
4244779521
| Author | SHA1 | Date | |
|---|---|---|---|
| 4244779521 | |||
| 70029f799e | |||
| 89fbd55250 | |||
| fba0ea5ec0 | |||
| 807e03d86b | |||
| 8610ab842a | |||
| 881157bd10 | |||
| eb81d5f7cb | |||
| a22081f288 | |||
| 77ae5c4888 | |||
| 897677a782 | |||
| 555ae15173 | |||
| 970c749a92 | |||
| 351eac4c02 | |||
| 145a76348c | |||
| 9ecfe784db | |||
| cd7e6b0843 | |||
| 74fe60f768 | |||
| 5ffd2b334e | |||
| dbd79075f5 | |||
| 01cc5aa3a1 | |||
| 075a06ba6f | |||
| bc87893134 | |||
| dc3268cbf4 | |||
| c95a6fac69 | |||
| 42a463f422 | |||
| b3eb6c9223 | |||
| 4fffeeaaa0 | |||
| 6846363132 | |||
| 70729bdd73 | |||
| 4192922fd3 | |||
| 93190d558f | |||
| 22d50d6c46 | |||
| 469c4c0c1d | |||
| 6fe75db56d | |||
| 35895ac7fd | |||
| 720a43a38c | |||
| 3fd6410bb4 | |||
| a1b0f65233 | |||
| 8a1b14fc79 | |||
| 30805b07ca | |||
| e7515b5450 | |||
| 06a05fcaad | |||
| 922f9f93d0 | |||
| 77908a1467 | |||
| e38de7d690 | |||
| 6311eebb0c | |||
| b0623b20ed | |||
| 47c46eaebf | |||
| 0ccb1c7d79 | |||
| e565d1748e | |||
| b139d85791 | |||
| 30c43271ea | |||
| 4a1042ab1a | |||
| 9af7381843 | |||
| 36776f8e28 | |||
| 4a6e7cf51a | |||
| 38d106a69e | |||
| cbe05c5ca8 | |||
| df8c6a1854 | |||
| 909d4af2a2 | |||
| 935ef52c10 | |||
| ff625c91c5 | |||
| aba8737c38 | |||
| 16ca4efc03 | |||
| e2c5971daf | |||
| c88f805b6e | |||
| 5fa0b48acc | |||
| 18c082a893 | |||
| e088123fb9 | |||
| 3d81461fbe | |||
| 756d99dcc8 | |||
| eea3f28cc5 | |||
| b0097ab99d | |||
| bb6ea0085b | |||
| 2f6d5ff818 |
92 changed files with 11188 additions and 2260 deletions
2
Justfile
2
Justfile
|
|
@ -32,6 +32,8 @@ lint:
|
||||||
mix format --check-formatted
|
mix format --check-formatted
|
||||||
mix compile --warnings-as-errors
|
mix compile --warnings-as-errors
|
||||||
mix credo
|
mix credo
|
||||||
|
# Check that all German translations are filled (UI must be in German)
|
||||||
|
@bash -c 'for file in priv/gettext/de/LC_MESSAGES/*.po; do awk "/^msgid \"\"$/{header=1; next} /^msgid /{header=0} /^msgstr \"\"$/ && !header{print FILENAME\":\"NR\": \" \$0; exit 1}" "$file" || exit 1; done'
|
||||||
mix gettext.extract --check-up-to-date
|
mix gettext.extract --check-up-to-date
|
||||||
|
|
||||||
audit:
|
audit:
|
||||||
|
|
|
||||||
|
|
@ -99,4 +99,213 @@
|
||||||
/* 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,6 +73,43 @@ 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},
|
||||||
|
|
@ -102,3 +139,170 @@ 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()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,318 +0,0 @@
|
||||||
---
|
|
||||||
name: Code Review Fixes - Membership Fee Features
|
|
||||||
overview: Umsetzung der validen Code Review Punkte aus beiden Reviews mit Priorisierung nach Kritikalität. Fokus auf Transaktionssicherheit, Code-Qualität, Performance und UX-Verbesserungen.
|
|
||||||
todos:
|
|
||||||
- id: fix-after-action-tasks
|
|
||||||
content: "after_action mit Task.start → after_transaction + Task.Supervisor: Task.Supervisor zu application.ex hinzufügen, after_action Hooks in after_transaction umwandeln, Task.Supervisor.async_nolink verwenden"
|
|
||||||
status: pending
|
|
||||||
- id: reduce-code-duplication
|
|
||||||
content: "Code-Duplikation reduzieren: handle_cycle_generation/2 private Funktion extrahieren, alle drei Stellen (Create, Type Change, Date Change) verwenden"
|
|
||||||
status: pending
|
|
||||||
dependencies:
|
|
||||||
- fix-after-action-tasks
|
|
||||||
- id: fix-join-date-validation
|
|
||||||
content: "join_date Validierung: Entweder Validierung wieder hinzufügen (validate compare(:join_date, less_than_or_equal_to: &Date.utc_today/0)) oder Dokumentation anpassen"
|
|
||||||
status: pending
|
|
||||||
- id: fix-load-cycles-docs
|
|
||||||
content: "load_cycles_for_members: Entweder Dokumentation korrigieren (ehrlich machen) oder echte Filterung implementieren (z.B. nur letzte 2 Intervalle)"
|
|
||||||
status: pending
|
|
||||||
- id: fix-get-current-cycle-sort
|
|
||||||
content: "get_current_cycle nondeterministisch: Vor List.first() nach cycle_start sortieren (desc) in MembershipFeeHelpers.get_current_cycle"
|
|
||||||
status: pending
|
|
||||||
- id: fix-n1-query-member-count
|
|
||||||
content: "N+1 Query beheben: Aggregate auf MembershipFeeType definieren oder member_count einmalig vorab laden und in assigns cachen"
|
|
||||||
status: pending
|
|
||||||
- id: fix-assign-new-stale
|
|
||||||
content: "assign_new → assign: In MembershipFeesComponent.update/2 immer assign(:cycles, cycles) und assign(:available_fee_types, available_fee_types) setzen"
|
|
||||||
status: pending
|
|
||||||
- id: fix-regenerating-flag
|
|
||||||
content: "@regenerating auf true setzen: Direkt beim Event-Start in handle_event(\"regenerate_cycles\", ...) socket |> assign(:regenerating, true) setzen"
|
|
||||||
status: pending
|
|
||||||
- id: fix-create-cycle-parsing
|
|
||||||
content: "Create-cycle parsing Fix: Decimal.parse explizit behandeln und {:error, :invalid_amount} zurückgeben statt :error"
|
|
||||||
status: pending
|
|
||||||
- id: fix-delete-all-atomic
|
|
||||||
content: "Delete all cycles atomar: Bulk Delete Query verwenden (Ash bulk destroy oder Query-basiert) statt Enum.map"
|
|
||||||
status: pending
|
|
||||||
- id: improve-async-error-handling
|
|
||||||
content: "Fehlerbehandlung bei async Tasks: Strukturierte Error-Logs mit Context, optional Retry-Mechanismus oder Event-System für Benachrichtigung"
|
|
||||||
status: pending
|
|
||||||
- id: improve-format-currency
|
|
||||||
content: "format_currency Robustheit: Number.Currency verwenden oder robusteres Pattern Matching + Tests für Edge Cases (negative Zahlen, sehr große Zahlen)"
|
|
||||||
status: pending
|
|
||||||
- id: add-missing-typespecs
|
|
||||||
content: "Fehlende Typespecs: @spec für SetDefaultMembershipFeeType.change/3 hinzufügen"
|
|
||||||
status: pending
|
|
||||||
- id: fix-race-condition
|
|
||||||
content: "Potenzielle Race Condition: Prüfen ob Ash doppelte Auslösung verhindert, ggf. Logik anpassen (beide Änderungen in einem Hook zusammenfassen)"
|
|
||||||
status: pending
|
|
||||||
- id: extract-magic-values
|
|
||||||
content: "Magic Numbers/Strings: Application.get_env(:mv, :sql_sandbox, false) in Konstante/Helper extrahieren (z.B. Mv.Config.sql_sandbox?/0)"
|
|
||||||
status: pending
|
|
||||||
- id: fix-domain-consistency
|
|
||||||
content: "Domain-Konsistenz: Überall in MembershipFeesComponent domain: MembershipFees explizit angeben"
|
|
||||||
status: pending
|
|
||||||
- id: fix-test-helper
|
|
||||||
content: "Test-Helper Fix: create_cycle/3 Helper - Cleanup nur einmal im Setup oder gezielt nur auto-generierte löschen"
|
|
||||||
status: pending
|
|
||||||
- id: fix-date-utc-today-param
|
|
||||||
content: "Date.utc_today() Parameter: today Parameter durchgeben in get_cycle_status_for_member und Helper-Funktionen"
|
|
||||||
status: pending
|
|
||||||
- id: fix-ui-locale-input
|
|
||||||
content: "UI/Locale Input Fix: type=\"number\" → type=\"text\" + inputmode=\"decimal\" + serverseitig \",\" → \".\" normalisieren"
|
|
||||||
status: pending
|
|
||||||
- id: fix-delete-confirmation
|
|
||||||
content: "Delete-all-Confirmation robuster: String.trim() + case-insensitive Vergleich oder \"type DELETE\" Pattern"
|
|
||||||
status: pending
|
|
||||||
- id: fix-warning-state
|
|
||||||
content: "Warning-State Fix: Bei Decimal.parse(:error) explizit hide_amount_warning(socket) aufrufen"
|
|
||||||
status: pending
|
|
||||||
- id: fix-double-toggle
|
|
||||||
content: "Toggle entfernen: Toggle-Button im Spalten-Header entfernen (nur in Toolbar behalten)"
|
|
||||||
status: pending
|
|
||||||
- id: fix-format-consistency
|
|
||||||
content: "Format-Konsistenz: Inputs ebenfalls auf Komma ausrichten oder serverseitig normalisieren"
|
|
||||||
status: pending
|
|
||||||
dependencies:
|
|
||||||
- fix-ui-locale-input
|
|
||||||
---
|
|
||||||
|
|
||||||
# Code Review Fixes - Membership Fee Features
|
|
||||||
|
|
||||||
## Kritische Probleme (Müssen vor Merge behoben werden)
|
|
||||||
|
|
||||||
### 1. after_action mit Task.start - Transaktionsprobleme
|
|
||||||
|
|
||||||
**Dateien:** `lib/membership/member.ex` (Zeilen 142, 279)
|
|
||||||
|
|
||||||
**Problem:** `Task.start/1` wird innerhalb von `after_action` Hooks verwendet. `after_action` läuft innerhalb der DB-Transaktion, daher:
|
|
||||||
|
|
||||||
- Tasks sehen möglicherweise noch nicht committed state
|
|
||||||
- Tasks werden auch bei Rollback gestartet
|
|
||||||
- Keine Supervision → Memory Leaks möglich
|
|
||||||
|
|
||||||
**Lösung:**
|
|
||||||
|
|
||||||
- `after_transaction` Hook verwenden (Ash Best Practice)
|
|
||||||
- `Task.Supervisor` zum Supervision Tree hinzufügen (`lib/mv/application.ex`)
|
|
||||||
- `Task.Supervisor.async_nolink/3` statt `Task.start/1` verwenden
|
|
||||||
|
|
||||||
**Betroffene Stellen:**
|
|
||||||
|
|
||||||
- Member Creation (Zeile 116-164)
|
|
||||||
- Join/Exit Date Change (Zeile 250-301)
|
|
||||||
|
|
||||||
### 2. Code-Duplikation in Cycle-Generation-Logik
|
|
||||||
|
|
||||||
**Datei:** `lib/membership/member.ex`
|
|
||||||
|
|
||||||
**Problem:** Cycle-Generation-Logik ist dreimal dupliziert (Create, Type Change, Date Change)
|
|
||||||
|
|
||||||
**Lösung:** Extrahiere in private Funktion `handle_cycle_generation/2`
|
|
||||||
|
|
||||||
## Wichtige Probleme (Sollten behoben werden)
|
|
||||||
|
|
||||||
### 3. join_date Validierung entfernt, aber Dokumentation behauptet Gegenteil
|
|
||||||
|
|
||||||
**Datei:** `lib/membership/member.ex` (Zeile 27, 516-518)
|
|
||||||
|
|
||||||
**Problem:** Dokumentation sagt "join_date not in future", aber Validierung fehlt
|
|
||||||
|
|
||||||
**Lösung:** Dokumentation anpassen
|
|
||||||
|
|
||||||
### 4. load_cycles_for_members overpromises
|
|
||||||
|
|
||||||
**Datei:** `lib/mv_web/member_live/index/membership_fee_status.ex` (Zeile 36-40)
|
|
||||||
|
|
||||||
**Problem:** Dokumentation sagt "Only loads the relevant cycle per member" und "Filters cycles at database level", aber lädt alle Cycles
|
|
||||||
|
|
||||||
**Lösung:** echte Filterung implementieren (z.B. nur letzte 2 Intervalle)
|
|
||||||
|
|
||||||
### 5. get_current_cycle nondeterministisch
|
|
||||||
|
|
||||||
**Datei:** `lib/mv_web/helpers/membership_fee_helpers.ex` (Zeile 178-182)
|
|
||||||
|
|
||||||
**Problem:** `List.first()` ohne explizite Sortierung → Ergebnis hängt von Reihenfolge ab
|
|
||||||
|
|
||||||
**Lösung:** Vor `List.first()` nach `cycle_start` sortieren (desc)
|
|
||||||
|
|
||||||
### 6. N+1 Query durch get_member_count
|
|
||||||
|
|
||||||
**Datei:** `lib/mv_web/live/membership_fee_type_live/index.ex` (Zeile 134-140)
|
|
||||||
|
|
||||||
**Problem:** `get_member_count/1` wird pro Row aufgerufen → N+1 Query
|
|
||||||
|
|
||||||
**Lösung:** Aggregate auf MembershipFeeType definieren oder einmalig vorab laden
|
|
||||||
|
|
||||||
### 7. assign_new kann stale werden
|
|
||||||
|
|
||||||
**Datei:** `lib/mv_web/live/member_live/show/membership_fees_component.ex` (Zeile 402-403)
|
|
||||||
|
|
||||||
**Problem:** `assign_new(:cycles, ...)` und `assign_new(:available_fee_types, ...)` werden nur gesetzt, wenn Assign noch nicht existiert
|
|
||||||
|
|
||||||
**Lösung:** In `update/2` immer `assign(:cycles, cycles)` / `assign(:available_fee_types, available_fee_types)` setzen
|
|
||||||
|
|
||||||
### 8. @regenerating wird nie auf true gesetzt
|
|
||||||
|
|
||||||
**Datei:** `lib/mv_web/live/member_live/show/membership_fees_component.ex` (Zeile 526-561)
|
|
||||||
|
|
||||||
**Problem:** `regenerating` wird nur auf `false` gesetzt, nie auf `true` → Button/Spinner werden nie disabled
|
|
||||||
|
|
||||||
**Lösung:** Direkt beim Event-Start `socket |> assign(:regenerating, true)` setzen
|
|
||||||
|
|
||||||
### 9. Create-cycle parsing: invalid amount zeigt falsche Fehlermeldung
|
|
||||||
|
|
||||||
**Datei:** `lib/mv_web/live/member_live/show/membership_fees_component.ex` (Zeile 748-812)
|
|
||||||
|
|
||||||
**Problem:** `Decimal.parse/1` gibt `:error` zurück, aber `with` behandelt es als `:error` → landet in "Invalid date format" Branch
|
|
||||||
|
|
||||||
**Lösung:** Explizit `{:error, :invalid_amount}` zurückgeben:
|
|
||||||
|
|
||||||
```elixir
|
|
||||||
amount = case Decimal.parse(amount_str) do
|
|
||||||
{d, _} -> {:ok, d}
|
|
||||||
:error -> {:error, :invalid_amount}
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
### 10. Delete all cycles: nicht atomar
|
|
||||||
|
|
||||||
**Datei:** `lib/mv_web/live/member_live/show/membership_fees_component.ex` (Zeile 666-714)
|
|
||||||
|
|
||||||
**Problem:** `Enum.map(cycles, &Ash.destroy/1)` → nicht atomar, teilweise gelöscht möglich
|
|
||||||
|
|
||||||
**Lösung:** Bulk Delete Query verwenden (Ash bulk destroy oder Query-basiert)
|
|
||||||
|
|
||||||
### 11. Fehlerbehandlung bei async Tasks
|
|
||||||
|
|
||||||
**Datei:** `lib/membership/member.ex`
|
|
||||||
|
|
||||||
**Problem:** Bei Fehlern in async Tasks wird nur geloggt, aber der Benutzer erhält keine Rückmeldung. Die Member-Aktion wird als erfolgreich zurückgegeben, auch wenn die Cycle-Generierung fehlschlägt. Keine Retry-Logik oder Monitoring.
|
|
||||||
|
|
||||||
**Lösung:**
|
|
||||||
|
|
||||||
- Für kritische Fälle: synchron ausführen oder Retry-Mechanismus implementieren
|
|
||||||
- Für nicht-kritische Fälle: Event-System für spätere Benachrichtigung
|
|
||||||
- Strukturierte Error-Logs mit Context
|
|
||||||
- Optional: Error-Tracking (Sentry, etc.)
|
|
||||||
|
|
||||||
### 12. format_currency Robustheit
|
|
||||||
|
|
||||||
**Datei:** `lib/mv_web/helpers/membership_fee_helpers.ex` (Zeilen 27-51)
|
|
||||||
|
|
||||||
**Problem:** Die Funktion verwendet String-Manipulation für Formatierung. Edge Cases könnten problematisch sein (z.B. sehr große Zahlen, negative Werte).
|
|
||||||
|
|
||||||
**Lösung:**
|
|
||||||
|
|
||||||
- `Number.Currency` oder ähnliche Bibliothek verwenden
|
|
||||||
- Oder: Robusteres Pattern Matching für Edge Cases
|
|
||||||
- Tests für Edge Cases hinzufügen (negative Zahlen, sehr große Zahlen)
|
|
||||||
|
|
||||||
### 13. Fehlende Typespecs
|
|
||||||
|
|
||||||
**Datei:** `lib/membership/member/changes/set_default_membership_fee_type.ex`
|
|
||||||
|
|
||||||
**Problem:** Keine `@spec` für die `change/3` Funktion.
|
|
||||||
|
|
||||||
**Lösung:** Typespecs hinzufügen für bessere Dokumentation und Dialyzer-Support.
|
|
||||||
|
|
||||||
### 14. Potenzielle Race Condition
|
|
||||||
|
|
||||||
**Datei:** `lib/membership/member.ex` (Zeile 250-301)
|
|
||||||
|
|
||||||
**Problem:** Wenn `join_date` und `exit_date` gleichzeitig geändert werden, könnte die Cycle-Generierung zweimal ausgelöst werden (einmal pro Änderung).
|
|
||||||
|
|
||||||
**Lösung:** Prüfen, ob Ash dies bereits verhindert, oder Logik anpassen (z.B. beide Änderungen in einem Hook zusammenfassen).
|
|
||||||
|
|
||||||
### 15. Magic Numbers/Strings
|
|
||||||
|
|
||||||
**Problem:** `Application.get_env(:mv, :sql_sandbox, false)` wird mehrfach verwendet.
|
|
||||||
|
|
||||||
**Lösung:** Extrahiere in Konstante oder Helper-Funktion (z.B. `Mv.Config.sql_sandbox?/0`).
|
|
||||||
|
|
||||||
## Mittlere Probleme (Nice-to-have)
|
|
||||||
|
|
||||||
### 16. Inconsistent use of domain
|
|
||||||
|
|
||||||
**Datei:** `lib/mv_web/live/member_live/show/membership_fees_component.ex` (Zeile 819-821)
|
|
||||||
|
|
||||||
**Problem:** Einige Actions verwenden `domain: MembershipFees`, andere nicht
|
|
||||||
|
|
||||||
**Lösung:** Konsistent `domain` überall verwenden
|
|
||||||
|
|
||||||
### 17. Tests: create_cycle/3 löscht jedes Mal alle Cycles
|
|
||||||
|
|
||||||
**Datei:** `test/mv_web/member_live/index/membership_fee_status_test.exs` (Zeile 45-52)
|
|
||||||
|
|
||||||
**Problem:** Helper löscht vor jedem Create alle Cycles → Tests prüfen nicht, was sie denken
|
|
||||||
|
|
||||||
**Lösung:** Cleanup nur einmal im Setup oder gezielt nur auto-generierte löschen
|
|
||||||
|
|
||||||
### 18. Tests/Design: Date.utc_today() macht Tests flaky
|
|
||||||
|
|
||||||
**Problem:** Tests hängen von `Date.utc_today()` ab → nicht deterministisch
|
|
||||||
|
|
||||||
**Lösung:** `today` Parameter durchgeben (z.B. `get_cycle_status_for_member(member, show_current, today \\ Date.utc_today())`)
|
|
||||||
|
|
||||||
### 19. UI/Locale: input type="number" + Decimal/Komma
|
|
||||||
|
|
||||||
**Problem:** `type="number"` funktioniert nicht zuverlässig mit Komma als Dezimaltrenner
|
|
||||||
|
|
||||||
**Lösung:** `type="text"` + `inputmode="decimal"` + serverseitig "," → "." normalisieren
|
|
||||||
|
|
||||||
### 20. Delete-all-Confirmation: String-Vergleich ist fragil
|
|
||||||
|
|
||||||
**Datei:** `lib/mv_web/live/member_live/show/membership_fees_component.ex` (Zeile 296-298)
|
|
||||||
|
|
||||||
**Problem:** String-Vergleich gegen `gettext("Yes")` und `"Yes"` → fragil bei Whitespace/Locale
|
|
||||||
|
|
||||||
**Lösung:** `String.trim()` + case-insensitive Vergleich oder "type DELETE" Pattern
|
|
||||||
|
|
||||||
### 21. MembershipFeeType Form: Warning-State kann hängen bleiben
|
|
||||||
|
|
||||||
**Datei:** `lib/mv_web/live/membership_fee_type_live/form.ex` (Zeile 367-378)
|
|
||||||
|
|
||||||
**Problem:** Bei `Decimal.parse(:error)` wird nur `socket` zurückgegeben → Warning kann stehen bleiben
|
|
||||||
|
|
||||||
**Lösung:** Bei `:error` explizit `hide_amount_warning(socket)` aufrufen
|
|
||||||
|
|
||||||
### 22. UI/UX: Toggle ist doppelt vorhanden
|
|
||||||
|
|
||||||
**Datei:** `lib/mv_web/live/member_live/index.html.heex` (Zeile 45-72, 284-296)
|
|
||||||
|
|
||||||
**Problem:** Toggle-Button sowohl in Toolbar als auch im Spalten-Header
|
|
||||||
|
|
||||||
**Lösung:** Toggle im Spalten-Header entfernen (nur in Toolbar behalten)
|
|
||||||
|
|
||||||
### 23. Konsistenz: format_currency vs Inputs
|
|
||||||
|
|
||||||
**Problem:** `format_currency` formatiert deutsch (Komma), aber Inputs erwarten Punkt
|
|
||||||
|
|
||||||
**Lösung:** Inputs ebenfalls auf Komma ausrichten oder serverseitig normalisieren
|
|
||||||
|
|
||||||
## Implementierungsreihenfolge
|
|
||||||
|
|
||||||
1. **Kritisch:** after_action → after_transaction + Task.Supervisor
|
|
||||||
2. **Kritisch:** Code-Duplikation reduzieren
|
|
||||||
3. **Wichtig:** join_date Validierung/Dokumentation
|
|
||||||
4. **Wichtig:** load_cycles_for_members Dokumentation/Implementierung
|
|
||||||
5. **Wichtig:** get_current_cycle Sortierung
|
|
||||||
6. **Wichtig:** N+1 Query beheben
|
|
||||||
7. **Wichtig:** assign_new → assign
|
|
||||||
8. **Wichtig:** @regenerating auf true setzen
|
|
||||||
9. **Wichtig:** Create-cycle parsing Fix
|
|
||||||
10. **Wichtig:** Delete all cycles atomar
|
|
||||||
11. **Wichtig:** Fehlerbehandlung bei async Tasks
|
|
||||||
12. **Wichtig:** format_currency Robustheit
|
|
||||||
13. **Wichtig:** Fehlende Typespecs
|
|
||||||
14. **Wichtig:** Potenzielle Race Condition prüfen/beheben
|
|
||||||
15. **Wichtig:** Magic Numbers/Strings extrahieren
|
|
||||||
16. **Mittel:** Domain-Konsistenz
|
|
||||||
17. **Mittel:** Test-Helper Fix
|
|
||||||
18. **Mittel:** Date.utc_today() Parameter
|
|
||||||
19. **Mittel:** UI/Locale Fixes
|
|
||||||
20. **Mittel:** String-Vergleich robuster
|
|
||||||
21. **Mittel:** Warning-State Fix
|
|
||||||
22. **Mittel:** Toggle entfernen
|
|
||||||
23. **Mittel:** Format-Konsistenz
|
|
||||||
|
|
||||||
|
|
@ -10,11 +10,7 @@ services:
|
||||||
POSTGRES_PASSWORD: postgres
|
POSTGRES_PASSWORD: postgres
|
||||||
POSTGRES_DB: mv_dev
|
POSTGRES_DB: mv_dev
|
||||||
volumes:
|
volumes:
|
||||||
- type: volume
|
- postgres-data:/var/lib/postgresql/data
|
||||||
source: postgres-data
|
|
||||||
target: /var/lib/postgresql/data
|
|
||||||
volume:
|
|
||||||
nocopy: true
|
|
||||||
ports:
|
ports:
|
||||||
- "5000:5432"
|
- "5000:5432"
|
||||||
networks:
|
networks:
|
||||||
|
|
@ -49,9 +45,7 @@ services:
|
||||||
- rauthy-dev
|
- rauthy-dev
|
||||||
- local
|
- local
|
||||||
volumes:
|
volumes:
|
||||||
- type: volume
|
- rauthy-data:/app/data
|
||||||
source: rauthy-data
|
|
||||||
target: /app/data
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres-data:
|
postgres-data:
|
||||||
|
|
|
||||||
|
|
@ -191,6 +191,26 @@ A **basic CSV member import feature** that allows administrators to upload a CSV
|
||||||
- `/templates/member_import_de.csv`
|
- `/templates/member_import_de.csv`
|
||||||
- In LiveView, link them using Phoenix static path helpers (e.g. `~p` or `Routes.static_path/2`, depending on Phoenix version).
|
- In LiveView, link them using Phoenix static path helpers (e.g. `~p` or `Routes.static_path/2`, depending on Phoenix version).
|
||||||
|
|
||||||
|
**Example Usage in LiveView Templates:**
|
||||||
|
|
||||||
|
```heex
|
||||||
|
<!-- Using ~p sigil (Phoenix 1.7+) -->
|
||||||
|
<.link href={~p"/templates/member_import_en.csv"} download>
|
||||||
|
<%= gettext("Download English Template") %>
|
||||||
|
</.link>
|
||||||
|
|
||||||
|
<.link href={~p"/templates/member_import_de.csv"} download>
|
||||||
|
<%= gettext("Download German Template") %>
|
||||||
|
</.link>
|
||||||
|
|
||||||
|
<!-- Alternative: Using Routes.static_path/2 -->
|
||||||
|
<.link href={Routes.static_path(MvWeb.Endpoint, "/templates/member_import_en.csv")} download>
|
||||||
|
<%= gettext("Download English Template") %>
|
||||||
|
</.link>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** The `templates` directory must be included in `MvWeb.static_paths()` (configured in `lib/mv_web.ex`) for the files to be served.
|
||||||
|
|
||||||
### File Limits
|
### File Limits
|
||||||
|
|
||||||
- **Max file size:** 10 MB
|
- **Max file size:** 10 MB
|
||||||
|
|
|
||||||
533
docs/daisyui-drawer-pattern.md
Normal file
533
docs/daisyui-drawer-pattern.md
Normal file
|
|
@ -0,0 +1,533 @@
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -110,8 +110,8 @@ Control access to LiveView pages:
|
||||||
Three scope levels for permissions:
|
Three scope levels for permissions:
|
||||||
- **:own** - Only records where `record.id == user.id` (for User resource)
|
- **:own** - Only records where `record.id == user.id` (for User resource)
|
||||||
- **:linked** - Only records linked to user via relationships
|
- **:linked** - Only records linked to user via relationships
|
||||||
- Member: `member.user_id == user.id`
|
- Member: `id == user.member_id` (User.member_id → Member.id, inverse relationship)
|
||||||
- CustomFieldValue: `custom_field_value.member.user_id == user.id`
|
- CustomFieldValue: `member_id == user.member_id` (traverses Member → User relationship)
|
||||||
- **:all** - All records, no filtering
|
- **:all** - All records, no filtering
|
||||||
|
|
||||||
**6. Special Cases**
|
**6. Special Cases**
|
||||||
|
|
@ -714,8 +714,8 @@ defmodule Mv.Authorization.Checks.HasPermission do
|
||||||
- **:all** - Authorizes without filtering (returns all records)
|
- **:all** - Authorizes without filtering (returns all records)
|
||||||
- **:own** - Filters to records where record.id == actor.id
|
- **:own** - Filters to records where record.id == actor.id
|
||||||
- **:linked** - Filters based on resource type:
|
- **:linked** - Filters based on resource type:
|
||||||
- Member: member.user_id == actor.id
|
- Member: `id == actor.member_id` (User.member_id → Member.id, inverse relationship)
|
||||||
- CustomFieldValue: custom_field_value.member.user_id == actor.id (traverses relationship!)
|
- CustomFieldValue: `member_id == actor.member_id` (CustomFieldValue.member_id → Member.id → User.member_id)
|
||||||
|
|
||||||
## Error Handling
|
## Error Handling
|
||||||
|
|
||||||
|
|
@ -799,12 +799,14 @@ defmodule Mv.Authorization.Checks.HasPermission do
|
||||||
defp apply_scope(:linked, actor, resource_name) do
|
defp apply_scope(:linked, actor, resource_name) do
|
||||||
case resource_name do
|
case resource_name do
|
||||||
"Member" ->
|
"Member" ->
|
||||||
# Member.user_id == actor.id (direct relationship)
|
# User.member_id → Member.id (inverse relationship)
|
||||||
{:filter, expr(user_id == ^actor.id)}
|
# Filter: member.id == actor.member_id
|
||||||
|
{:filter, expr(id == ^actor.member_id)}
|
||||||
|
|
||||||
"CustomFieldValue" ->
|
"CustomFieldValue" ->
|
||||||
# CustomFieldValue.member.user_id == actor.id (traverse through member!)
|
# CustomFieldValue.member_id → Member.id → User.member_id
|
||||||
{:filter, expr(member.user_id == ^actor.id)}
|
# Filter: custom_field_value.member_id == actor.member_id
|
||||||
|
{:filter, expr(member_id == ^actor.member_id)}
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
# Fallback for other resources: try direct user_id
|
# Fallback for other resources: try direct user_id
|
||||||
|
|
@ -918,7 +920,7 @@ end
|
||||||
|
|
||||||
**Location:** `lib/mv/membership/member.ex`
|
**Location:** `lib/mv/membership/member.ex`
|
||||||
|
|
||||||
**Special Case:** Users can always access their linked member (where `member.user_id == user.id`).
|
**Special Case:** Users can always READ their linked member (where `id == user.member_id`).
|
||||||
|
|
||||||
```elixir
|
```elixir
|
||||||
defmodule Mv.Membership.Member do
|
defmodule Mv.Membership.Member do
|
||||||
|
|
@ -978,10 +980,10 @@ defmodule Mv.Membership.CustomFieldValue do
|
||||||
|
|
||||||
policies do
|
policies do
|
||||||
# SPECIAL CASE: Users can access custom field values of their linked member
|
# SPECIAL CASE: Users can access custom field values of their linked member
|
||||||
# Note: This traverses the member relationship!
|
# Note: This uses member_id relationship (CustomFieldValue.member_id → Member.id → User.member_id)
|
||||||
policy action_type([:read, :update]) do
|
policy action_type([:read, :update]) do
|
||||||
description "Users can access custom field values of their linked member"
|
description "Users can access custom field values of their linked member"
|
||||||
authorize_if expr(member.user_id == ^actor(:id))
|
authorize_if expr(member_id == ^actor(:member_id))
|
||||||
end
|
end
|
||||||
|
|
||||||
# GENERAL: Check permissions from role
|
# GENERAL: Check permissions from role
|
||||||
|
|
|
||||||
|
|
@ -294,7 +294,9 @@ Each Permission Set contains:
|
||||||
**:own** - Only records where id == actor.id
|
**:own** - Only records where id == actor.id
|
||||||
- Example: User can read their own User record
|
- Example: User can read their own User record
|
||||||
|
|
||||||
**:linked** - Only records where user_id == actor.id
|
**:linked** - Only records linked to actor via relationships
|
||||||
|
- Member: `id == actor.member_id` (User.member_id → Member.id, inverse relationship)
|
||||||
|
- CustomFieldValue: `member_id == actor.member_id` (traverses Member → User relationship)
|
||||||
- Example: User can read Member linked to their account
|
- Example: User can read Member linked to their account
|
||||||
|
|
||||||
**:all** - All records without restriction
|
**:all** - All records without restriction
|
||||||
|
|
|
||||||
747
docs/sidebar-analysis-current-state.md
Normal file
747
docs/sidebar-analysis-current-state.md
Normal file
|
|
@ -0,0 +1,747 @@
|
||||||
|
# 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**
|
||||||
|
|
||||||
|
|
||||||
1251
docs/sidebar-requirements-v2.md
Normal file
1251
docs/sidebar-requirements-v2.md
Normal file
File diff suppressed because it is too large
Load diff
233
docs/test-failures-analysis.md
Normal file
233
docs/test-failures-analysis.md
Normal file
|
|
@ -0,0 +1,233 @@
|
||||||
|
# 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
|
||||||
|
|
||||||
1576
docs/umsetzung-sidebar.md
Normal file
1576
docs/umsetzung-sidebar.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -34,12 +34,16 @@ defmodule Mv.Membership.Member do
|
||||||
"""
|
"""
|
||||||
use Ash.Resource,
|
use Ash.Resource,
|
||||||
domain: Mv.Membership,
|
domain: Mv.Membership,
|
||||||
data_layer: AshPostgres.DataLayer
|
data_layer: AshPostgres.DataLayer,
|
||||||
|
authorizers: [Ash.Policy.Authorizer]
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
import Ash.Expr
|
import Ash.Expr
|
||||||
|
alias Mv.Helpers
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
|
alias Mv.Membership.Helpers.VisibilityConfig
|
||||||
|
|
||||||
# Module constants
|
# Module constants
|
||||||
@member_search_limit 10
|
@member_search_limit 10
|
||||||
|
|
||||||
|
|
@ -116,11 +120,12 @@ defmodule Mv.Membership.Member do
|
||||||
# Only runs if membership_fee_type_id is set
|
# Only runs if membership_fee_type_id is set
|
||||||
# Note: Cycle generation runs asynchronously to not block the action,
|
# Note: Cycle generation runs asynchronously to not block the action,
|
||||||
# but in test environment it runs synchronously for DB sandbox compatibility
|
# but in test environment it runs synchronously for DB sandbox compatibility
|
||||||
change after_transaction(fn _changeset, result, _context ->
|
change after_transaction(fn changeset, result, _context ->
|
||||||
case result do
|
case result do
|
||||||
{:ok, member} ->
|
{:ok, member} ->
|
||||||
if member.membership_fee_type_id && member.join_date do
|
if member.membership_fee_type_id && member.join_date do
|
||||||
handle_cycle_generation(member)
|
actor = Map.get(changeset.context, :actor)
|
||||||
|
handle_cycle_generation(member, actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
{:error, _} ->
|
{:error, _} ->
|
||||||
|
|
@ -191,7 +196,9 @@ defmodule Mv.Membership.Member do
|
||||||
Ash.Changeset.changing_attribute?(changeset, :membership_fee_type_id)
|
Ash.Changeset.changing_attribute?(changeset, :membership_fee_type_id)
|
||||||
|
|
||||||
if fee_type_changed && member.membership_fee_type_id && member.join_date do
|
if fee_type_changed && member.membership_fee_type_id && member.join_date do
|
||||||
case regenerate_cycles_on_type_change(member) do
|
actor = Map.get(changeset.context, :actor)
|
||||||
|
|
||||||
|
case regenerate_cycles_on_type_change(member, actor: actor) do
|
||||||
{:ok, notifications} ->
|
{:ok, notifications} ->
|
||||||
# Return notifications to Ash - they will be sent automatically after commit
|
# Return notifications to Ash - they will be sent automatically after commit
|
||||||
{:ok, member, notifications}
|
{:ok, member, notifications}
|
||||||
|
|
@ -223,7 +230,8 @@ defmodule Mv.Membership.Member do
|
||||||
exit_date_changed = Ash.Changeset.changing_attribute?(changeset, :exit_date)
|
exit_date_changed = Ash.Changeset.changing_attribute?(changeset, :exit_date)
|
||||||
|
|
||||||
if (join_date_changed || exit_date_changed) && member.membership_fee_type_id do
|
if (join_date_changed || exit_date_changed) && member.membership_fee_type_id do
|
||||||
handle_cycle_generation(member)
|
actor = Map.get(changeset.context, :actor)
|
||||||
|
handle_cycle_generation(member, actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
{:error, _} ->
|
{:error, _} ->
|
||||||
|
|
@ -294,6 +302,41 @@ defmodule Mv.Membership.Member do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Authorization Policies
|
||||||
|
# Order matters: Most specific policies first, then general permission check
|
||||||
|
policies do
|
||||||
|
# SYSTEM OPERATIONS: Allow CRUD operations without actor (TEST ENVIRONMENT ONLY)
|
||||||
|
# In test: All operations allowed (for test fixtures)
|
||||||
|
# In production/dev: ALL operations denied without actor (fail-closed for security)
|
||||||
|
# NoActor.check uses compile-time environment detection to prevent security issues
|
||||||
|
bypass action_type([:create, :read, :update, :destroy]) do
|
||||||
|
description "Allow system operations without actor (test environment only)"
|
||||||
|
authorize_if Mv.Authorization.Checks.NoActor
|
||||||
|
end
|
||||||
|
|
||||||
|
# SPECIAL CASE: Users can always READ their linked member
|
||||||
|
# This allows users with ANY permission set to read their own linked member
|
||||||
|
# Check using the inverse relationship: User.member_id → Member.id
|
||||||
|
bypass action_type(:read) do
|
||||||
|
description "Users can always read member linked to their account"
|
||||||
|
authorize_if expr(id == ^actor(:member_id))
|
||||||
|
end
|
||||||
|
|
||||||
|
# GENERAL: Check permissions from user's role
|
||||||
|
# HasPermission handles update permissions correctly:
|
||||||
|
# - :own_data → can update linked member (scope :linked)
|
||||||
|
# - :read_only → cannot update any member (no update permission)
|
||||||
|
# - :normal_user → can update all members (scope :all)
|
||||||
|
# - :admin → can update all members (scope :all)
|
||||||
|
policy action_type([:read, :create, :update, :destroy]) do
|
||||||
|
description "Check permissions from user's role and permission set"
|
||||||
|
authorize_if Mv.Authorization.Checks.HasPermission
|
||||||
|
end
|
||||||
|
|
||||||
|
# DEFAULT: Forbid if no policy matched
|
||||||
|
# Ash implicitly forbids if no policy authorized
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Filters members list based on email match priority.
|
Filters members list based on email match priority.
|
||||||
|
|
||||||
|
|
@ -361,8 +404,13 @@ defmodule Mv.Membership.Member do
|
||||||
user_id = user_arg[:id]
|
user_id = user_arg[:id]
|
||||||
current_member_id = changeset.data.id
|
current_member_id = changeset.data.id
|
||||||
|
|
||||||
|
# Get actor from changeset context for authorization
|
||||||
|
# If no actor is present, this will fail in production (fail-closed)
|
||||||
|
actor = Map.get(changeset.context || %{}, :actor)
|
||||||
|
|
||||||
# Check the current state of the user in the database
|
# Check the current state of the user in the database
|
||||||
case Ash.get(Mv.Accounts.User, user_id) do
|
# Pass actor to ensure proper authorization (User might have policies in future)
|
||||||
|
case Ash.get(Mv.Accounts.User, user_id, actor: actor) do
|
||||||
# User is free to be linked
|
# User is free to be linked
|
||||||
{:ok, %{member_id: nil}} ->
|
{:ok, %{member_id: nil}} ->
|
||||||
:ok
|
:ok
|
||||||
|
|
@ -600,18 +648,21 @@ 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 = normalize_visibility_config(visibility_config)
|
normalized_config = VisibilityConfig.normalize(visibility_config)
|
||||||
|
|
||||||
# Get value from normalized config, default to true
|
# Get value from normalized config, use field-specific default
|
||||||
Map.get(normalized_config, field, true)
|
Map.get(normalized_config, field, default_visibility)
|
||||||
|
|
||||||
{:error, _} ->
|
{:error, _} ->
|
||||||
# If settings can't be loaded, default to visible
|
# If settings can't be loaded, use field-specific default
|
||||||
true
|
default_visibility
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -737,33 +788,37 @@ defmodule Mv.Membership.Member do
|
||||||
# Returns {:ok, notifications} or {:error, reason} where notifications are collected
|
# Returns {:ok, notifications} or {:error, reason} where notifications are collected
|
||||||
# to be sent after transaction commits
|
# to be sent after transaction commits
|
||||||
@doc false
|
@doc false
|
||||||
def regenerate_cycles_on_type_change(member) do
|
def regenerate_cycles_on_type_change(member, opts \\ []) do
|
||||||
today = Date.utc_today()
|
today = Date.utc_today()
|
||||||
lock_key = :erlang.phash2(member.id)
|
lock_key = :erlang.phash2(member.id)
|
||||||
|
actor = Keyword.get(opts, :actor)
|
||||||
|
|
||||||
# Use advisory lock to prevent concurrent deletion and regeneration
|
# Use advisory lock to prevent concurrent deletion and regeneration
|
||||||
# This ensures atomicity when multiple updates happen simultaneously
|
# This ensures atomicity when multiple updates happen simultaneously
|
||||||
if Mv.Repo.in_transaction?() do
|
if Mv.Repo.in_transaction?() do
|
||||||
regenerate_cycles_in_transaction(member, today, lock_key)
|
regenerate_cycles_in_transaction(member, today, lock_key, actor: actor)
|
||||||
else
|
else
|
||||||
regenerate_cycles_new_transaction(member, today, lock_key)
|
regenerate_cycles_new_transaction(member, today, lock_key, actor: actor)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Already in transaction: use advisory lock directly
|
# Already in transaction: use advisory lock directly
|
||||||
# Returns {:ok, notifications} - notifications should be returned to after_action hook
|
# Returns {:ok, notifications} - notifications should be returned to after_action hook
|
||||||
defp regenerate_cycles_in_transaction(member, today, lock_key) do
|
defp regenerate_cycles_in_transaction(member, today, lock_key, opts) do
|
||||||
|
actor = Keyword.get(opts, :actor)
|
||||||
Ecto.Adapters.SQL.query!(Mv.Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
|
Ecto.Adapters.SQL.query!(Mv.Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
|
||||||
do_regenerate_cycles_on_type_change(member, today, skip_lock?: true)
|
do_regenerate_cycles_on_type_change(member, today, skip_lock?: true, actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Not in transaction: start new transaction with advisory lock
|
# Not in transaction: start new transaction with advisory lock
|
||||||
# Returns {:ok, notifications} - notifications should be sent by caller (e.g., via after_action)
|
# Returns {:ok, notifications} - notifications should be sent by caller (e.g., via after_action)
|
||||||
defp regenerate_cycles_new_transaction(member, today, lock_key) do
|
defp regenerate_cycles_new_transaction(member, today, lock_key, opts) do
|
||||||
|
actor = Keyword.get(opts, :actor)
|
||||||
|
|
||||||
Mv.Repo.transaction(fn ->
|
Mv.Repo.transaction(fn ->
|
||||||
Ecto.Adapters.SQL.query!(Mv.Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
|
Ecto.Adapters.SQL.query!(Mv.Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
|
||||||
|
|
||||||
case do_regenerate_cycles_on_type_change(member, today, skip_lock?: true) do
|
case do_regenerate_cycles_on_type_change(member, today, skip_lock?: true, actor: actor) do
|
||||||
{:ok, notifications} ->
|
{:ok, notifications} ->
|
||||||
# Return notifications - they will be sent by the caller
|
# Return notifications - they will be sent by the caller
|
||||||
notifications
|
notifications
|
||||||
|
|
@ -785,6 +840,7 @@ defmodule Mv.Membership.Member do
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
skip_lock? = Keyword.get(opts, :skip_lock?, false)
|
skip_lock? = Keyword.get(opts, :skip_lock?, false)
|
||||||
|
actor = Keyword.get(opts, :actor)
|
||||||
|
|
||||||
# Find all unpaid cycles for this member
|
# Find all unpaid cycles for this member
|
||||||
# We need to check cycle_end for each cycle using its own interval
|
# We need to check cycle_end for each cycle using its own interval
|
||||||
|
|
@ -794,10 +850,21 @@ defmodule Mv.Membership.Member do
|
||||||
|> Ash.Query.filter(status == :unpaid)
|
|> Ash.Query.filter(status == :unpaid)
|
||||||
|> Ash.Query.load([:membership_fee_type])
|
|> Ash.Query.load([:membership_fee_type])
|
||||||
|
|
||||||
case Ash.read(all_unpaid_cycles_query) do
|
result =
|
||||||
|
if actor do
|
||||||
|
Ash.read(all_unpaid_cycles_query, actor: actor)
|
||||||
|
else
|
||||||
|
Ash.read(all_unpaid_cycles_query)
|
||||||
|
end
|
||||||
|
|
||||||
|
case result do
|
||||||
{:ok, all_unpaid_cycles} ->
|
{:ok, all_unpaid_cycles} ->
|
||||||
cycles_to_delete = filter_future_cycles(all_unpaid_cycles, today)
|
cycles_to_delete = filter_future_cycles(all_unpaid_cycles, today)
|
||||||
delete_and_regenerate_cycles(cycles_to_delete, member.id, today, skip_lock?: skip_lock?)
|
|
||||||
|
delete_and_regenerate_cycles(cycles_to_delete, member.id, today,
|
||||||
|
skip_lock?: skip_lock?,
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
{:error, reason}
|
{:error, reason}
|
||||||
|
|
@ -826,13 +893,14 @@ defmodule Mv.Membership.Member do
|
||||||
# Returns {:ok, notifications} or {:error, reason}
|
# Returns {:ok, notifications} or {:error, reason}
|
||||||
defp delete_and_regenerate_cycles(cycles_to_delete, member_id, today, opts) do
|
defp delete_and_regenerate_cycles(cycles_to_delete, member_id, today, opts) do
|
||||||
skip_lock? = Keyword.get(opts, :skip_lock?, false)
|
skip_lock? = Keyword.get(opts, :skip_lock?, false)
|
||||||
|
actor = Keyword.get(opts, :actor)
|
||||||
|
|
||||||
if Enum.empty?(cycles_to_delete) do
|
if Enum.empty?(cycles_to_delete) do
|
||||||
# No cycles to delete, just regenerate
|
# No cycles to delete, just regenerate
|
||||||
regenerate_cycles(member_id, today, skip_lock?: skip_lock?)
|
regenerate_cycles(member_id, today, skip_lock?: skip_lock?, actor: actor)
|
||||||
else
|
else
|
||||||
case delete_cycles(cycles_to_delete) do
|
case delete_cycles(cycles_to_delete) do
|
||||||
:ok -> regenerate_cycles(member_id, today, skip_lock?: skip_lock?)
|
:ok -> regenerate_cycles(member_id, today, skip_lock?: skip_lock?, actor: actor)
|
||||||
{:error, reason} -> {:error, reason}
|
{:error, reason} -> {:error, reason}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -858,11 +926,13 @@ defmodule Mv.Membership.Member do
|
||||||
# Returns {:ok, notifications} - notifications should be returned to after_action hook
|
# Returns {:ok, notifications} - notifications should be returned to after_action hook
|
||||||
defp regenerate_cycles(member_id, today, opts) do
|
defp regenerate_cycles(member_id, today, opts) do
|
||||||
skip_lock? = Keyword.get(opts, :skip_lock?, false)
|
skip_lock? = Keyword.get(opts, :skip_lock?, false)
|
||||||
|
actor = Keyword.get(opts, :actor)
|
||||||
|
|
||||||
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(
|
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(
|
||||||
member_id,
|
member_id,
|
||||||
today: today,
|
today: today,
|
||||||
skip_lock?: skip_lock?
|
skip_lock?: skip_lock?,
|
||||||
|
actor: actor
|
||||||
) do
|
) do
|
||||||
{:ok, _cycles, notifications} when is_list(notifications) ->
|
{:ok, _cycles, notifications} when is_list(notifications) ->
|
||||||
{:ok, notifications}
|
{:ok, notifications}
|
||||||
|
|
@ -876,21 +946,25 @@ defmodule Mv.Membership.Member do
|
||||||
# based on environment (test vs production)
|
# based on environment (test vs production)
|
||||||
# This function encapsulates the common logic for cycle generation
|
# This function encapsulates the common logic for cycle generation
|
||||||
# to avoid code duplication across different hooks
|
# to avoid code duplication across different hooks
|
||||||
defp handle_cycle_generation(member) do
|
defp handle_cycle_generation(member, opts) do
|
||||||
|
actor = Keyword.get(opts, :actor)
|
||||||
|
|
||||||
if Mv.Config.sql_sandbox?() do
|
if Mv.Config.sql_sandbox?() do
|
||||||
handle_cycle_generation_sync(member)
|
handle_cycle_generation_sync(member, actor: actor)
|
||||||
else
|
else
|
||||||
handle_cycle_generation_async(member)
|
handle_cycle_generation_async(member, actor: actor)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Runs cycle generation synchronously (for test environment)
|
# Runs cycle generation synchronously (for test environment)
|
||||||
defp handle_cycle_generation_sync(member) do
|
defp handle_cycle_generation_sync(member, opts) do
|
||||||
require Logger
|
require Logger
|
||||||
|
actor = Keyword.get(opts, :actor)
|
||||||
|
|
||||||
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(
|
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(
|
||||||
member.id,
|
member.id,
|
||||||
today: Date.utc_today()
|
today: Date.utc_today(),
|
||||||
|
actor: actor
|
||||||
) do
|
) do
|
||||||
{:ok, cycles, notifications} ->
|
{:ok, cycles, notifications} ->
|
||||||
send_notifications_if_any(notifications)
|
send_notifications_if_any(notifications)
|
||||||
|
|
@ -902,9 +976,11 @@ defmodule Mv.Membership.Member do
|
||||||
end
|
end
|
||||||
|
|
||||||
# Runs cycle generation asynchronously (for production environment)
|
# Runs cycle generation asynchronously (for production environment)
|
||||||
defp handle_cycle_generation_async(member) do
|
defp handle_cycle_generation_async(member, opts) do
|
||||||
|
actor = Keyword.get(opts, :actor)
|
||||||
|
|
||||||
Task.Supervisor.async_nolink(Mv.TaskSupervisor, fn ->
|
Task.Supervisor.async_nolink(Mv.TaskSupervisor, fn ->
|
||||||
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id) do
|
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id, actor: actor) do
|
||||||
{:ok, cycles, notifications} ->
|
{:ok, cycles, notifications} ->
|
||||||
send_notifications_if_any(notifications)
|
send_notifications_if_any(notifications)
|
||||||
log_cycle_generation_success(member, cycles, notifications, sync: false)
|
log_cycle_generation_success(member, cycles, notifications, sync: false)
|
||||||
|
|
@ -956,29 +1032,6 @@ 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.
|
||||||
|
|
||||||
|
|
@ -1156,15 +1209,18 @@ defmodule Mv.Membership.Member do
|
||||||
custom_field_values_arg = Ash.Changeset.get_argument(changeset, :custom_field_values)
|
custom_field_values_arg = Ash.Changeset.get_argument(changeset, :custom_field_values)
|
||||||
|
|
||||||
if is_nil(custom_field_values_arg) do
|
if is_nil(custom_field_values_arg) do
|
||||||
extract_existing_values(changeset.data)
|
extract_existing_values(changeset.data, changeset)
|
||||||
else
|
else
|
||||||
extract_argument_values(custom_field_values_arg)
|
extract_argument_values(custom_field_values_arg)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Extracts custom field values from existing member data (update scenario)
|
# Extracts custom field values from existing member data (update scenario)
|
||||||
defp extract_existing_values(member_data) do
|
defp extract_existing_values(member_data, changeset) do
|
||||||
case Ash.load(member_data, :custom_field_values) do
|
actor = Map.get(changeset.context, :actor)
|
||||||
|
opts = Helpers.ash_actor_opts(actor)
|
||||||
|
|
||||||
|
case Ash.load(member_data, :custom_field_values, opts) do
|
||||||
{:ok, %{custom_field_values: existing_values}} ->
|
{:ok, %{custom_field_values: existing_values}} ->
|
||||||
Enum.reduce(existing_values, %{}, &extract_value_from_cfv/2)
|
Enum.reduce(existing_values, %{}, &extract_value_from_cfv/2)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,9 @@ 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
|
||||||
|
|
||||||
|
|
@ -89,7 +92,10 @@ 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, %{club_name: default_club_name})
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
@ -183,4 +189,42 @@ 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,6 +91,16 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,164 @@
|
||||||
|
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
|
||||||
|
|
@ -21,8 +21,8 @@ defmodule Mv.Authorization.Checks.HasPermission do
|
||||||
- **:all** - Authorizes without filtering (returns all records)
|
- **:all** - Authorizes without filtering (returns all records)
|
||||||
- **:own** - Filters to records where record.id == actor.id
|
- **:own** - Filters to records where record.id == actor.id
|
||||||
- **:linked** - Filters based on resource type:
|
- **:linked** - Filters based on resource type:
|
||||||
- Member: member.user.id == actor.id (via has_one :user relationship)
|
- Member: `id == actor.member_id` (User.member_id → Member.id, inverse relationship)
|
||||||
- CustomFieldValue: custom_field_value.member.user.id == actor.id (traverses member → user relationship!)
|
- CustomFieldValue: `member_id == actor.member_id` (CustomFieldValue.member_id → Member.id → User.member_id)
|
||||||
|
|
||||||
## Error Handling
|
## Error Handling
|
||||||
|
|
||||||
|
|
@ -59,6 +59,7 @@ defmodule Mv.Authorization.Checks.HasPermission do
|
||||||
def strict_check(actor, authorizer, _opts) do
|
def strict_check(actor, authorizer, _opts) do
|
||||||
resource = authorizer.resource
|
resource = authorizer.resource
|
||||||
action = get_action_from_authorizer(authorizer)
|
action = get_action_from_authorizer(authorizer)
|
||||||
|
record = get_record_from_authorizer(authorizer)
|
||||||
|
|
||||||
cond do
|
cond do
|
||||||
is_nil(actor) ->
|
is_nil(actor) ->
|
||||||
|
|
@ -76,12 +77,12 @@ defmodule Mv.Authorization.Checks.HasPermission do
|
||||||
{:ok, false}
|
{:ok, false}
|
||||||
|
|
||||||
true ->
|
true ->
|
||||||
strict_check_with_permissions(actor, resource, action)
|
strict_check_with_permissions(actor, resource, action, record)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper function to reduce nesting depth
|
# Helper function to reduce nesting depth
|
||||||
defp strict_check_with_permissions(actor, resource, action) do
|
defp strict_check_with_permissions(actor, resource, action, record) do
|
||||||
with %{role: %{permission_set_name: ps_name}} when not is_nil(ps_name) <- actor,
|
with %{role: %{permission_set_name: ps_name}} when not is_nil(ps_name) <- actor,
|
||||||
{:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name),
|
{:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name),
|
||||||
permissions <- PermissionSets.get_permissions(ps_atom),
|
permissions <- PermissionSets.get_permissions(ps_atom),
|
||||||
|
|
@ -93,9 +94,15 @@ defmodule Mv.Authorization.Checks.HasPermission do
|
||||||
actor,
|
actor,
|
||||||
resource_name
|
resource_name
|
||||||
) do
|
) do
|
||||||
:authorized -> {:ok, true}
|
:authorized ->
|
||||||
{:filter, _} -> {:ok, :unknown}
|
{:ok, true}
|
||||||
false -> {:ok, false}
|
|
||||||
|
{:filter, filter_expr} ->
|
||||||
|
# For strict_check on single records, evaluate the filter against the record
|
||||||
|
evaluate_filter_for_strict_check(filter_expr, actor, record, resource_name)
|
||||||
|
|
||||||
|
false ->
|
||||||
|
{:ok, false}
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
%{role: nil} ->
|
%{role: nil} ->
|
||||||
|
|
@ -122,9 +129,17 @@ defmodule Mv.Authorization.Checks.HasPermission do
|
||||||
action = get_action_from_authorizer(authorizer)
|
action = get_action_from_authorizer(authorizer)
|
||||||
|
|
||||||
cond do
|
cond do
|
||||||
is_nil(actor) -> nil
|
is_nil(actor) ->
|
||||||
is_nil(action) -> nil
|
# No actor - deny access (fail-closed)
|
||||||
true -> auto_filter_with_permissions(actor, resource, action)
|
# Return filter that never matches (expr(false) = match none)
|
||||||
|
deny_filter()
|
||||||
|
|
||||||
|
is_nil(action) ->
|
||||||
|
# Cannot determine action - deny access (fail-closed)
|
||||||
|
deny_filter()
|
||||||
|
|
||||||
|
true ->
|
||||||
|
auto_filter_with_permissions(actor, resource, action)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -141,21 +156,97 @@ defmodule Mv.Authorization.Checks.HasPermission do
|
||||||
actor,
|
actor,
|
||||||
resource_name
|
resource_name
|
||||||
) do
|
) do
|
||||||
:authorized -> nil
|
:authorized ->
|
||||||
{:filter, filter_expr} -> filter_expr
|
# :all scope - allow all records (no filter)
|
||||||
false -> nil
|
# Return empty keyword list (no filtering)
|
||||||
|
[]
|
||||||
|
|
||||||
|
{:filter, filter_expr} ->
|
||||||
|
# :linked or :own scope - apply filter
|
||||||
|
# filter_expr is a keyword list from expr(...), return it directly
|
||||||
|
filter_expr
|
||||||
|
|
||||||
|
false ->
|
||||||
|
# No permission - deny access (fail-closed)
|
||||||
|
deny_filter()
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
|
_ ->
|
||||||
|
# Error case (no role, invalid permission set, etc.) - deny access (fail-closed)
|
||||||
|
deny_filter()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Helper function to return a filter that never matches (deny all records)
|
||||||
|
# Used when authorization should be denied (fail-closed)
|
||||||
|
#
|
||||||
|
# Using `expr(false)` avoids depending on the primary key being named `:id`.
|
||||||
|
# This is more robust than [id: {:in, []}] which assumes the primary key is `:id`.
|
||||||
|
defp deny_filter do
|
||||||
|
expr(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Helper to extract action type from authorizer
|
||||||
|
# CRITICAL: Must use action_type, not action.name!
|
||||||
|
# Action types: :create, :read, :update, :destroy
|
||||||
|
# Action names: :create_member, :update_member, etc.
|
||||||
|
# PermissionSets uses action types, not action names
|
||||||
|
#
|
||||||
|
# Prefer authorizer.action.type (stable API) over authorizer.subject (varies by context)
|
||||||
|
defp get_action_from_authorizer(authorizer) do
|
||||||
|
# Primary: Use authorizer.action.type (stable API)
|
||||||
|
case Map.get(authorizer, :action) do
|
||||||
|
%{type: action_type} when action_type in [:create, :read, :update, :destroy] ->
|
||||||
|
action_type
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
# Fallback: Try authorizer.subject (for compatibility with different Ash versions/contexts)
|
||||||
|
case Map.get(authorizer, :subject) do
|
||||||
|
%{action_type: action_type} when action_type in [:create, :read, :update, :destroy] ->
|
||||||
|
action_type
|
||||||
|
|
||||||
|
%{action: %{type: action_type}}
|
||||||
|
when action_type in [:create, :read, :update, :destroy] ->
|
||||||
|
action_type
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Helper to extract record from authorizer for strict_check
|
||||||
|
defp get_record_from_authorizer(authorizer) do
|
||||||
|
case authorizer.subject do
|
||||||
|
%{data: data} when not is_nil(data) -> data
|
||||||
_ -> nil
|
_ -> nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper to extract action from authorizer
|
# Evaluate filter expression for strict_check on single records
|
||||||
defp get_action_from_authorizer(authorizer) do
|
# For :linked scope with Member resource: id == actor.member_id
|
||||||
case authorizer.subject do
|
defp evaluate_filter_for_strict_check(_filter_expr, actor, record, resource_name) do
|
||||||
%{action: %{name: action}} -> action
|
case {resource_name, record} do
|
||||||
%{action: action} when is_atom(action) -> action
|
{"Member", %{id: member_id}} when not is_nil(member_id) ->
|
||||||
_ -> nil
|
# Check if this member's ID matches the actor's member_id
|
||||||
|
if member_id == actor.member_id do
|
||||||
|
{:ok, true}
|
||||||
|
else
|
||||||
|
{:ok, false}
|
||||||
|
end
|
||||||
|
|
||||||
|
{"CustomFieldValue", %{member_id: cfv_member_id}} when not is_nil(cfv_member_id) ->
|
||||||
|
# Check if this CFV's member_id matches the actor's member_id
|
||||||
|
if cfv_member_id == actor.member_id do
|
||||||
|
{:ok, true}
|
||||||
|
else
|
||||||
|
{:ok, false}
|
||||||
|
end
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
# For other cases or when record is not available, return :unknown
|
||||||
|
# This will cause Ash to use auto_filter instead
|
||||||
|
{:ok, :unknown}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -190,21 +281,24 @@ defmodule Mv.Authorization.Checks.HasPermission do
|
||||||
end
|
end
|
||||||
|
|
||||||
# Scope: linked - Filter based on user relationship (resource-specific!)
|
# Scope: linked - Filter based on user relationship (resource-specific!)
|
||||||
# Uses Ash relationships: Member has_one :user, CustomFieldValue belongs_to :member
|
# IMPORTANT: Understand the relationship direction!
|
||||||
|
# - User belongs_to :member (User.member_id → Member.id)
|
||||||
|
# - Member has_one :user (inverse, no FK on Member)
|
||||||
defp apply_scope(:linked, actor, resource_name) do
|
defp apply_scope(:linked, actor, resource_name) do
|
||||||
case resource_name do
|
case resource_name do
|
||||||
"Member" ->
|
"Member" ->
|
||||||
# Member has_one :user → filter by user.id == actor.id
|
# User.member_id → Member.id (inverse relationship)
|
||||||
{:filter, expr(user.id == ^actor.id)}
|
# Filter: member.id == actor.member_id
|
||||||
|
{:filter, expr(id == ^actor.member_id)}
|
||||||
|
|
||||||
"CustomFieldValue" ->
|
"CustomFieldValue" ->
|
||||||
# CustomFieldValue belongs_to :member → member has_one :user
|
# CustomFieldValue.member_id → Member.id → User.member_id
|
||||||
# Traverse: custom_field_value.member.user.id == actor.id
|
# Filter: custom_field_value.member_id == actor.member_id
|
||||||
{:filter, expr(member.user.id == ^actor.id)}
|
{:filter, expr(member_id == ^actor.member_id)}
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
# Fallback for other resources: try user relationship first, then user_id
|
# Fallback for other resources
|
||||||
{:filter, expr(user.id == ^actor.id or user_id == ^actor.id)}
|
{:filter, expr(user_id == ^actor.id)}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
74
lib/mv/authorization/checks/no_actor.ex
Normal file
74
lib/mv/authorization/checks/no_actor.ex
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
defmodule Mv.Authorization.Checks.NoActor do
|
||||||
|
@moduledoc """
|
||||||
|
Custom Ash Policy Check that allows actions when no actor is present.
|
||||||
|
|
||||||
|
**IMPORTANT:** This check ONLY works in test environment for security reasons.
|
||||||
|
In production/dev, ALL operations without an actor are denied.
|
||||||
|
|
||||||
|
## Security Note
|
||||||
|
|
||||||
|
This check uses compile-time environment detection to prevent accidental
|
||||||
|
security issues in production. In production, ALL operations (including :create
|
||||||
|
and :read) will be denied if no actor is present.
|
||||||
|
|
||||||
|
For seeds and system operations in production, use an admin actor instead:
|
||||||
|
|
||||||
|
admin_user = get_admin_user()
|
||||||
|
Ash.create!(resource, attrs, actor: admin_user)
|
||||||
|
|
||||||
|
## Usage in Policies
|
||||||
|
|
||||||
|
policies do
|
||||||
|
# Allow system operations without actor (TEST ENVIRONMENT ONLY)
|
||||||
|
# In test: All operations allowed
|
||||||
|
# In production: ALL operations denied (fail-closed)
|
||||||
|
bypass action_type([:create, :read, :update, :destroy]) do
|
||||||
|
authorize_if NoActor
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check permissions when actor is present
|
||||||
|
policy action_type([:read, :create, :update, :destroy]) do
|
||||||
|
authorize_if HasPermission
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
## Behavior
|
||||||
|
|
||||||
|
- In test environment: Returns `true` when actor is nil (allows all operations)
|
||||||
|
- In production/dev: Returns `false` when actor is nil (denies all operations - fail-closed)
|
||||||
|
- Returns `false` when actor is present (delegates to other policies)
|
||||||
|
"""
|
||||||
|
|
||||||
|
use Ash.Policy.SimpleCheck
|
||||||
|
|
||||||
|
# Compile-time check: Only allow no-actor bypass in test environment
|
||||||
|
@allow_no_actor_bypass Mix.env() == :test
|
||||||
|
# Alternative (if you want to control via config):
|
||||||
|
# @allow_no_actor_bypass Application.compile_env(:mv, :allow_no_actor_bypass, false)
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def describe(_opts) do
|
||||||
|
if @allow_no_actor_bypass do
|
||||||
|
"allows actions when no actor is present (test environment only)"
|
||||||
|
else
|
||||||
|
"denies all actions when no actor is present (production/dev - fail-closed)"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def match?(nil, _context, _opts) do
|
||||||
|
# Actor is nil
|
||||||
|
if @allow_no_actor_bypass do
|
||||||
|
# Test environment: Allow all operations
|
||||||
|
true
|
||||||
|
else
|
||||||
|
# Production/dev: Deny all operations (fail-closed for security)
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def match?(_actor, _context, _opts) do
|
||||||
|
# Actor is present - don't match (let other policies decide)
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -41,8 +41,10 @@ defmodule Mv.EmailSync.Changes.SyncMemberEmailToUser do
|
||||||
Ash.Changeset.around_transaction(changeset, fn cs, callback ->
|
Ash.Changeset.around_transaction(changeset, fn cs, callback ->
|
||||||
result = callback.(cs)
|
result = callback.(cs)
|
||||||
|
|
||||||
|
actor = Map.get(changeset.context, :actor)
|
||||||
|
|
||||||
with {:ok, member} <- Helpers.extract_record(result),
|
with {:ok, member} <- Helpers.extract_record(result),
|
||||||
linked_user <- Loader.get_linked_user(member) do
|
linked_user <- Loader.get_linked_user(member, actor) do
|
||||||
Helpers.sync_email_to_linked_record(result, linked_user, new_email)
|
Helpers.sync_email_to_linked_record(result, linked_user, new_email)
|
||||||
else
|
else
|
||||||
_ -> result
|
_ -> result
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,17 @@ defmodule Mv.EmailSync.Changes.SyncUserEmailToMember do
|
||||||
if Map.get(context, :syncing_email, false) do
|
if Map.get(context, :syncing_email, false) do
|
||||||
changeset
|
changeset
|
||||||
else
|
else
|
||||||
sync_email(changeset)
|
# Ensure actor is in changeset context - get it from context if available
|
||||||
|
actor = Map.get(changeset.context, :actor) || Map.get(context, :actor)
|
||||||
|
|
||||||
|
changeset_with_actor =
|
||||||
|
if actor && !Map.has_key?(changeset.context, :actor) do
|
||||||
|
Ash.Changeset.put_context(changeset, :actor, actor)
|
||||||
|
else
|
||||||
|
changeset
|
||||||
|
end
|
||||||
|
|
||||||
|
sync_email(changeset_with_actor)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -42,7 +52,7 @@ defmodule Mv.EmailSync.Changes.SyncUserEmailToMember do
|
||||||
result = callback.(cs)
|
result = callback.(cs)
|
||||||
|
|
||||||
with {:ok, record} <- Helpers.extract_record(result),
|
with {:ok, record} <- Helpers.extract_record(result),
|
||||||
{:ok, user, member} <- get_user_and_member(record) do
|
{:ok, user, member} <- get_user_and_member(record, cs) do
|
||||||
# When called from Member-side, we need to update the member in the result
|
# When called from Member-side, we need to update the member in the result
|
||||||
# When called from User-side, we update the linked member in DB only
|
# When called from User-side, we update the linked member in DB only
|
||||||
case record do
|
case record do
|
||||||
|
|
@ -61,15 +71,19 @@ defmodule Mv.EmailSync.Changes.SyncUserEmailToMember do
|
||||||
end
|
end
|
||||||
|
|
||||||
# Retrieves user and member - works for both resource types
|
# Retrieves user and member - works for both resource types
|
||||||
defp get_user_and_member(%Mv.Accounts.User{} = user) do
|
defp get_user_and_member(%Mv.Accounts.User{} = user, changeset) do
|
||||||
case Loader.get_linked_member(user) do
|
actor = Map.get(changeset.context, :actor)
|
||||||
|
|
||||||
|
case Loader.get_linked_member(user, actor) do
|
||||||
nil -> {:error, :no_member}
|
nil -> {:error, :no_member}
|
||||||
member -> {:ok, user, member}
|
member -> {:ok, user, member}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp get_user_and_member(%Mv.Membership.Member{} = member) do
|
defp get_user_and_member(%Mv.Membership.Member{} = member, changeset) do
|
||||||
case Loader.load_linked_user!(member) do
|
actor = Map.get(changeset.context, :actor)
|
||||||
|
|
||||||
|
case Loader.load_linked_user!(member, actor) do
|
||||||
{:ok, user} -> {:ok, user, member}
|
{:ok, user} -> {:ok, user, member}
|
||||||
error -> error
|
error -> error
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,30 @@ defmodule Mv.EmailSync.Loader do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Helper functions for loading linked records in email synchronization.
|
Helper functions for loading linked records in email synchronization.
|
||||||
Centralizes the logic for retrieving related User/Member entities.
|
Centralizes the logic for retrieving related User/Member entities.
|
||||||
|
|
||||||
|
## Authorization
|
||||||
|
|
||||||
|
This module runs systemically and accepts optional actor parameters.
|
||||||
|
When called from hooks/changes, actor is extracted from changeset context.
|
||||||
|
When called directly, actor should be provided for proper authorization.
|
||||||
|
|
||||||
|
All functions accept an optional `actor` parameter that is passed to Ash operations
|
||||||
|
to ensure proper authorization checks are performed.
|
||||||
"""
|
"""
|
||||||
|
alias Mv.Helpers
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Loads the member linked to a user, returns nil if not linked or on error.
|
Loads the member linked to a user, returns nil if not linked or on error.
|
||||||
"""
|
|
||||||
def get_linked_member(%{member_id: nil}), do: nil
|
|
||||||
|
|
||||||
def get_linked_member(%{member_id: id}) do
|
Accepts optional actor for authorization.
|
||||||
case Ash.get(Mv.Membership.Member, id) do
|
"""
|
||||||
|
def get_linked_member(user, actor \\ nil)
|
||||||
|
def get_linked_member(%{member_id: nil}, _actor), do: nil
|
||||||
|
|
||||||
|
def get_linked_member(%{member_id: id}, actor) do
|
||||||
|
opts = Helpers.ash_actor_opts(actor)
|
||||||
|
|
||||||
|
case Ash.get(Mv.Membership.Member, id, opts) do
|
||||||
{:ok, member} -> member
|
{:ok, member} -> member
|
||||||
{:error, _} -> nil
|
{:error, _} -> nil
|
||||||
end
|
end
|
||||||
|
|
@ -18,9 +33,13 @@ defmodule Mv.EmailSync.Loader do
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Loads the user linked to a member, returns nil if not linked or on error.
|
Loads the user linked to a member, returns nil if not linked or on error.
|
||||||
|
|
||||||
|
Accepts optional actor for authorization.
|
||||||
"""
|
"""
|
||||||
def get_linked_user(member) do
|
def get_linked_user(member, actor \\ nil) do
|
||||||
case Ash.load(member, :user) do
|
opts = Helpers.ash_actor_opts(actor)
|
||||||
|
|
||||||
|
case Ash.load(member, :user, opts) do
|
||||||
{:ok, %{user: user}} -> user
|
{:ok, %{user: user}} -> user
|
||||||
{:error, _} -> nil
|
{:error, _} -> nil
|
||||||
end
|
end
|
||||||
|
|
@ -29,9 +48,13 @@ defmodule Mv.EmailSync.Loader do
|
||||||
@doc """
|
@doc """
|
||||||
Loads the user linked to a member, returning an error tuple if not linked.
|
Loads the user linked to a member, returning an error tuple if not linked.
|
||||||
Useful when a link is required for the operation.
|
Useful when a link is required for the operation.
|
||||||
|
|
||||||
|
Accepts optional actor for authorization.
|
||||||
"""
|
"""
|
||||||
def load_linked_user!(member) do
|
def load_linked_user!(member, actor \\ nil) do
|
||||||
case Ash.load(member, :user) do
|
opts = Helpers.ash_actor_opts(actor)
|
||||||
|
|
||||||
|
case Ash.load(member, :user, opts) do
|
||||||
{:ok, %{user: user}} when not is_nil(user) -> {:ok, user}
|
{:ok, %{user: user}} when not is_nil(user) -> {:ok, user}
|
||||||
{:ok, _} -> {:error, :no_linked_user}
|
{:ok, _} -> {:error, :no_linked_user}
|
||||||
{:error, _} = error -> error
|
{:error, _} = error -> error
|
||||||
|
|
|
||||||
27
lib/mv/helpers.ex
Normal file
27
lib/mv/helpers.ex
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
defmodule Mv.Helpers do
|
||||||
|
@moduledoc """
|
||||||
|
Shared helper functions used across the Mv application.
|
||||||
|
|
||||||
|
Provides utilities that are not specific to a single domain or layer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Converts an actor to Ash options list for authorization.
|
||||||
|
Returns empty list if actor is nil.
|
||||||
|
|
||||||
|
This helper ensures consistent actor handling across all Ash operations
|
||||||
|
in the application, whether called from LiveViews, changes, validations,
|
||||||
|
or other contexts.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
opts = ash_actor_opts(actor)
|
||||||
|
Ash.read(query, opts)
|
||||||
|
|
||||||
|
opts = ash_actor_opts(nil)
|
||||||
|
# => []
|
||||||
|
"""
|
||||||
|
@spec ash_actor_opts(Mv.Accounts.User.t() | nil) :: keyword()
|
||||||
|
def ash_actor_opts(nil), do: []
|
||||||
|
def ash_actor_opts(actor) when not is_nil(actor), do: [actor: actor]
|
||||||
|
end
|
||||||
49
lib/mv/helpers/type_parsers.ex
Normal file
49
lib/mv/helpers/type_parsers.ex
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
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
|
||||||
55
lib/mv/membership/helpers/visibility_config.ex
Normal file
55
lib/mv/membership/helpers/visibility_config.ex
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
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
|
||||||
|
|
@ -8,6 +8,7 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
|
||||||
This allows creating members with the same email as unlinked users.
|
This allows creating members with the same email as unlinked users.
|
||||||
"""
|
"""
|
||||||
use Ash.Resource.Validation
|
use Ash.Resource.Validation
|
||||||
|
alias Mv.Helpers
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Validates email uniqueness across linked Member-User pairs.
|
Validates email uniqueness across linked Member-User pairs.
|
||||||
|
|
@ -29,7 +30,8 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
|
||||||
def validate(changeset, _opts, _context) do
|
def validate(changeset, _opts, _context) do
|
||||||
email_changing? = Ash.Changeset.changing_attribute?(changeset, :email)
|
email_changing? = Ash.Changeset.changing_attribute?(changeset, :email)
|
||||||
|
|
||||||
linked_user_id = get_linked_user_id(changeset.data)
|
actor = Map.get(changeset.context || %{}, :actor)
|
||||||
|
linked_user_id = get_linked_user_id(changeset.data, actor)
|
||||||
is_linked? = not is_nil(linked_user_id)
|
is_linked? = not is_nil(linked_user_id)
|
||||||
|
|
||||||
# Only validate if member is already linked AND email is changing
|
# Only validate if member is already linked AND email is changing
|
||||||
|
|
@ -38,19 +40,21 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
|
||||||
|
|
||||||
if should_validate? do
|
if should_validate? do
|
||||||
new_email = Ash.Changeset.get_attribute(changeset, :email)
|
new_email = Ash.Changeset.get_attribute(changeset, :email)
|
||||||
check_email_uniqueness(new_email, linked_user_id)
|
check_email_uniqueness(new_email, linked_user_id, actor)
|
||||||
else
|
else
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp check_email_uniqueness(email, exclude_user_id) do
|
defp check_email_uniqueness(email, exclude_user_id, actor) do
|
||||||
query =
|
query =
|
||||||
Mv.Accounts.User
|
Mv.Accounts.User
|
||||||
|> Ash.Query.filter(email == ^email)
|
|> Ash.Query.filter(email == ^email)
|
||||||
|> maybe_exclude_id(exclude_user_id)
|
|> maybe_exclude_id(exclude_user_id)
|
||||||
|
|
||||||
case Ash.read(query) do
|
opts = Helpers.ash_actor_opts(actor)
|
||||||
|
|
||||||
|
case Ash.read(query, opts) do
|
||||||
{:ok, []} ->
|
{:ok, []} ->
|
||||||
:ok
|
:ok
|
||||||
|
|
||||||
|
|
@ -65,8 +69,10 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
|
||||||
defp maybe_exclude_id(query, nil), do: query
|
defp maybe_exclude_id(query, nil), do: query
|
||||||
defp maybe_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id)
|
defp maybe_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id)
|
||||||
|
|
||||||
defp get_linked_user_id(member_data) do
|
defp get_linked_user_id(member_data, actor) do
|
||||||
case Ash.load(member_data, :user) do
|
opts = Helpers.ash_actor_opts(actor)
|
||||||
|
|
||||||
|
case Ash.load(member_data, :user, opts) do
|
||||||
{:ok, %{user: %{id: id}}} -> id
|
{:ok, %{user: %{id: id}}} -> id
|
||||||
_ -> nil
|
_ -> nil
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,15 @@ defmodule Mv.MembershipFees.CycleGenerator do
|
||||||
Uses PostgreSQL advisory locks to prevent race conditions when generating
|
Uses PostgreSQL advisory locks to prevent race conditions when generating
|
||||||
cycles for the same member concurrently.
|
cycles for the same member concurrently.
|
||||||
|
|
||||||
|
## Authorization
|
||||||
|
|
||||||
|
This module runs systemically and accepts optional actor parameters.
|
||||||
|
When called from hooks/changes, actor is extracted from changeset context.
|
||||||
|
When called directly, actor should be provided for proper authorization.
|
||||||
|
|
||||||
|
All functions accept an optional `actor` parameter in the `opts` keyword list
|
||||||
|
that is passed to Ash operations to ensure proper authorization checks are performed.
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
# Generate cycles for a single member
|
# Generate cycles for a single member
|
||||||
|
|
@ -77,7 +86,9 @@ defmodule Mv.MembershipFees.CycleGenerator do
|
||||||
def generate_cycles_for_member(member_or_id, opts \\ [])
|
def generate_cycles_for_member(member_or_id, opts \\ [])
|
||||||
|
|
||||||
def generate_cycles_for_member(member_id, opts) when is_binary(member_id) do
|
def generate_cycles_for_member(member_id, opts) when is_binary(member_id) do
|
||||||
case load_member(member_id) do
|
actor = Keyword.get(opts, :actor)
|
||||||
|
|
||||||
|
case load_member(member_id, actor: actor) do
|
||||||
{:ok, member} -> generate_cycles_for_member(member, opts)
|
{:ok, member} -> generate_cycles_for_member(member, opts)
|
||||||
{:error, reason} -> {:error, reason}
|
{:error, reason} -> {:error, reason}
|
||||||
end
|
end
|
||||||
|
|
@ -87,25 +98,27 @@ defmodule Mv.MembershipFees.CycleGenerator do
|
||||||
today = Keyword.get(opts, :today, Date.utc_today())
|
today = Keyword.get(opts, :today, Date.utc_today())
|
||||||
skip_lock? = Keyword.get(opts, :skip_lock?, false)
|
skip_lock? = Keyword.get(opts, :skip_lock?, false)
|
||||||
|
|
||||||
do_generate_cycles_with_lock(member, today, skip_lock?)
|
do_generate_cycles_with_lock(member, today, skip_lock?, opts)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Generate cycles with lock handling
|
# Generate cycles with lock handling
|
||||||
# Returns {:ok, cycles, notifications} - notifications are never sent here,
|
# Returns {:ok, cycles, notifications} - notifications are never sent here,
|
||||||
# they should be returned to the caller (e.g., via after_action hook)
|
# they should be returned to the caller (e.g., via after_action hook)
|
||||||
defp do_generate_cycles_with_lock(member, today, true = _skip_lock?) do
|
defp do_generate_cycles_with_lock(member, today, true = _skip_lock?, opts) do
|
||||||
# Lock already set by caller (e.g., regenerate_cycles_on_type_change)
|
# Lock already set by caller (e.g., regenerate_cycles_on_type_change)
|
||||||
# Just generate cycles without additional locking
|
# Just generate cycles without additional locking
|
||||||
do_generate_cycles(member, today)
|
actor = Keyword.get(opts, :actor)
|
||||||
|
do_generate_cycles(member, today, actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp do_generate_cycles_with_lock(member, today, false) do
|
defp do_generate_cycles_with_lock(member, today, false, opts) do
|
||||||
lock_key = :erlang.phash2(member.id)
|
lock_key = :erlang.phash2(member.id)
|
||||||
|
actor = Keyword.get(opts, :actor)
|
||||||
|
|
||||||
Repo.transaction(fn ->
|
Repo.transaction(fn ->
|
||||||
Ecto.Adapters.SQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
|
Ecto.Adapters.SQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
|
||||||
|
|
||||||
case do_generate_cycles(member, today) do
|
case do_generate_cycles(member, today, actor: actor) do
|
||||||
{:ok, cycles, notifications} ->
|
{:ok, cycles, notifications} ->
|
||||||
# Return cycles and notifications - do NOT send notifications here
|
# Return cycles and notifications - do NOT send notifications here
|
||||||
# They will be sent by the caller (e.g., via after_action hook)
|
# They will be sent by the caller (e.g., via after_action hook)
|
||||||
|
|
@ -222,21 +235,33 @@ defmodule Mv.MembershipFees.CycleGenerator do
|
||||||
|
|
||||||
# Private functions
|
# Private functions
|
||||||
|
|
||||||
defp load_member(member_id) do
|
defp load_member(member_id, opts) do
|
||||||
|
actor = Keyword.get(opts, :actor)
|
||||||
|
|
||||||
|
query =
|
||||||
Member
|
Member
|
||||||
|> Ash.Query.filter(id == ^member_id)
|
|> Ash.Query.filter(id == ^member_id)
|
||||||
|> Ash.Query.load([:membership_fee_type, :membership_fee_cycles])
|
|> Ash.Query.load([:membership_fee_type, :membership_fee_cycles])
|
||||||
|> Ash.read_one()
|
|
||||||
|> case do
|
result =
|
||||||
|
if actor do
|
||||||
|
Ash.read_one(query, actor: actor)
|
||||||
|
else
|
||||||
|
Ash.read_one(query)
|
||||||
|
end
|
||||||
|
|
||||||
|
case result do
|
||||||
{:ok, nil} -> {:error, :member_not_found}
|
{:ok, nil} -> {:error, :member_not_found}
|
||||||
{:ok, member} -> {:ok, member}
|
{:ok, member} -> {:ok, member}
|
||||||
{:error, reason} -> {:error, reason}
|
{:error, reason} -> {:error, reason}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp do_generate_cycles(member, today) do
|
defp do_generate_cycles(member, today, opts) do
|
||||||
|
actor = Keyword.get(opts, :actor)
|
||||||
|
|
||||||
# Reload member with relationships to ensure fresh data
|
# Reload member with relationships to ensure fresh data
|
||||||
case load_member(member.id) do
|
case load_member(member.id, actor: actor) do
|
||||||
{:ok, member} ->
|
{:ok, member} ->
|
||||||
cond do
|
cond do
|
||||||
is_nil(member.membership_fee_type_id) ->
|
is_nil(member.membership_fee_type_id) ->
|
||||||
|
|
@ -246,7 +271,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
|
||||||
{:error, :no_join_date}
|
{:error, :no_join_date}
|
||||||
|
|
||||||
true ->
|
true ->
|
||||||
generate_missing_cycles(member, today)
|
generate_missing_cycles(member, today, actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
|
|
@ -254,7 +279,8 @@ defmodule Mv.MembershipFees.CycleGenerator do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp generate_missing_cycles(member, today) do
|
defp generate_missing_cycles(member, today, opts) do
|
||||||
|
actor = Keyword.get(opts, :actor)
|
||||||
fee_type = member.membership_fee_type
|
fee_type = member.membership_fee_type
|
||||||
interval = fee_type.interval
|
interval = fee_type.interval
|
||||||
amount = fee_type.amount
|
amount = fee_type.amount
|
||||||
|
|
@ -270,7 +296,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
|
||||||
# Only generate if start_date <= end_date
|
# Only generate if start_date <= end_date
|
||||||
if start_date && Date.compare(start_date, end_date) != :gt do
|
if start_date && Date.compare(start_date, end_date) != :gt do
|
||||||
cycle_starts = generate_cycle_starts(start_date, end_date, interval)
|
cycle_starts = generate_cycle_starts(start_date, end_date, interval)
|
||||||
create_cycles(cycle_starts, member.id, fee_type.id, amount)
|
create_cycles(cycle_starts, member.id, fee_type.id, amount, actor: actor)
|
||||||
else
|
else
|
||||||
{:ok, [], []}
|
{:ok, [], []}
|
||||||
end
|
end
|
||||||
|
|
@ -365,7 +391,8 @@ defmodule Mv.MembershipFees.CycleGenerator do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp create_cycles(cycle_starts, member_id, fee_type_id, amount) do
|
defp create_cycles(cycle_starts, member_id, fee_type_id, amount, opts) do
|
||||||
|
actor = Keyword.get(opts, :actor)
|
||||||
# Always use return_notifications?: true to collect notifications
|
# Always use return_notifications?: true to collect notifications
|
||||||
# Notifications will be returned to the caller, who is responsible for
|
# Notifications will be returned to the caller, who is responsible for
|
||||||
# sending them (e.g., via after_action hook returning {:ok, result, notifications})
|
# sending them (e.g., via after_action hook returning {:ok, result, notifications})
|
||||||
|
|
@ -380,7 +407,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
|
||||||
}
|
}
|
||||||
|
|
||||||
handle_cycle_creation_result(
|
handle_cycle_creation_result(
|
||||||
Ash.create(MembershipFeeCycle, attrs, return_notifications?: true),
|
Ash.create(MembershipFeeCycle, attrs, return_notifications?: true, actor: actor),
|
||||||
cycle_start
|
cycle_start
|
||||||
)
|
)
|
||||||
end)
|
end)
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ defmodule MvWeb do
|
||||||
those modules here.
|
those modules here.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
|
def static_paths, do: ~w(assets fonts images favicon.ico robots.txt templates)
|
||||||
|
|
||||||
def router do
|
def router do
|
||||||
quote do
|
quote do
|
||||||
|
|
|
||||||
|
|
@ -692,10 +692,11 @@ 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]} />
|
<span class={[@name, @class]} {@rest} />
|
||||||
"""
|
"""
|
||||||
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.Navbar
|
import MvWeb.Layouts.Sidebar
|
||||||
|
|
||||||
embed_templates "layouts/*"
|
embed_templates "layouts/*"
|
||||||
|
|
||||||
|
|
@ -43,20 +43,66 @@ 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 %>
|
||||||
<.navbar current_user={@current_user} club_name={@club_name} />
|
<div
|
||||||
<% end %>
|
id="app-layout"
|
||||||
<main class="px-4 py-20 sm:px-6 lg:px-16">
|
class="drawer lg:drawer-open"
|
||||||
<div class="mx-auto max-full space-y-4">
|
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)}
|
{render_slot(@inner_block)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</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 %>
|
||||||
|
|
||||||
<.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.
|
||||||
|
|
||||||
|
|
@ -69,7 +115,7 @@ defmodule MvWeb.Layouts do
|
||||||
|
|
||||||
def flash_group(assigns) do
|
def flash_group(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<div id={@id} aria-live="polite" class="toast toast-top toast-end z-50 flex flex-col gap-2">
|
<div id={@id} aria-live="polite" class="z-50 flex flex-col gap-2 toast toast-top toast-end">
|
||||||
<.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} />
|
||||||
|
|
|
||||||
|
|
@ -1,152 +0,0 @@
|
||||||
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
|
|
||||||
317
lib/mv_web/components/layouts/sidebar.ex
Normal file
317
lib/mv_web/components/layouts/sidebar.ex
Normal file
|
|
@ -0,0 +1,317 @@
|
||||||
|
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,6 +78,12 @@ 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
|
||||||
|
|
|
||||||
59
lib/mv_web/helpers/field_type_formatter.ex
Normal file
59
lib/mv_web/helpers/field_type_formatter.ex
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
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,6 +18,8 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
|
||||||
|
|
||||||
use MvWeb, :live_component
|
use MvWeb, :live_component
|
||||||
|
|
||||||
|
alias MvWeb.Translations.MemberFields
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# UPDATE
|
# UPDATE
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -66,7 +68,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("Columns")}
|
button_label={gettext("Show/Hide Columns")}
|
||||||
items={@all_items}
|
items={@all_items}
|
||||||
checkboxes={true}
|
checkboxes={true}
|
||||||
selected={@selected_fields}
|
selected={@selected_fields}
|
||||||
|
|
@ -153,12 +155,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
|
||||||
MvWeb.Translations.MemberFields.label(field)
|
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} -> MvWeb.Translations.MemberFields.label(atom)
|
{:ok, atom} -> 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 interval (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation."
|
"Contribution types define different membership fee structures. Each type has a fixed cycle (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 custom field overview")}
|
aria-label={gettext("Back to settings")}
|
||||||
>
|
>
|
||||||
<.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 Custom Field"), else: gettext("New Custom Field")}
|
{if @custom_field, do: gettext("Edit Data Field"), else: gettext("New Data 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 Custom Field")}
|
{gettext("Save Data Field")}
|
||||||
</.button>
|
</.button>
|
||||||
</div>
|
</div>
|
||||||
</.form>
|
</.form>
|
||||||
|
|
@ -91,7 +91,9 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("save", %{"custom_field" => custom_field_params}, socket) do
|
def handle_event("save", %{"custom_field" => custom_field_params}, socket) do
|
||||||
case AshPhoenix.Form.submit(socket.assigns.form, params: custom_field_params) do
|
actor = MvWeb.LiveHelpers.current_actor(socket)
|
||||||
|
|
||||||
|
case MvWeb.LiveHelpers.submit_form(socket.assigns.form, custom_field_params, actor) do
|
||||||
{:ok, custom_field} ->
|
{:ok, custom_field} ->
|
||||||
action =
|
action =
|
||||||
case socket.assigns.form.source.type do
|
case socket.assigns.form.source.type do
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,7 @@ 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}>
|
<div id={@id} class="mt-8">
|
||||||
<.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.")}
|
||||||
|
|
@ -30,7 +29,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 Custom Field")}
|
<.icon name="hero-plus" /> {gettext("New Data Field")}
|
||||||
</.button>
|
</.button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -68,6 +67,19 @@ 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")}
|
||||||
|
|
@ -90,9 +102,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
||||||
</:action>
|
</:action>
|
||||||
|
|
||||||
<:action :let={{_id, custom_field}}>
|
<:action :let={{_id, custom_field}}>
|
||||||
<.link phx-click={
|
<.link phx-click={JS.push("prepare_delete", value: %{id: custom_field.id}, target: @myself)}>
|
||||||
JS.push("prepare_delete", value: %{id: custom_field.id}, target: @myself)
|
|
||||||
}>
|
|
||||||
{gettext("Delete")}
|
{gettext("Delete")}
|
||||||
</.link>
|
</.link>
|
||||||
</:action>
|
</:action>
|
||||||
|
|
@ -101,7 +111,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 Custom Field")}</h3>
|
<h3 class="text-lg font-bold">{gettext("Delete Data Field")}</h3>
|
||||||
|
|
||||||
<div class="py-4 space-y-4">
|
<div class="py-4 space-y-4">
|
||||||
<div class="alert alert-warning">
|
<div class="alert alert-warning">
|
||||||
|
|
@ -162,13 +172,15 @@ 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
|
||||||
|
|
@ -179,6 +191,13 @@ 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)
|
||||||
|
|
@ -193,6 +212,11 @@ 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)
|
||||||
|
|
@ -204,6 +228,11 @@ 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)
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,9 @@ defmodule MvWeb.CustomFieldValueLive.Form do
|
||||||
"""
|
"""
|
||||||
use MvWeb, :live_view
|
use MvWeb, :live_view
|
||||||
|
|
||||||
|
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
||||||
|
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
|
|
@ -172,8 +175,9 @@ defmodule MvWeb.CustomFieldValueLive.Form do
|
||||||
page_title = action <> " " <> "Custom field value"
|
page_title = action <> " " <> "Custom field value"
|
||||||
|
|
||||||
# Load all CustomFields and Members for the selection fields
|
# Load all CustomFields and Members for the selection fields
|
||||||
custom_fields = Ash.read!(Mv.Membership.CustomField)
|
actor = current_actor(socket)
|
||||||
members = Ash.read!(Mv.Membership.Member)
|
custom_fields = Ash.read!(Mv.Membership.CustomField, actor: actor)
|
||||||
|
members = Ash.read!(Mv.Membership.Member, actor: actor)
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|
|
@ -224,7 +228,9 @@ defmodule MvWeb.CustomFieldValueLive.Form do
|
||||||
custom_field_value_params
|
custom_field_value_params
|
||||||
end
|
end
|
||||||
|
|
||||||
case AshPhoenix.Form.submit(socket.assigns.form, params: updated_params) do
|
actor = current_actor(socket)
|
||||||
|
|
||||||
|
case submit_form(socket.assigns.form, updated_params, actor) do
|
||||||
{:ok, custom_field_value} ->
|
{:ok, custom_field_value} ->
|
||||||
notify_parent({:saved, custom_field_value})
|
notify_parent({:saved, custom_field_value})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,9 @@ defmodule MvWeb.CustomFieldValueLive.Index do
|
||||||
"""
|
"""
|
||||||
use MvWeb, :live_view
|
use MvWeb, :live_view
|
||||||
|
|
||||||
|
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
||||||
|
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
|
|
@ -70,17 +73,85 @@ defmodule MvWeb.CustomFieldValueLive.Index do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
|
actor = current_actor(socket)
|
||||||
|
|
||||||
|
# Early return if no actor (prevents exceptions in unauthenticated tests)
|
||||||
|
if is_nil(actor) do
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, "Listing Custom field values")
|
|> assign(:page_title, "Listing Custom field values")
|
||||||
|> stream(:custom_field_values, Ash.read!(Mv.Membership.CustomFieldValue))}
|
|> stream(:custom_field_values, [])}
|
||||||
|
else
|
||||||
|
case Ash.read(Mv.Membership.CustomFieldValue, actor: actor) do
|
||||||
|
{:ok, custom_field_values} ->
|
||||||
|
{:ok,
|
||||||
|
socket
|
||||||
|
|> assign(:page_title, "Listing Custom field values")
|
||||||
|
|> stream(:custom_field_values, custom_field_values)}
|
||||||
|
|
||||||
|
{:error, %Ash.Error.Forbidden{}} ->
|
||||||
|
{:ok,
|
||||||
|
socket
|
||||||
|
|> assign(:page_title, "Listing Custom field values")
|
||||||
|
|> stream(:custom_field_values, [])
|
||||||
|
|> put_flash(:error, gettext("You do not have permission to view custom field values"))}
|
||||||
|
|
||||||
|
{:error, error} ->
|
||||||
|
{:ok,
|
||||||
|
socket
|
||||||
|
|> assign(:page_title, "Listing Custom field values")
|
||||||
|
|> stream(:custom_field_values, [])
|
||||||
|
|> put_flash(:error, format_error(error))}
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("delete", %{"id" => id}, socket) do
|
def handle_event("delete", %{"id" => id}, socket) do
|
||||||
custom_field_value = Ash.get!(Mv.Membership.CustomFieldValue, id)
|
actor = MvWeb.LiveHelpers.current_actor(socket)
|
||||||
Ash.destroy!(custom_field_value)
|
|
||||||
|
|
||||||
{:noreply, stream_delete(socket, :custom_field_values, custom_field_value)}
|
case Ash.get(Mv.Membership.CustomFieldValue, id, actor: actor) do
|
||||||
|
{:ok, custom_field_value} ->
|
||||||
|
case Ash.destroy(custom_field_value, actor: actor) do
|
||||||
|
:ok ->
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> stream_delete(:custom_field_values, custom_field_value)
|
||||||
|
|> put_flash(:info, gettext("Custom field value deleted successfully"))}
|
||||||
|
|
||||||
|
{:error, %Ash.Error.Forbidden{}} ->
|
||||||
|
{:noreply,
|
||||||
|
put_flash(
|
||||||
|
socket,
|
||||||
|
:error,
|
||||||
|
gettext("You do not have permission to delete this custom field value")
|
||||||
|
)}
|
||||||
|
|
||||||
|
{:error, error} ->
|
||||||
|
{:noreply, put_flash(socket, :error, format_error(error))}
|
||||||
|
end
|
||||||
|
|
||||||
|
{:error, %Ash.Error.Query.NotFound{}} ->
|
||||||
|
{:noreply, put_flash(socket, :error, gettext("Custom field value not found"))}
|
||||||
|
|
||||||
|
{:error, %Ash.Error.Forbidden{} = _error} ->
|
||||||
|
{:noreply,
|
||||||
|
put_flash(
|
||||||
|
socket,
|
||||||
|
:error,
|
||||||
|
gettext("You do not have permission to access this custom field value")
|
||||||
|
)}
|
||||||
|
|
||||||
|
{:error, error} ->
|
||||||
|
{:noreply, put_flash(socket, :error, format_error(error))}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_error(%Ash.Error.Invalid{errors: errors}) do
|
||||||
|
Enum.map_join(errors, ", ", fn %{message: message} -> message end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_error(error) do
|
||||||
|
inspect(error)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
Custom field value {@custom_field_value.id}
|
Data 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 Custom field value"
|
defp page_title(:show), do: "Show data field value"
|
||||||
defp page_title(:edit), do: "Edit Custom field value"
|
defp page_title(:edit), do: "Edit data field value"
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ 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
|
||||||
|
|
||||||
|
|
@ -62,11 +63,21 @@ 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
|
||||||
|
|
@ -79,7 +90,9 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("save", %{"setting" => setting_params}, socket) do
|
def handle_event("save", %{"setting" => setting_params}, socket) do
|
||||||
case AshPhoenix.Form.submit(socket.assigns.form, params: setting_params) do
|
actor = MvWeb.LiveHelpers.current_actor(socket)
|
||||||
|
|
||||||
|
case MvWeb.LiveHelpers.submit_form(socket.assigns.form, setting_params, actor) do
|
||||||
{:ok, _updated_settings} ->
|
{:ok, _updated_settings} ->
|
||||||
# Reload settings from database to ensure all dependent data is updated
|
# Reload settings from database to ensure all dependent data is updated
|
||||||
{:ok, fresh_settings} = Membership.get_settings()
|
{:ok, fresh_settings} = Membership.get_settings()
|
||||||
|
|
@ -105,12 +118,14 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
)
|
)
|
||||||
|
|
||||||
{:noreply,
|
{:noreply,
|
||||||
put_flash(socket, :info, gettext("Custom field %{action} successfully", action: action))}
|
socket
|
||||||
|
|> 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("Custom field deleted successfully"))}
|
{:noreply, put_flash(socket, :info, gettext("Data field deleted successfully"))}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
@ -119,7 +134,7 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
put_flash(
|
put_flash(
|
||||||
socket,
|
socket,
|
||||||
:error,
|
:error,
|
||||||
gettext("Failed to delete custom field: %{error}", error: inspect(error))
|
gettext("Failed to delete data field: %{error}", error: inspect(error))
|
||||||
)}
|
)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -128,6 +143,43 @@ 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(
|
||||||
|
|
|
||||||
338
lib/mv_web/live/member_field_live/form_component.ex
Normal file
338
lib/mv_web/live/member_field_live/form_component.ex
Normal file
|
|
@ -0,0 +1,338 @@
|
||||||
|
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
|
||||||
219
lib/mv_web/live/member_field_live/index_component.ex
Normal file
219
lib/mv_web/live/member_field_live/index_component.ex
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
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
|
||||||
|
|
@ -21,6 +21,10 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
"""
|
"""
|
||||||
use MvWeb, :live_view
|
use MvWeb, :live_view
|
||||||
|
|
||||||
|
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
||||||
|
|
||||||
|
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
|
||||||
|
|
||||||
alias Mv.MembershipFees
|
alias Mv.MembershipFees
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
alias Mv.MembershipFees.MembershipFeeType
|
||||||
alias MvWeb.Helpers.MembershipFeeHelpers
|
alias MvWeb.Helpers.MembershipFeeHelpers
|
||||||
|
|
@ -172,7 +176,7 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
<select
|
<select
|
||||||
class="select select-bordered w-full"
|
class="select select-bordered w-full"
|
||||||
name={@form[:membership_fee_type_id].name}
|
name={@form[:membership_fee_type_id].name}
|
||||||
phx-change="validate_membership_fee_type"
|
phx-change="validate"
|
||||||
value={@form[:membership_fee_type_id].value || ""}
|
value={@form[:membership_fee_type_id].value || ""}
|
||||||
>
|
>
|
||||||
<option value="">{gettext("None")}</option>
|
<option value="">{gettext("None")}</option>
|
||||||
|
|
@ -222,6 +226,8 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(params, _session, socket) do
|
def mount(params, _session, socket) do
|
||||||
|
# current_user should be set by on_mount hooks (LiveUserAuth + LiveHelpers)
|
||||||
|
actor = current_actor(socket)
|
||||||
{:ok, custom_fields} = Mv.Membership.list_custom_fields()
|
{:ok, custom_fields} = Mv.Membership.list_custom_fields()
|
||||||
|
|
||||||
initial_custom_field_values =
|
initial_custom_field_values =
|
||||||
|
|
@ -239,14 +245,14 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
member =
|
member =
|
||||||
case params["id"] do
|
case params["id"] do
|
||||||
nil -> nil
|
nil -> nil
|
||||||
id -> Ash.get!(Mv.Membership.Member, id, load: [:membership_fee_type])
|
id -> Ash.get!(Mv.Membership.Member, id, load: [:membership_fee_type], actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
page_title =
|
page_title =
|
||||||
if is_nil(member), do: gettext("Create Member"), else: gettext("Edit Member")
|
if is_nil(member), do: gettext("Create Member"), else: gettext("Edit Member")
|
||||||
|
|
||||||
# Load available membership fee types
|
# Load available membership fee types
|
||||||
available_fee_types = load_available_fee_types(member)
|
available_fee_types = load_available_fee_types(member, actor)
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|
|
@ -265,34 +271,42 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("validate", %{"member" => member_params}, socket) do
|
def handle_event("validate", %{"member" => member_params}, socket) do
|
||||||
validated_form = AshPhoenix.Form.validate(socket.assigns.form, member_params)
|
# Merge with existing form values to preserve unchanged fields (especially custom_field_values)
|
||||||
|
# Extract values directly from form fields to get current state
|
||||||
|
existing_values = get_existing_form_values(socket.assigns.form)
|
||||||
|
|
||||||
|
# Merge existing values with new params (new params take precedence)
|
||||||
|
merged_params = Map.merge(existing_values, member_params)
|
||||||
|
|
||||||
|
validated_form = AshPhoenix.Form.validate(socket.assigns.form, merged_params)
|
||||||
|
|
||||||
# Check for interval mismatch if membership_fee_type_id changed
|
# Check for interval mismatch if membership_fee_type_id changed
|
||||||
socket = check_interval_change(socket, member_params)
|
socket = check_interval_change(socket, merged_params)
|
||||||
|
|
||||||
{:noreply, assign(socket, form: validated_form)}
|
{:noreply, assign(socket, form: validated_form)}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event(
|
def handle_event("save", %{"member" => member_params}, socket) do
|
||||||
"validate_membership_fee_type",
|
try do
|
||||||
%{"member" => %{"membership_fee_type_id" => fee_type_id}},
|
actor = current_actor(socket)
|
||||||
socket
|
|
||||||
) do
|
case submit_form(socket.assigns.form, member_params, actor) do
|
||||||
# Same validation as above, but triggered by select change
|
{:ok, member} ->
|
||||||
handle_event("validate", %{"member" => %{"membership_fee_type_id" => fee_type_id}}, socket)
|
handle_save_success(socket, member)
|
||||||
|
|
||||||
|
{:error, form} ->
|
||||||
|
{:noreply, assign(socket, form: form)}
|
||||||
|
end
|
||||||
|
rescue
|
||||||
|
_e in [Ash.Error.Forbidden, Ash.Error.Forbidden.Policy] ->
|
||||||
|
handle_save_forbidden(socket)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("save", %{"member" => member_params}, socket) do
|
defp handle_save_success(socket, member) do
|
||||||
case AshPhoenix.Form.submit(socket.assigns.form, params: member_params) do
|
|
||||||
{:ok, member} ->
|
|
||||||
notify_parent({:saved, member})
|
notify_parent({:saved, member})
|
||||||
|
|
||||||
action =
|
action = get_action_name(socket.assigns.form.source.type)
|
||||||
case socket.assigns.form.source.type do
|
|
||||||
:create -> gettext("create")
|
|
||||||
:update -> gettext("update")
|
|
||||||
other -> to_string(other)
|
|
||||||
end
|
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|
|
@ -300,18 +314,32 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
|> push_navigate(to: return_path(socket.assigns.return_to, member))
|
|> push_navigate(to: return_path(socket.assigns.return_to, member))
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
{:error, form} ->
|
defp handle_save_forbidden(socket) do
|
||||||
{:noreply, assign(socket, form: form)}
|
# Handle policy violations that aren't properly displayed in forms
|
||||||
end
|
# AshPhoenix.Form doesn't implement FormData.Error protocol for Forbidden errors
|
||||||
|
action = get_action_name(socket.assigns.form.source.type)
|
||||||
|
|
||||||
|
error_message =
|
||||||
|
gettext("You do not have permission to %{action} members.", action: action)
|
||||||
|
|
||||||
|
{:noreply, put_flash(socket, :error, error_message)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp get_action_name(:create), do: gettext("create")
|
||||||
|
defp get_action_name(:update), do: gettext("update")
|
||||||
|
defp get_action_name(other), do: to_string(other)
|
||||||
|
|
||||||
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
|
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
|
||||||
|
|
||||||
defp assign_form(%{assigns: %{member: member}} = socket) do
|
defp assign_form(%{assigns: assigns} = socket) do
|
||||||
|
member = assigns.member
|
||||||
|
actor = assigns[:current_user] || assigns.current_user
|
||||||
|
|
||||||
form =
|
form =
|
||||||
if member do
|
if member do
|
||||||
{:ok, member} = Ash.load(member, custom_field_values: [:custom_field])
|
{:ok, member} = Ash.load(member, [custom_field_values: [:custom_field]], actor: actor)
|
||||||
|
|
||||||
existing_custom_field_values =
|
existing_custom_field_values =
|
||||||
member.custom_field_values
|
member.custom_field_values
|
||||||
|
|
@ -342,7 +370,8 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
api: Mv.Membership,
|
api: Mv.Membership,
|
||||||
as: "member",
|
as: "member",
|
||||||
params: params,
|
params: params,
|
||||||
forms: [auto?: true]
|
forms: [auto?: true],
|
||||||
|
actor: actor
|
||||||
)
|
)
|
||||||
|
|
||||||
missing_custom_field_values =
|
missing_custom_field_values =
|
||||||
|
|
@ -360,7 +389,8 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
api: Mv.Membership,
|
api: Mv.Membership,
|
||||||
as: "member",
|
as: "member",
|
||||||
params: %{"custom_field_values" => socket.assigns[:initial_custom_field_values]},
|
params: %{"custom_field_values" => socket.assigns[:initial_custom_field_values]},
|
||||||
forms: [auto?: true]
|
forms: [auto?: true],
|
||||||
|
actor: actor
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -375,11 +405,11 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
# Helper Functions
|
# Helper Functions
|
||||||
# -----------------------------------------------------------------
|
# -----------------------------------------------------------------
|
||||||
|
|
||||||
defp load_available_fee_types(member) do
|
defp load_available_fee_types(member, actor) do
|
||||||
all_types =
|
all_types =
|
||||||
MembershipFeeType
|
MembershipFeeType
|
||||||
|> Ash.Query.sort(name: :asc)
|
|> Ash.Query.sort(name: :asc)
|
||||||
|> Ash.read!(domain: MembershipFees)
|
|> Ash.read!(domain: MembershipFees, actor: actor)
|
||||||
|
|
||||||
# If member has a fee type, filter to same interval
|
# If member has a fee type, filter to same interval
|
||||||
if member && member.membership_fee_type do
|
if member && member.membership_fee_type do
|
||||||
|
|
@ -453,4 +483,167 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
defp custom_field_input_type(:date), do: "date"
|
defp custom_field_input_type(:date), do: "date"
|
||||||
defp custom_field_input_type(:email), do: "email"
|
defp custom_field_input_type(:email), do: "email"
|
||||||
defp custom_field_input_type(_), do: "text"
|
defp custom_field_input_type(_), do: "text"
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
# Helper Functions for Form Value Preservation
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
|
||||||
|
# Helper to extract existing form values to preserve them when only one field changes
|
||||||
|
# This ensures custom_field_values and other fields are preserved when only the dropdown changes
|
||||||
|
defp get_existing_form_values(form) do
|
||||||
|
%{}
|
||||||
|
|> extract_form_value(form, :first_name, &to_string/1)
|
||||||
|
|> extract_form_value(form, :last_name, &to_string/1)
|
||||||
|
|> extract_form_value(form, :email, &to_string/1)
|
||||||
|
|> extract_form_value(form, :street, &to_string/1)
|
||||||
|
|> extract_form_value(form, :house_number, &to_string/1)
|
||||||
|
|> extract_form_value(form, :postal_code, &to_string/1)
|
||||||
|
|> extract_form_value(form, :city, &to_string/1)
|
||||||
|
|> extract_form_value(form, :join_date, &format_date_value/1)
|
||||||
|
|> extract_form_value(form, :exit_date, &format_date_value/1)
|
||||||
|
|> extract_form_value(form, :notes, &to_string/1)
|
||||||
|
|> extract_form_value(form, :membership_fee_type_id, &to_string/1)
|
||||||
|
|> extract_form_value(form, :membership_fee_start_date, &format_date_value/1)
|
||||||
|
|> extract_custom_field_values(form)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Helper to extract a single form field value
|
||||||
|
defp extract_form_value(acc, form, field, formatter) do
|
||||||
|
if form[field] && form[field].value do
|
||||||
|
Map.put(acc, to_string(field), formatter.(form[field].value))
|
||||||
|
else
|
||||||
|
acc
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Extracts custom field values from the form structure
|
||||||
|
# The form is a Phoenix.HTML.Form with source being AshPhoenix.Form
|
||||||
|
# Custom field values are in form.source.params["custom_field_values"] as a map
|
||||||
|
defp extract_custom_field_values(acc, form) do
|
||||||
|
cfv_params = get_custom_field_values_params(form)
|
||||||
|
|
||||||
|
if map_size(cfv_params) > 0 do
|
||||||
|
custom_field_values = convert_cfv_params_to_list(cfv_params)
|
||||||
|
Map.put(acc, "custom_field_values", custom_field_values)
|
||||||
|
else
|
||||||
|
acc
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Gets custom_field_values from form params
|
||||||
|
defp get_custom_field_values_params(form) do
|
||||||
|
ash_form = form.source
|
||||||
|
|
||||||
|
if ash_form && Map.has_key?(ash_form, :params) && ash_form.params["custom_field_values"] do
|
||||||
|
ash_form.params["custom_field_values"]
|
||||||
|
else
|
||||||
|
%{}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Converts custom field values map to sorted list
|
||||||
|
defp convert_cfv_params_to_list(cfv_params) do
|
||||||
|
cfv_params
|
||||||
|
|> Map.to_list()
|
||||||
|
|> Enum.sort_by(&parse_numeric_key/1)
|
||||||
|
|> Enum.map(&build_custom_field_value/1)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Parses numeric key for sorting
|
||||||
|
defp parse_numeric_key({key, _}) do
|
||||||
|
case Integer.parse(key) do
|
||||||
|
{num, _} -> num
|
||||||
|
:error -> 999_999
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Builds a custom field value map from params
|
||||||
|
defp build_custom_field_value({_key, cfv_map}) do
|
||||||
|
%{
|
||||||
|
"custom_field_id" => Map.get(cfv_map, "custom_field_id", ""),
|
||||||
|
"value" => extract_custom_field_value_from_map(Map.get(cfv_map, "value", %{}))
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Extracts the value map structure from a custom field value
|
||||||
|
# Handles both map format and Ash.Union struct format
|
||||||
|
defp extract_custom_field_value_from_map(%Ash.Union{} = union) do
|
||||||
|
union_type = Atom.to_string(union.type)
|
||||||
|
|
||||||
|
%{
|
||||||
|
"_union_type" => union_type,
|
||||||
|
"type" => union_type,
|
||||||
|
"value" => format_custom_field_value(union.value)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp extract_custom_field_value_from_map(value_map) when is_map(value_map) do
|
||||||
|
union_type = extract_union_type_from_map(value_map)
|
||||||
|
value = Map.get(value_map, "value") || Map.get(value_map, :value)
|
||||||
|
|
||||||
|
%{
|
||||||
|
"_union_type" => union_type,
|
||||||
|
"type" => union_type,
|
||||||
|
"value" => format_custom_field_value(value)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp extract_custom_field_value_from_map(_),
|
||||||
|
do: %{"_union_type" => "", "type" => "", "value" => ""}
|
||||||
|
|
||||||
|
# Extracts union type from map, checking various possible locations
|
||||||
|
defp extract_union_type_from_map(value_map) do
|
||||||
|
cond do
|
||||||
|
has_non_empty_string(value_map, "_union_type") ->
|
||||||
|
Map.get(value_map, "_union_type")
|
||||||
|
|
||||||
|
has_non_empty_atom(value_map, :_union_type) ->
|
||||||
|
to_string(Map.get(value_map, :_union_type))
|
||||||
|
|
||||||
|
has_atom_type(value_map) ->
|
||||||
|
Atom.to_string(Map.get(value_map, :type))
|
||||||
|
|
||||||
|
has_string_type(value_map) ->
|
||||||
|
Map.get(value_map, "type")
|
||||||
|
|
||||||
|
true ->
|
||||||
|
""
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Helper to check if map has non-empty string value
|
||||||
|
defp has_non_empty_string(map, key) do
|
||||||
|
value = Map.get(map, key)
|
||||||
|
value && value != ""
|
||||||
|
end
|
||||||
|
|
||||||
|
# Helper to check if map has non-empty atom value
|
||||||
|
defp has_non_empty_atom(map, key) do
|
||||||
|
value = Map.get(map, key)
|
||||||
|
value && value != ""
|
||||||
|
end
|
||||||
|
|
||||||
|
# Helper to check if map has atom type
|
||||||
|
defp has_atom_type(map) do
|
||||||
|
value = Map.get(map, :type)
|
||||||
|
value && is_atom(value)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Helper to check if map has string type
|
||||||
|
defp has_string_type(map) do
|
||||||
|
value = Map.get(map, "type")
|
||||||
|
value && is_binary(value)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Formats custom field value based on its type
|
||||||
|
defp format_custom_field_value(%Date{} = date), do: Date.to_iso8601(date)
|
||||||
|
defp format_custom_field_value(%Decimal{} = decimal), do: Decimal.to_string(decimal, :normal)
|
||||||
|
defp format_custom_field_value(value) when is_boolean(value), do: to_string(value)
|
||||||
|
defp format_custom_field_value(value) when is_binary(value), do: value
|
||||||
|
defp format_custom_field_value(value), do: to_string(value)
|
||||||
|
|
||||||
|
# Formats date value (Date or string) to string
|
||||||
|
defp format_date_value(%Date{} = date), do: Date.to_iso8601(date)
|
||||||
|
defp format_date_value(value) when is_binary(value), do: value
|
||||||
|
defp format_date_value(_), do: ""
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -27,14 +27,17 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
"""
|
"""
|
||||||
use MvWeb, :live_view
|
use MvWeb, :live_view
|
||||||
|
|
||||||
|
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
import Ash.Expr
|
import Ash.Expr
|
||||||
|
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||||
|
|
||||||
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>")
|
||||||
|
|
@ -55,20 +58,21 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, session, socket) do
|
def mount(_params, session, socket) do
|
||||||
# Load custom fields that should be shown in overview (for display)
|
# Load custom fields that should be shown in overview (for display)
|
||||||
# Note: Using Ash.read! (bang version) - errors will be handled by Phoenix LiveView
|
# Errors in mount are handled by Phoenix LiveView and result in a 500 error page.
|
||||||
# and result in a 500 error page. This is appropriate for LiveViews where errors
|
# This is appropriate for initialization errors that should be visible to the user.
|
||||||
# should be visible to the user rather than silently failing.
|
actor = current_actor(socket)
|
||||||
|
|
||||||
custom_fields_visible =
|
custom_fields_visible =
|
||||||
Mv.Membership.CustomField
|
Mv.Membership.CustomField
|
||||||
|> Ash.Query.filter(expr(show_in_overview == true))
|
|> Ash.Query.filter(expr(show_in_overview == true))
|
||||||
|> Ash.Query.sort(name: :asc)
|
|> Ash.Query.sort(name: :asc)
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: actor)
|
||||||
|
|
||||||
# Load ALL custom fields for the dropdown (to show all available fields)
|
# Load ALL custom fields for the dropdown (to show all available fields)
|
||||||
all_custom_fields =
|
all_custom_fields =
|
||||||
Mv.Membership.CustomField
|
Mv.Membership.CustomField
|
||||||
|> Ash.Query.sort(name: :asc)
|
|> Ash.Query.sort(name: :asc)
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: actor)
|
||||||
|
|
||||||
# Load settings once to avoid N+1 queries
|
# Load settings once to avoid N+1 queries
|
||||||
settings =
|
settings =
|
||||||
|
|
@ -130,13 +134,41 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
"""
|
"""
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("delete", %{"id" => id}, socket) do
|
def handle_event("delete", %{"id" => id}, socket) do
|
||||||
# Note: Using bang versions (!) - errors will be handled by Phoenix LiveView
|
actor = current_actor(socket)
|
||||||
# This ensures users see error messages if deletion fails (e.g., permission denied)
|
|
||||||
member = Ash.get!(Mv.Membership.Member, id)
|
|
||||||
Ash.destroy!(member)
|
|
||||||
|
|
||||||
|
case Ash.get(Mv.Membership.Member, id, actor: actor) do
|
||||||
|
{:ok, member} ->
|
||||||
|
case Ash.destroy(member, actor: actor) do
|
||||||
|
:ok ->
|
||||||
updated_members = Enum.reject(socket.assigns.members, &(&1.id == id))
|
updated_members = Enum.reject(socket.assigns.members, &(&1.id == id))
|
||||||
{:noreply, assign(socket, :members, updated_members)}
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:members, updated_members)
|
||||||
|
|> put_flash(:info, gettext("Member deleted successfully"))}
|
||||||
|
|
||||||
|
{:error, %Ash.Error.Forbidden{}} ->
|
||||||
|
{:noreply,
|
||||||
|
put_flash(
|
||||||
|
socket,
|
||||||
|
:error,
|
||||||
|
gettext("You do not have permission to delete this member")
|
||||||
|
)}
|
||||||
|
|
||||||
|
{:error, error} ->
|
||||||
|
{:noreply, put_flash(socket, :error, format_error(error))}
|
||||||
|
end
|
||||||
|
|
||||||
|
{:error, %Ash.Error.Query.NotFound{}} ->
|
||||||
|
{:noreply, put_flash(socket, :error, gettext("Member not found"))}
|
||||||
|
|
||||||
|
{:error, %Ash.Error.Forbidden{} = _error} ->
|
||||||
|
{:noreply,
|
||||||
|
put_flash(socket, :error, gettext("You do not have permission to access this member"))}
|
||||||
|
|
||||||
|
{:error, error} ->
|
||||||
|
{:noreply, put_flash(socket, :error, format_error(error))}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
@ -236,6 +268,24 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Helper to format errors for display
|
||||||
|
defp format_error(%Ash.Error.Invalid{errors: errors}) do
|
||||||
|
error_messages =
|
||||||
|
Enum.map(errors, fn error ->
|
||||||
|
case error do
|
||||||
|
%{field: field, message: message} -> "#{field}: #{message}"
|
||||||
|
%{message: message} -> message
|
||||||
|
_ -> inspect(error)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
Enum.join(error_messages, ", ")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_error(error) do
|
||||||
|
inspect(error)
|
||||||
|
end
|
||||||
|
|
||||||
# -----------------------------------------------------------------
|
# -----------------------------------------------------------------
|
||||||
# Handle Infos from Child Components
|
# Handle Infos from Child Components
|
||||||
# -----------------------------------------------------------------
|
# -----------------------------------------------------------------
|
||||||
|
|
@ -676,9 +726,9 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
socket.assigns.custom_fields_visible
|
socket.assigns.custom_fields_visible
|
||||||
)
|
)
|
||||||
|
|
||||||
# Note: Using Ash.read! - errors will be handled by Phoenix LiveView
|
# Errors in handle_params are handled by Phoenix LiveView
|
||||||
# This is appropriate for data loading in LiveViews
|
actor = current_actor(socket)
|
||||||
members = Ash.read!(query)
|
members = Ash.read!(query, actor: actor)
|
||||||
|
|
||||||
# Custom field values are already filtered at the database level in load_custom_field_values/2
|
# Custom field values are already filtered at the database level in load_custom_field_values/2
|
||||||
# No need for in-memory filtering anymore
|
# No need for in-memory filtering anymore
|
||||||
|
|
|
||||||
|
|
@ -257,6 +257,24 @@
|
||||||
>
|
>
|
||||||
{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,6 +20,8 @@ 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.
|
||||||
|
|
||||||
|
|
@ -177,13 +179,15 @@ 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 =
|
||||||
normalize_visibility_config(Map.get(settings, :member_field_visibility, %{}))
|
VisibilityConfig.normalize(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)
|
||||||
show_in_overview = Map.get(visibility_config, field, true)
|
# exit_date defaults to false (hidden), all other fields default to true
|
||||||
|
default_visibility = if field == :exit_date, do: false, else: true
|
||||||
|
show_in_overview = Map.get(visibility_config, field, default_visibility)
|
||||||
Map.put(acc, field_string, show_in_overview)
|
Map.put(acc, field_string, show_in_overview)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
@ -199,27 +203,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,9 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
"""
|
"""
|
||||||
use MvWeb, :live_view
|
use MvWeb, :live_view
|
||||||
import Ash.Query
|
import Ash.Query
|
||||||
|
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||||
|
|
||||||
|
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
||||||
|
|
||||||
alias MvWeb.Helpers.MembershipFeeHelpers
|
alias MvWeb.Helpers.MembershipFeeHelpers
|
||||||
|
|
||||||
|
|
@ -220,6 +223,7 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
module={MvWeb.MemberLive.Show.MembershipFeesComponent}
|
module={MvWeb.MemberLive.Show.MembershipFeesComponent}
|
||||||
id={"membership-fees-#{@member.id}"}
|
id={"membership-fees-#{@member.id}"}
|
||||||
member={@member}
|
member={@member}
|
||||||
|
current_user={@current_user}
|
||||||
/>
|
/>
|
||||||
<% end %>
|
<% end %>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
|
|
@ -233,12 +237,14 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_params(%{"id" => id}, _, socket) do
|
def handle_params(%{"id" => id}, _, socket) do
|
||||||
|
actor = current_actor(socket)
|
||||||
|
|
||||||
# Load custom fields once using assign_new to avoid repeated queries
|
# Load custom fields once using assign_new to avoid repeated queries
|
||||||
socket =
|
socket =
|
||||||
assign_new(socket, :custom_fields, fn ->
|
assign_new(socket, :custom_fields, fn ->
|
||||||
Mv.Membership.CustomField
|
Mv.Membership.CustomField
|
||||||
|> Ash.Query.sort(name: :asc)
|
|> Ash.Query.sort(name: :asc)
|
||||||
|> Ash.read!()
|
|> Ash.read!(actor: actor)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
query =
|
query =
|
||||||
|
|
@ -251,7 +257,7 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
membership_fee_cycles: [:membership_fee_type]
|
membership_fee_cycles: [:membership_fee_type]
|
||||||
])
|
])
|
||||||
|
|
||||||
member = Ash.read_one!(query)
|
member = Ash.read_one!(query, actor: actor)
|
||||||
|
|
||||||
# Calculate last and current cycle status from loaded cycles
|
# Calculate last and current cycle status from loaded cycles
|
||||||
last_cycle_status = get_last_cycle_status(member)
|
last_cycle_status = get_last_cycle_status(member)
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
use MvWeb, :live_component
|
use MvWeb, :live_component
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||||
|
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
|
alias Mv.MembershipFees
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
alias Mv.MembershipFees.MembershipFeeType
|
||||||
alias Mv.MembershipFees.MembershipFeeCycle
|
alias Mv.MembershipFees.MembershipFeeCycle
|
||||||
alias Mv.MembershipFees.CycleGenerator
|
alias Mv.MembershipFees.CycleGenerator
|
||||||
|
|
@ -390,6 +392,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
@impl true
|
@impl true
|
||||||
def update(assigns, socket) do
|
def update(assigns, socket) do
|
||||||
member = assigns.member
|
member = assigns.member
|
||||||
|
actor = assigns.current_user
|
||||||
|
|
||||||
# Load cycles if not already loaded
|
# Load cycles if not already loaded
|
||||||
cycles =
|
cycles =
|
||||||
|
|
@ -403,7 +406,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
cycles = Enum.sort_by(cycles, & &1.cycle_start, {:desc, Date})
|
cycles = Enum.sort_by(cycles, & &1.cycle_start, {:desc, Date})
|
||||||
|
|
||||||
# Get available fee types (filtered to same interval if member has a type)
|
# Get available fee types (filtered to same interval if member has a type)
|
||||||
available_fee_types = get_available_fee_types(member)
|
available_fee_types = get_available_fee_types(member, actor)
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|
|
@ -424,7 +427,9 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("change_membership_fee_type", %{"value" => ""}, socket) do
|
def handle_event("change_membership_fee_type", %{"value" => ""}, socket) do
|
||||||
# Remove membership fee type
|
# Remove membership fee type
|
||||||
case update_member_fee_type(socket.assigns.member, nil) do
|
actor = current_actor(socket)
|
||||||
|
|
||||||
|
case update_member_fee_type(socket.assigns.member, nil, actor) do
|
||||||
{:ok, updated_member} ->
|
{:ok, updated_member} ->
|
||||||
send(self(), {:member_updated, updated_member})
|
send(self(), {:member_updated, updated_member})
|
||||||
|
|
||||||
|
|
@ -432,7 +437,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
socket
|
socket
|
||||||
|> assign(:member, updated_member)
|
|> assign(:member, updated_member)
|
||||||
|> assign(:cycles, [])
|
|> assign(:cycles, [])
|
||||||
|> assign(:available_fee_types, get_available_fee_types(updated_member))
|
|> assign(
|
||||||
|
:available_fee_types,
|
||||||
|
get_available_fee_types(updated_member, current_actor(socket))
|
||||||
|
)
|
||||||
|> assign(:interval_warning, nil)
|
|> assign(:interval_warning, nil)
|
||||||
|> put_flash(:info, gettext("Membership fee type removed"))}
|
|> put_flash(:info, gettext("Membership fee type removed"))}
|
||||||
|
|
||||||
|
|
@ -443,7 +451,8 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
|
|
||||||
def handle_event("change_membership_fee_type", %{"value" => fee_type_id}, socket) do
|
def handle_event("change_membership_fee_type", %{"value" => fee_type_id}, socket) do
|
||||||
member = socket.assigns.member
|
member = socket.assigns.member
|
||||||
new_fee_type = Ash.get!(MembershipFeeType, fee_type_id, domain: MembershipFees)
|
actor = current_actor(socket)
|
||||||
|
new_fee_type = Ash.get!(MembershipFeeType, fee_type_id, domain: MembershipFees, actor: actor)
|
||||||
|
|
||||||
# Check if interval matches
|
# Check if interval matches
|
||||||
interval_warning =
|
interval_warning =
|
||||||
|
|
@ -461,15 +470,22 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
if interval_warning do
|
if interval_warning do
|
||||||
{:noreply, assign(socket, :interval_warning, interval_warning)}
|
{:noreply, assign(socket, :interval_warning, interval_warning)}
|
||||||
else
|
else
|
||||||
case update_member_fee_type(member, fee_type_id) do
|
actor = current_actor(socket)
|
||||||
|
|
||||||
|
case update_member_fee_type(member, fee_type_id, actor) do
|
||||||
{:ok, updated_member} ->
|
{:ok, updated_member} ->
|
||||||
# Reload member with cycles
|
# Reload member with cycles
|
||||||
|
actor = current_actor(socket)
|
||||||
|
|
||||||
updated_member =
|
updated_member =
|
||||||
updated_member
|
updated_member
|
||||||
|> Ash.load!([
|
|> Ash.load!(
|
||||||
|
[
|
||||||
:membership_fee_type,
|
:membership_fee_type,
|
||||||
membership_fee_cycles: [:membership_fee_type]
|
membership_fee_cycles: [:membership_fee_type]
|
||||||
])
|
],
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
cycles =
|
cycles =
|
||||||
Enum.sort_by(
|
Enum.sort_by(
|
||||||
|
|
@ -484,7 +500,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
socket
|
socket
|
||||||
|> assign(:member, updated_member)
|
|> assign(:member, updated_member)
|
||||||
|> assign(:cycles, cycles)
|
|> assign(:cycles, cycles)
|
||||||
|> assign(:available_fee_types, get_available_fee_types(updated_member))
|
|> assign(
|
||||||
|
:available_fee_types,
|
||||||
|
get_available_fee_types(updated_member, current_actor(socket))
|
||||||
|
)
|
||||||
|> assign(:interval_warning, nil)
|
|> assign(:interval_warning, nil)
|
||||||
|> put_flash(:info, gettext("Membership fee type updated. Cycles regenerated."))}
|
|> put_flash(:info, gettext("Membership fee type updated. Cycles regenerated."))}
|
||||||
|
|
||||||
|
|
@ -505,7 +524,9 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
:suspended -> :mark_as_suspended
|
:suspended -> :mark_as_suspended
|
||||||
end
|
end
|
||||||
|
|
||||||
case Ash.update(cycle, action: action, domain: MembershipFees) do
|
actor = current_actor(socket)
|
||||||
|
|
||||||
|
case Ash.update(cycle, action: action, domain: MembershipFees, actor: actor) do
|
||||||
{:ok, updated_cycle} ->
|
{:ok, updated_cycle} ->
|
||||||
updated_cycles = replace_cycle(socket.assigns.cycles, updated_cycle)
|
updated_cycles = replace_cycle(socket.assigns.cycles, updated_cycle)
|
||||||
|
|
||||||
|
|
@ -535,16 +556,22 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
def handle_event("regenerate_cycles", _params, socket) do
|
def handle_event("regenerate_cycles", _params, socket) do
|
||||||
socket = assign(socket, :regenerating, true)
|
socket = assign(socket, :regenerating, true)
|
||||||
member = socket.assigns.member
|
member = socket.assigns.member
|
||||||
|
actor = current_actor(socket)
|
||||||
|
|
||||||
case CycleGenerator.generate_cycles_for_member(member.id) do
|
case CycleGenerator.generate_cycles_for_member(member.id, actor: actor) do
|
||||||
{:ok, _new_cycles, _notifications} ->
|
{:ok, _new_cycles, _notifications} ->
|
||||||
# Reload member with cycles
|
# Reload member with cycles
|
||||||
|
actor = current_actor(socket)
|
||||||
|
|
||||||
updated_member =
|
updated_member =
|
||||||
member
|
member
|
||||||
|> Ash.load!([
|
|> Ash.load!(
|
||||||
|
[
|
||||||
:membership_fee_type,
|
:membership_fee_type,
|
||||||
membership_fee_cycles: [:membership_fee_type]
|
membership_fee_cycles: [:membership_fee_type]
|
||||||
])
|
],
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
cycles =
|
cycles =
|
||||||
Enum.sort_by(
|
Enum.sort_by(
|
||||||
|
|
@ -574,7 +601,8 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
cycle = find_cycle(socket.assigns.cycles, cycle_id)
|
cycle = find_cycle(socket.assigns.cycles, cycle_id)
|
||||||
|
|
||||||
# Load cycle with membership_fee_type for display
|
# Load cycle with membership_fee_type for display
|
||||||
cycle = Ash.load!(cycle, :membership_fee_type)
|
actor = current_actor(socket)
|
||||||
|
cycle = Ash.load!(cycle, :membership_fee_type, actor: actor)
|
||||||
|
|
||||||
{:noreply, assign(socket, :editing_cycle, cycle)}
|
{:noreply, assign(socket, :editing_cycle, cycle)}
|
||||||
end
|
end
|
||||||
|
|
@ -591,9 +619,11 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
|
|
||||||
case Decimal.parse(normalized_amount_str) do
|
case Decimal.parse(normalized_amount_str) do
|
||||||
{amount, _} when is_struct(amount, Decimal) ->
|
{amount, _} when is_struct(amount, Decimal) ->
|
||||||
|
actor = current_actor(socket)
|
||||||
|
|
||||||
case cycle
|
case cycle
|
||||||
|> Ash.Changeset.for_update(:update, %{amount: amount})
|
|> Ash.Changeset.for_update(:update, %{amount: amount})
|
||||||
|> Ash.update(domain: MembershipFees) do
|
|> Ash.update(domain: MembershipFees, actor: actor) do
|
||||||
{:ok, updated_cycle} ->
|
{:ok, updated_cycle} ->
|
||||||
updated_cycles = replace_cycle(socket.assigns.cycles, updated_cycle)
|
updated_cycles = replace_cycle(socket.assigns.cycles, updated_cycle)
|
||||||
|
|
||||||
|
|
@ -618,7 +648,8 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
cycle = find_cycle(socket.assigns.cycles, cycle_id)
|
cycle = find_cycle(socket.assigns.cycles, cycle_id)
|
||||||
|
|
||||||
# Load cycle with membership_fee_type for display
|
# Load cycle with membership_fee_type for display
|
||||||
cycle = Ash.load!(cycle, :membership_fee_type)
|
actor = current_actor(socket)
|
||||||
|
cycle = Ash.load!(cycle, :membership_fee_type, actor: actor)
|
||||||
|
|
||||||
{:noreply, assign(socket, :deleting_cycle, cycle)}
|
{:noreply, assign(socket, :deleting_cycle, cycle)}
|
||||||
end
|
end
|
||||||
|
|
@ -629,8 +660,9 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
|
|
||||||
def handle_event("confirm_delete_cycle", %{"cycle_id" => cycle_id}, socket) do
|
def handle_event("confirm_delete_cycle", %{"cycle_id" => cycle_id}, socket) do
|
||||||
cycle = find_cycle(socket.assigns.cycles, cycle_id)
|
cycle = find_cycle(socket.assigns.cycles, cycle_id)
|
||||||
|
actor = current_actor(socket)
|
||||||
|
|
||||||
case Ash.destroy(cycle, domain: MembershipFees) do
|
case Ash.destroy(cycle, domain: MembershipFees, actor: actor) do
|
||||||
:ok ->
|
:ok ->
|
||||||
updated_cycles = Enum.reject(socket.assigns.cycles, &(&1.id == cycle_id))
|
updated_cycles = Enum.reject(socket.assigns.cycles, &(&1.id == cycle_id))
|
||||||
|
|
||||||
|
|
@ -701,12 +733,17 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
|
|
||||||
if deleted_count > 0 do
|
if deleted_count > 0 do
|
||||||
# Reload member to get updated cycles
|
# Reload member to get updated cycles
|
||||||
|
actor = current_actor(socket)
|
||||||
|
|
||||||
updated_member =
|
updated_member =
|
||||||
member
|
member
|
||||||
|> Ash.load!([
|
|> Ash.load!(
|
||||||
|
[
|
||||||
:membership_fee_type,
|
:membership_fee_type,
|
||||||
membership_fee_cycles: [:membership_fee_type]
|
membership_fee_cycles: [:membership_fee_type]
|
||||||
])
|
],
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
updated_cycles =
|
updated_cycles =
|
||||||
Enum.sort_by(
|
Enum.sort_by(
|
||||||
|
|
@ -788,15 +825,20 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
membership_fee_type_id: member.membership_fee_type_id
|
membership_fee_type_id: member.membership_fee_type_id
|
||||||
}
|
}
|
||||||
|
|
||||||
case Ash.create(MembershipFeeCycle, attrs, domain: MembershipFees) do
|
actor = current_actor(socket)
|
||||||
|
|
||||||
|
case Ash.create(MembershipFeeCycle, attrs, domain: MembershipFees, actor: actor) do
|
||||||
{:ok, _new_cycle} ->
|
{:ok, _new_cycle} ->
|
||||||
# Reload member with cycles
|
# Reload member with cycles
|
||||||
updated_member =
|
updated_member =
|
||||||
member
|
member
|
||||||
|> Ash.load!([
|
|> Ash.load!(
|
||||||
|
[
|
||||||
:membership_fee_type,
|
:membership_fee_type,
|
||||||
membership_fee_cycles: [:membership_fee_type]
|
membership_fee_cycles: [:membership_fee_type]
|
||||||
])
|
],
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
cycles =
|
cycles =
|
||||||
Enum.sort_by(
|
Enum.sort_by(
|
||||||
|
|
@ -844,11 +886,11 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
|
|
||||||
# Helper functions
|
# Helper functions
|
||||||
|
|
||||||
defp get_available_fee_types(member) do
|
defp get_available_fee_types(member, actor) do
|
||||||
all_types =
|
all_types =
|
||||||
MembershipFeeType
|
MembershipFeeType
|
||||||
|> Ash.Query.sort(name: :asc)
|
|> Ash.Query.sort(name: :asc)
|
||||||
|> Ash.read!()
|
|> Ash.read!(domain: MembershipFees, actor: actor)
|
||||||
|
|
||||||
# If member has a fee type, filter to same interval
|
# If member has a fee type, filter to same interval
|
||||||
if member.membership_fee_type do
|
if member.membership_fee_type do
|
||||||
|
|
@ -860,12 +902,12 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp update_member_fee_type(member, fee_type_id) do
|
defp update_member_fee_type(member, fee_type_id, actor) do
|
||||||
attrs = %{membership_fee_type_id: fee_type_id}
|
attrs = %{membership_fee_type_id: fee_type_id}
|
||||||
|
|
||||||
member
|
member
|
||||||
|> Ash.Changeset.for_update(:update_member, attrs, domain: Membership)
|
|> Ash.Changeset.for_update(:update_member, attrs, domain: Membership)
|
||||||
|> Ash.update(domain: Membership)
|
|> Ash.update(domain: Membership, actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp find_cycle(cycles, cycle_id) do
|
defp find_cycle(cycles, cycle_id) do
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,9 @@ defmodule MvWeb.MembershipFeeSettingsLive do
|
||||||
Map.put(params, "include_joining_cycle", false)
|
Map.put(params, "include_joining_cycle", false)
|
||||||
end
|
end
|
||||||
|
|
||||||
case AshPhoenix.Form.submit(socket.assigns.form, params: normalized_params) do
|
actor = MvWeb.LiveHelpers.current_actor(socket)
|
||||||
|
|
||||||
|
case MvWeb.LiveHelpers.submit_form(socket.assigns.form, normalized_params, actor) do
|
||||||
{:ok, updated_settings} ->
|
{:ok, updated_settings} ->
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,14 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
|
||||||
"""
|
"""
|
||||||
use MvWeb, :live_view
|
use MvWeb, :live_view
|
||||||
|
|
||||||
|
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
||||||
|
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
|
||||||
|
|
||||||
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
|
||||||
|
|
@ -305,7 +308,9 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
|
||||||
if socket.assigns.show_amount_warning do
|
if socket.assigns.show_amount_warning do
|
||||||
{:noreply, put_flash(socket, :error, gettext("Please confirm the amount change first"))}
|
{:noreply, put_flash(socket, :error, gettext("Please confirm the amount change first"))}
|
||||||
else
|
else
|
||||||
case AshPhoenix.Form.submit(socket.assigns.form, params: params) do
|
actor = current_actor(socket)
|
||||||
|
|
||||||
|
case submit_form(socket.assigns.form, params, actor) do
|
||||||
{:ok, membership_fee_type} ->
|
{:ok, membership_fee_type} ->
|
||||||
notify_parent({:saved, membership_fee_type})
|
notify_parent({:saved, membership_fee_type})
|
||||||
|
|
||||||
|
|
@ -380,7 +385,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
|
||||||
@spec return_path(String.t(), MembershipFeeType.t() | nil) :: String.t()
|
@spec return_path(String.t(), MembershipFeeType.t() | nil) :: String.t()
|
||||||
defp return_path("index", _membership_fee_type), do: ~p"/membership_fee_types"
|
defp return_path("index", _membership_fee_type), do: ~p"/membership_fee_types"
|
||||||
|
|
||||||
@spec get_affected_member_count(String.t()) :: non_neg_integer()
|
@spec get_affected_member_count(String.t(), Mv.Accounts.User.t() | nil) :: non_neg_integer()
|
||||||
# Checks if amount changed and updates socket assigns accordingly
|
# Checks if amount changed and updates socket assigns accordingly
|
||||||
defp check_amount_change(socket, params) do
|
defp check_amount_change(socket, params) do
|
||||||
if socket.assigns.membership_fee_type && Map.has_key?(params, "amount") do
|
if socket.assigns.membership_fee_type && Map.has_key?(params, "amount") do
|
||||||
|
|
@ -428,7 +433,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
|
||||||
socket.assigns.affected_member_count
|
socket.assigns.affected_member_count
|
||||||
else
|
else
|
||||||
# Warning being shown for first time, calculate count
|
# Warning being shown for first time, calculate count
|
||||||
get_affected_member_count(socket.assigns.membership_fee_type.id)
|
get_affected_member_count(socket.assigns.membership_fee_type.id, current_actor(socket))
|
||||||
end
|
end
|
||||||
|
|
||||||
socket
|
socket
|
||||||
|
|
@ -446,8 +451,10 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
|
||||||
|> assign(:pending_amount, nil)
|
|> assign(:pending_amount, nil)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp get_affected_member_count(fee_type_id) do
|
defp get_affected_member_count(fee_type_id, actor) do
|
||||||
case Ash.count(Member |> Ash.Query.filter(membership_fee_type_id == ^fee_type_id)) do
|
case Ash.count(Member |> Ash.Query.filter(membership_fee_type_id == ^fee_type_id),
|
||||||
|
actor: actor
|
||||||
|
) do
|
||||||
{:ok, count} -> count
|
{:ok, count} -> count
|
||||||
_ -> 0
|
_ -> 0
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -14,18 +14,22 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
|
||||||
"""
|
"""
|
||||||
use MvWeb, :live_view
|
use MvWeb, :live_view
|
||||||
|
|
||||||
|
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
||||||
|
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
alias Mv.MembershipFees
|
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
alias Mv.Membership.Member
|
alias Mv.Membership.Member
|
||||||
|
alias Mv.MembershipFees
|
||||||
|
alias Mv.MembershipFees.MembershipFeeType
|
||||||
alias MvWeb.Helpers.MembershipFeeHelpers
|
alias MvWeb.Helpers.MembershipFeeHelpers
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
fee_types = load_membership_fee_types()
|
actor = current_actor(socket)
|
||||||
member_counts = load_member_counts(fee_types)
|
fee_types = load_membership_fee_types(actor)
|
||||||
|
member_counts = load_member_counts(fee_types, actor)
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|
|
@ -115,7 +119,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>
|
||||||
|
|
@ -129,9 +133,11 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("delete", %{"id" => id}, socket) do
|
def handle_event("delete", %{"id" => id}, socket) do
|
||||||
fee_type = Ash.get!(MembershipFeeType, id, domain: MembershipFees)
|
actor = current_actor(socket)
|
||||||
|
|
||||||
case Ash.destroy(fee_type, domain: MembershipFees) do
|
case Ash.get(MembershipFeeType, id, domain: MembershipFees, actor: actor) do
|
||||||
|
{:ok, fee_type} ->
|
||||||
|
case Ash.destroy(fee_type, domain: MembershipFees, actor: actor) do
|
||||||
:ok ->
|
:ok ->
|
||||||
updated_types = Enum.reject(socket.assigns.membership_fee_types, &(&1.id == id))
|
updated_types = Enum.reject(socket.assigns.membership_fee_types, &(&1.id == id))
|
||||||
updated_counts = Map.delete(socket.assigns.member_counts, id)
|
updated_counts = Map.delete(socket.assigns.member_counts, id)
|
||||||
|
|
@ -142,6 +148,29 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
|
||||||
|> assign(:member_counts, updated_counts)
|
|> assign(:member_counts, updated_counts)
|
||||||
|> put_flash(:info, gettext("Membership fee type deleted"))}
|
|> put_flash(:info, gettext("Membership fee type deleted"))}
|
||||||
|
|
||||||
|
{:error, %Ash.Error.Forbidden{}} ->
|
||||||
|
{:noreply,
|
||||||
|
put_flash(
|
||||||
|
socket,
|
||||||
|
:error,
|
||||||
|
gettext("You do not have permission to delete this membership fee type")
|
||||||
|
)}
|
||||||
|
|
||||||
|
{:error, error} ->
|
||||||
|
{:noreply, put_flash(socket, :error, format_error(error))}
|
||||||
|
end
|
||||||
|
|
||||||
|
{:error, %Ash.Error.Query.NotFound{}} ->
|
||||||
|
{:noreply, put_flash(socket, :error, gettext("Membership fee type not found"))}
|
||||||
|
|
||||||
|
{:error, %Ash.Error.Forbidden{} = _error} ->
|
||||||
|
{:noreply,
|
||||||
|
put_flash(
|
||||||
|
socket,
|
||||||
|
:error,
|
||||||
|
gettext("You do not have permission to access this membership fee type")
|
||||||
|
)}
|
||||||
|
|
||||||
{:error, error} ->
|
{:error, error} ->
|
||||||
{:noreply, put_flash(socket, :error, format_error(error))}
|
{:noreply, put_flash(socket, :error, format_error(error))}
|
||||||
end
|
end
|
||||||
|
|
@ -149,14 +178,14 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
|
||||||
|
|
||||||
# Helper functions
|
# Helper functions
|
||||||
|
|
||||||
defp load_membership_fee_types do
|
defp load_membership_fee_types(actor) do
|
||||||
MembershipFeeType
|
MembershipFeeType
|
||||||
|> Ash.Query.sort(name: :asc)
|
|> Ash.Query.sort(name: :asc)
|
||||||
|> Ash.read!(domain: MembershipFees)
|
|> Ash.read!(domain: MembershipFees, actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Loads all member counts for fee types in a single query to avoid N+1 queries
|
# Loads all member counts for fee types in a single query to avoid N+1 queries
|
||||||
defp load_member_counts(fee_types) do
|
defp load_member_counts(fee_types, actor) do
|
||||||
fee_type_ids = Enum.map(fee_types, & &1.id)
|
fee_type_ids = Enum.map(fee_types, & &1.id)
|
||||||
|
|
||||||
# Load all members with membership_fee_type_id in a single query
|
# Load all members with membership_fee_type_id in a single query
|
||||||
|
|
@ -164,7 +193,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
|
||||||
Member
|
Member
|
||||||
|> Ash.Query.filter(membership_fee_type_id in ^fee_type_ids)
|
|> Ash.Query.filter(membership_fee_type_id in ^fee_type_ids)
|
||||||
|> Ash.Query.select([:membership_fee_type_id])
|
|> Ash.Query.select([:membership_fee_type_id])
|
||||||
|> Ash.read!(domain: Membership)
|
|> Ash.read!(domain: Membership, actor: actor)
|
||||||
|
|
||||||
# Group by membership_fee_type_id and count
|
# Group by membership_fee_type_id and count
|
||||||
members
|
members
|
||||||
|
|
|
||||||
|
|
@ -162,7 +162,9 @@ defmodule MvWeb.RoleLive.Form do
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("save", %{"role" => role_params}, socket) do
|
def handle_event("save", %{"role" => role_params}, socket) do
|
||||||
case AshPhoenix.Form.submit(socket.assigns.form, params: role_params) do
|
actor = MvWeb.LiveHelpers.current_actor(socket)
|
||||||
|
|
||||||
|
case MvWeb.LiveHelpers.submit_form(socket.assigns.form, role_params, actor) do
|
||||||
{:ok, role} ->
|
{:ok, role} ->
|
||||||
notify_parent({:saved, role})
|
notify_parent({:saved, role})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,7 @@ defmodule MvWeb.RoleLive.Index do
|
||||||
|
|
||||||
@spec load_roles(map() | nil) :: [Mv.Authorization.Role.t()]
|
@spec load_roles(map() | nil) :: [Mv.Authorization.Role.t()]
|
||||||
defp load_roles(actor) do
|
defp load_roles(actor) do
|
||||||
opts = if actor, do: [actor: actor], else: []
|
opts = MvWeb.LiveHelpers.ash_actor_opts(actor)
|
||||||
|
|
||||||
case Authorization.list_roles(opts) do
|
case Authorization.list_roles(opts) do
|
||||||
{:ok, roles} -> Enum.sort_by(roles, & &1.name)
|
{:ok, roles} -> Enum.sort_by(roles, & &1.name)
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,9 @@ defmodule MvWeb.UserLive.Form do
|
||||||
"""
|
"""
|
||||||
use MvWeb, :live_view
|
use MvWeb, :live_view
|
||||||
|
|
||||||
|
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
||||||
|
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
|
|
@ -258,10 +261,12 @@ defmodule MvWeb.UserLive.Form do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(params, _session, socket) do
|
def mount(params, _session, socket) do
|
||||||
|
actor = current_actor(socket)
|
||||||
|
|
||||||
user =
|
user =
|
||||||
case params["id"] do
|
case params["id"] do
|
||||||
nil -> nil
|
nil -> nil
|
||||||
id -> Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member])
|
id -> Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member], actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
action = if is_nil(user), do: gettext("New"), else: gettext("Edit")
|
action = if is_nil(user), do: gettext("New"), else: gettext("Edit")
|
||||||
|
|
@ -300,6 +305,7 @@ defmodule MvWeb.UserLive.Form do
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
def handle_event("validate", %{"user" => user_params}, socket) do
|
def handle_event("validate", %{"user" => user_params}, socket) do
|
||||||
validated_form = AshPhoenix.Form.validate(socket.assigns.form, user_params)
|
validated_form = AshPhoenix.Form.validate(socket.assigns.form, user_params)
|
||||||
|
|
||||||
|
|
@ -307,7 +313,7 @@ defmodule MvWeb.UserLive.Form do
|
||||||
socket =
|
socket =
|
||||||
if Map.has_key?(user_params, "email") do
|
if Map.has_key?(user_params, "email") do
|
||||||
user_email = user_params["email"]
|
user_email = user_params["email"]
|
||||||
members = load_members_for_linking(user_email, socket.assigns.member_search_query)
|
members = load_members_for_linking(user_email, socket.assigns.member_search_query, socket)
|
||||||
|
|
||||||
assign(socket, form: validated_form, available_members: members)
|
assign(socket, form: validated_form, available_members: members)
|
||||||
else
|
else
|
||||||
|
|
@ -317,62 +323,30 @@ defmodule MvWeb.UserLive.Form do
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
def handle_event("save", %{"user" => user_params}, socket) do
|
def handle_event("save", %{"user" => user_params}, socket) do
|
||||||
|
actor = current_actor(socket)
|
||||||
# First save the user without member changes
|
# First save the user without member changes
|
||||||
case AshPhoenix.Form.submit(socket.assigns.form, params: user_params) do
|
case submit_form(socket.assigns.form, user_params, actor) do
|
||||||
{:ok, user} ->
|
{:ok, user} ->
|
||||||
# Then handle member linking/unlinking as a separate step
|
handle_member_linking(socket, user, actor)
|
||||||
result =
|
|
||||||
cond do
|
|
||||||
# Selected member ID takes precedence (new link)
|
|
||||||
socket.assigns.selected_member_id ->
|
|
||||||
Mv.Accounts.update_user(user, %{member: %{id: socket.assigns.selected_member_id}})
|
|
||||||
|
|
||||||
# Unlink flag is set
|
|
||||||
socket.assigns[:unlink_member] ->
|
|
||||||
Mv.Accounts.update_user(user, %{member: nil})
|
|
||||||
|
|
||||||
# No changes to member relationship
|
|
||||||
true ->
|
|
||||||
{:ok, user}
|
|
||||||
end
|
|
||||||
|
|
||||||
case result do
|
|
||||||
{:ok, updated_user} ->
|
|
||||||
notify_parent({:saved, updated_user})
|
|
||||||
|
|
||||||
socket =
|
|
||||||
socket
|
|
||||||
|> put_flash(:info, "User #{socket.assigns.form.source.type}d successfully")
|
|
||||||
|> push_navigate(to: return_path(socket.assigns.return_to, updated_user))
|
|
||||||
|
|
||||||
{:noreply, socket}
|
|
||||||
|
|
||||||
{:error, error} ->
|
|
||||||
# Show user-friendly error from member linking/unlinking
|
|
||||||
error_message = extract_error_message(error)
|
|
||||||
|
|
||||||
{:noreply,
|
|
||||||
put_flash(
|
|
||||||
socket,
|
|
||||||
:error,
|
|
||||||
gettext("Failed to link member: %{error}", error: error_message)
|
|
||||||
)}
|
|
||||||
end
|
|
||||||
|
|
||||||
{:error, form} ->
|
{:error, form} ->
|
||||||
{:noreply, assign(socket, form: form)}
|
{:noreply, assign(socket, form: form)}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
def handle_event("show_member_dropdown", _params, socket) do
|
def handle_event("show_member_dropdown", _params, socket) do
|
||||||
{:noreply, assign(socket, show_member_dropdown: true)}
|
{:noreply, assign(socket, show_member_dropdown: true)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
def handle_event("hide_member_dropdown", _params, socket) do
|
def handle_event("hide_member_dropdown", _params, socket) do
|
||||||
{:noreply, assign(socket, show_member_dropdown: false, focused_member_index: nil)}
|
{:noreply, assign(socket, show_member_dropdown: false, focused_member_index: nil)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
def handle_event("member_dropdown_keydown", %{"key" => "ArrowDown"}, socket) do
|
def handle_event("member_dropdown_keydown", %{"key" => "ArrowDown"}, socket) do
|
||||||
return_if_dropdown_closed(socket, fn ->
|
return_if_dropdown_closed(socket, fn ->
|
||||||
max_index = length(socket.assigns.available_members) - 1
|
max_index = length(socket.assigns.available_members) - 1
|
||||||
|
|
@ -389,6 +363,7 @@ defmodule MvWeb.UserLive.Form do
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
def handle_event("member_dropdown_keydown", %{"key" => "ArrowUp"}, socket) do
|
def handle_event("member_dropdown_keydown", %{"key" => "ArrowUp"}, socket) do
|
||||||
return_if_dropdown_closed(socket, fn ->
|
return_if_dropdown_closed(socket, fn ->
|
||||||
current = socket.assigns.focused_member_index
|
current = socket.assigns.focused_member_index
|
||||||
|
|
@ -404,23 +379,27 @@ defmodule MvWeb.UserLive.Form do
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
def handle_event("member_dropdown_keydown", %{"key" => "Enter"}, socket) do
|
def handle_event("member_dropdown_keydown", %{"key" => "Enter"}, socket) do
|
||||||
return_if_dropdown_closed(socket, fn ->
|
return_if_dropdown_closed(socket, fn ->
|
||||||
select_focused_member(socket)
|
select_focused_member(socket)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
def handle_event("member_dropdown_keydown", %{"key" => "Escape"}, socket) do
|
def handle_event("member_dropdown_keydown", %{"key" => "Escape"}, socket) do
|
||||||
return_if_dropdown_closed(socket, fn ->
|
return_if_dropdown_closed(socket, fn ->
|
||||||
{:noreply, assign(socket, show_member_dropdown: false, focused_member_index: nil)}
|
{:noreply, assign(socket, show_member_dropdown: false, focused_member_index: nil)}
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
def handle_event("member_dropdown_keydown", _params, socket) do
|
def handle_event("member_dropdown_keydown", _params, socket) do
|
||||||
# Ignore other keys
|
# Ignore other keys
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
def handle_event("search_members", %{"member_search" => query}, socket) do
|
def handle_event("search_members", %{"member_search" => query}, socket) do
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|
|
@ -432,6 +411,7 @@ defmodule MvWeb.UserLive.Form do
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
def handle_event("select_member", %{"id" => member_id}, socket) do
|
def handle_event("select_member", %{"id" => member_id}, socket) do
|
||||||
# Find the selected member to get their name
|
# Find the selected member to get their name
|
||||||
selected_member = Enum.find(socket.assigns.available_members, &(&1.id == member_id))
|
selected_member = Enum.find(socket.assigns.available_members, &(&1.id == member_id))
|
||||||
|
|
@ -448,27 +428,82 @@ defmodule MvWeb.UserLive.Form do
|
||||||
|> assign(:selected_member_name, member_name)
|
|> assign(:selected_member_name, member_name)
|
||||||
|> assign(:unlink_member, false)
|
|> assign(:unlink_member, false)
|
||||||
|> assign(:show_member_dropdown, false)
|
|> assign(:show_member_dropdown, false)
|
||||||
|> assign(:member_search_query, member_name)
|
|> assign(:focused_member_index, nil)
|
||||||
|> push_event("set-input-value", %{id: "member-search-input", value: member_name})
|
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
def handle_event("unlink_member", _params, socket) do
|
def handle_event("unlink_member", _params, socket) do
|
||||||
# Set flag to unlink member on save
|
|
||||||
# Clear all member selection state and keep dropdown hidden
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> assign(:unlink_member, true)
|
|
||||||
|> assign(:selected_member_id, nil)
|
|> assign(:selected_member_id, nil)
|
||||||
|> assign(:selected_member_name, nil)
|
|> assign(:selected_member_name, nil)
|
||||||
|> assign(:member_search_query, "")
|
|> assign(:unlink_member, true)
|
||||||
|> assign(:show_member_dropdown, false)
|
|> assign(:show_member_dropdown, false)
|
||||||
|> load_initial_members()
|
|> assign(:focused_member_index, nil)
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp handle_member_linking(socket, user, actor) do
|
||||||
|
result = perform_member_link_action(socket, user, actor)
|
||||||
|
|
||||||
|
case result do
|
||||||
|
{:ok, updated_user} ->
|
||||||
|
handle_save_success(socket, updated_user)
|
||||||
|
|
||||||
|
{:error, error} ->
|
||||||
|
handle_member_link_error(socket, error)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp perform_member_link_action(socket, user, actor) do
|
||||||
|
cond do
|
||||||
|
# Selected member ID takes precedence (new link)
|
||||||
|
socket.assigns.selected_member_id ->
|
||||||
|
Mv.Accounts.update_user(user, %{member: %{id: socket.assigns.selected_member_id}},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
|
# Unlink flag is set
|
||||||
|
socket.assigns[:unlink_member] ->
|
||||||
|
Mv.Accounts.update_user(user, %{member: nil}, actor: actor)
|
||||||
|
|
||||||
|
# No changes to member relationship
|
||||||
|
true ->
|
||||||
|
{:ok, user}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp handle_save_success(socket, updated_user) do
|
||||||
|
notify_parent({:saved, updated_user})
|
||||||
|
|
||||||
|
action = get_action_name(socket.assigns.form.source.type)
|
||||||
|
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> put_flash(:info, gettext("User %{action} successfully", action: action))
|
||||||
|
|> push_navigate(to: return_path(socket.assigns.return_to, updated_user))
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_action_name(:create), do: gettext("created")
|
||||||
|
defp get_action_name(:update), do: gettext("updated")
|
||||||
|
defp get_action_name(other), do: to_string(other)
|
||||||
|
|
||||||
|
defp handle_member_link_error(socket, error) do
|
||||||
|
error_message = extract_error_message(error)
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
put_flash(
|
||||||
|
socket,
|
||||||
|
:error,
|
||||||
|
gettext("Failed to link member: %{error}", error: error_message)
|
||||||
|
)}
|
||||||
|
end
|
||||||
|
|
||||||
@spec notify_parent(any()) :: any()
|
@spec notify_parent(any()) :: any()
|
||||||
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
|
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
|
||||||
|
|
||||||
|
|
@ -497,18 +532,21 @@ defmodule MvWeb.UserLive.Form do
|
||||||
|
|
||||||
@spec assign_form(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()
|
@spec assign_form(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()
|
||||||
defp assign_form(%{assigns: %{user: user, show_password_fields: show_password_fields}} = socket) do
|
defp assign_form(%{assigns: %{user: user, show_password_fields: show_password_fields}} = socket) do
|
||||||
|
actor = current_actor(socket)
|
||||||
|
|
||||||
form =
|
form =
|
||||||
if user do
|
if user do
|
||||||
# For existing users, use admin password action if password fields are shown
|
# For existing users, use admin password action if password fields are shown
|
||||||
action = if show_password_fields, do: :admin_set_password, else: :update_user
|
action = if show_password_fields, do: :admin_set_password, else: :update_user
|
||||||
AshPhoenix.Form.for_update(user, action, domain: Mv.Accounts, as: "user")
|
AshPhoenix.Form.for_update(user, action, domain: Mv.Accounts, as: "user", actor: actor)
|
||||||
else
|
else
|
||||||
# For new users, use password registration if password fields are shown
|
# For new users, use password registration if password fields are shown
|
||||||
action = if show_password_fields, do: :register_with_password, else: :create_user
|
action = if show_password_fields, do: :register_with_password, else: :create_user
|
||||||
|
|
||||||
AshPhoenix.Form.for_create(Mv.Accounts.User, action,
|
AshPhoenix.Form.for_create(Mv.Accounts.User, action,
|
||||||
domain: Mv.Accounts,
|
domain: Mv.Accounts,
|
||||||
as: "user"
|
as: "user",
|
||||||
|
actor: actor
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -524,7 +562,7 @@ defmodule MvWeb.UserLive.Form do
|
||||||
user = socket.assigns.user
|
user = socket.assigns.user
|
||||||
user_email = if user, do: user.email, else: nil
|
user_email = if user, do: user.email, else: nil
|
||||||
|
|
||||||
members = load_members_for_linking(user_email, "")
|
members = load_members_for_linking(user_email, "", socket)
|
||||||
|
|
||||||
# Dropdown should ALWAYS be hidden initially
|
# Dropdown should ALWAYS be hidden initially
|
||||||
# It will only show when user focuses the input field (show_member_dropdown event)
|
# It will only show when user focuses the input field (show_member_dropdown event)
|
||||||
|
|
@ -539,12 +577,15 @@ defmodule MvWeb.UserLive.Form do
|
||||||
user = socket.assigns.user
|
user = socket.assigns.user
|
||||||
user_email = if user, do: user.email, else: nil
|
user_email = if user, do: user.email, else: nil
|
||||||
|
|
||||||
members = load_members_for_linking(user_email, query)
|
members = load_members_for_linking(user_email, query, socket)
|
||||||
assign(socket, available_members: members)
|
assign(socket, available_members: members)
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec load_members_for_linking(String.t() | nil, String.t() | nil) :: [Mv.Membership.Member.t()]
|
@spec load_members_for_linking(String.t() | nil, String.t() | nil, Phoenix.LiveView.Socket.t()) ::
|
||||||
defp load_members_for_linking(user_email, search_query) do
|
[
|
||||||
|
Mv.Membership.Member.t()
|
||||||
|
]
|
||||||
|
defp load_members_for_linking(user_email, search_query, socket) do
|
||||||
user_email_str = if user_email, do: to_string(user_email), else: nil
|
user_email_str = if user_email, do: to_string(user_email), else: nil
|
||||||
search_query_str = if search_query && search_query != "", do: search_query, else: nil
|
search_query_str = if search_query && search_query != "", do: search_query, else: nil
|
||||||
|
|
||||||
|
|
@ -555,18 +596,25 @@ defmodule MvWeb.UserLive.Form do
|
||||||
search_query: search_query_str
|
search_query: search_query_str
|
||||||
})
|
})
|
||||||
|
|
||||||
case Ash.read(query, domain: Mv.Membership) do
|
actor = current_actor(socket)
|
||||||
{:ok, members} ->
|
|
||||||
# Apply email match filter if user_email is provided
|
# Early return if no actor (prevents exceptions in unauthenticated tests)
|
||||||
if user_email_str do
|
if is_nil(actor) do
|
||||||
Mv.Membership.Member.filter_by_email_match(members, user_email_str)
|
[]
|
||||||
else
|
else
|
||||||
members
|
case Ash.read(query, domain: Mv.Membership, actor: actor) do
|
||||||
|
{:ok, members} -> apply_email_filter(members, user_email_str)
|
||||||
|
{:error, _} -> []
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
{:error, _} ->
|
@spec apply_email_filter([Mv.Membership.Member.t()], String.t() | nil) ::
|
||||||
[]
|
[Mv.Membership.Member.t()]
|
||||||
end
|
defp apply_email_filter(members, nil), do: members
|
||||||
|
|
||||||
|
defp apply_email_filter(members, user_email_str) when is_binary(user_email_str) do
|
||||||
|
Mv.Membership.Member.filter_by_email_match(members, user_email_str)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Extract user-friendly error message from Ash.Error
|
# Extract user-friendly error message from Ash.Error
|
||||||
|
|
@ -576,10 +624,10 @@ defmodule MvWeb.UserLive.Form do
|
||||||
case List.first(errors) do
|
case List.first(errors) do
|
||||||
%{message: message} when is_binary(message) -> message
|
%{message: message} when is_binary(message) -> message
|
||||||
%{field: field, message: message} -> "#{field}: #{message}"
|
%{field: field, message: message} -> "#{field}: #{message}"
|
||||||
_ -> "Unknown error"
|
_ -> gettext("Unknown error")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp extract_error_message(error) when is_binary(error), do: error
|
defp extract_error_message(error) when is_binary(error), do: error
|
||||||
defp extract_error_message(_), do: "Unknown error"
|
defp extract_error_message(_), do: gettext("Unknown error")
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,13 @@ defmodule MvWeb.UserLive.Index do
|
||||||
use MvWeb, :live_view
|
use MvWeb, :live_view
|
||||||
import MvWeb.TableComponents
|
import MvWeb.TableComponents
|
||||||
|
|
||||||
|
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
||||||
|
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
users = Ash.read!(Mv.Accounts.User, domain: Mv.Accounts, load: [:member])
|
actor = current_actor(socket)
|
||||||
|
users = Ash.read!(Mv.Accounts.User, domain: Mv.Accounts, load: [:member], actor: actor)
|
||||||
sorted = Enum.sort_by(users, & &1.email)
|
sorted = Enum.sort_by(users, & &1.email)
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
|
|
@ -39,11 +43,41 @@ defmodule MvWeb.UserLive.Index do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("delete", %{"id" => id}, socket) do
|
def handle_event("delete", %{"id" => id}, socket) do
|
||||||
user = Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts)
|
actor = current_actor(socket)
|
||||||
Ash.destroy!(user, domain: Mv.Accounts)
|
|
||||||
|
|
||||||
|
case Ash.get(Mv.Accounts.User, id, domain: Mv.Accounts, actor: actor) do
|
||||||
|
{:ok, user} ->
|
||||||
|
case Ash.destroy(user, domain: Mv.Accounts, actor: actor) do
|
||||||
|
:ok ->
|
||||||
updated_users = Enum.reject(socket.assigns.users, &(&1.id == id))
|
updated_users = Enum.reject(socket.assigns.users, &(&1.id == id))
|
||||||
{:noreply, assign(socket, :users, updated_users)}
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:users, updated_users)
|
||||||
|
|> put_flash(:info, gettext("User deleted successfully"))}
|
||||||
|
|
||||||
|
{:error, %Ash.Error.Forbidden{}} ->
|
||||||
|
{:noreply,
|
||||||
|
put_flash(
|
||||||
|
socket,
|
||||||
|
:error,
|
||||||
|
gettext("You do not have permission to delete this user")
|
||||||
|
)}
|
||||||
|
|
||||||
|
{:error, error} ->
|
||||||
|
{:noreply, put_flash(socket, :error, format_error(error))}
|
||||||
|
end
|
||||||
|
|
||||||
|
{:error, %Ash.Error.Query.NotFound{}} ->
|
||||||
|
{:noreply, put_flash(socket, :error, gettext("User not found"))}
|
||||||
|
|
||||||
|
{:error, %Ash.Error.Forbidden{} = _error} ->
|
||||||
|
{:noreply,
|
||||||
|
put_flash(socket, :error, gettext("You do not have permission to access this user"))}
|
||||||
|
|
||||||
|
{:error, error} ->
|
||||||
|
{:noreply, put_flash(socket, :error, format_error(error))}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Selects one user in the list of users
|
# Selects one user in the list of users
|
||||||
|
|
@ -104,4 +138,12 @@ defmodule MvWeb.UserLive.Index do
|
||||||
defp toggle_order(:desc), do: :asc
|
defp toggle_order(:desc), do: :asc
|
||||||
defp sort_fun(:asc), do: &<=/2
|
defp sort_fun(:asc), do: &<=/2
|
||||||
defp sort_fun(:desc), do: &>=/2
|
defp sort_fun(:desc), do: &>=/2
|
||||||
|
|
||||||
|
defp format_error(%Ash.Error.Invalid{errors: errors}) do
|
||||||
|
Enum.map_join(errors, ", ", fn %{message: message} -> message end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_error(error) do
|
||||||
|
inspect(error)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,9 @@ defmodule MvWeb.UserLive.Show do
|
||||||
"""
|
"""
|
||||||
use MvWeb, :live_view
|
use MvWeb, :live_view
|
||||||
|
|
||||||
|
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
||||||
|
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
|
|
@ -70,7 +73,8 @@ defmodule MvWeb.UserLive.Show do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(%{"id" => id}, _session, socket) do
|
def mount(%{"id" => id}, _session, socket) do
|
||||||
user = Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member])
|
actor = current_actor(socket)
|
||||||
|
user = Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member], actor: actor)
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|
|
|
||||||
|
|
@ -59,4 +59,53 @@ defmodule MvWeb.LiveHelpers do
|
||||||
user
|
user
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Helper function to get the current actor (user) from socket assigns.
|
||||||
|
|
||||||
|
Provides consistent access pattern across all LiveViews.
|
||||||
|
Returns nil if no current_user is present.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
actor = current_actor(socket)
|
||||||
|
members = Membership.list_members!(actor: actor)
|
||||||
|
"""
|
||||||
|
@spec current_actor(Phoenix.LiveView.Socket.t()) :: Mv.Accounts.User.t() | nil
|
||||||
|
def current_actor(socket) do
|
||||||
|
socket.assigns[:current_user] || socket.assigns.current_user
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Converts an actor to Ash options list for authorization.
|
||||||
|
Returns empty list if actor is nil.
|
||||||
|
|
||||||
|
Delegates to `Mv.Helpers.ash_actor_opts/1` for consistency across the application.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
opts = ash_actor_opts(actor)
|
||||||
|
Ash.read(query, opts)
|
||||||
|
"""
|
||||||
|
@spec ash_actor_opts(Mv.Accounts.User.t() | nil) :: keyword()
|
||||||
|
defdelegate ash_actor_opts(actor), to: Mv.Helpers
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Submits an AshPhoenix form with consistent actor handling.
|
||||||
|
|
||||||
|
This wrapper ensures that actor is always passed via `action_opts`
|
||||||
|
in a consistent manner across all LiveViews.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
case submit_form(form, params, actor) do
|
||||||
|
{:ok, resource} -> # success
|
||||||
|
{:error, form} -> # validation errors
|
||||||
|
end
|
||||||
|
"""
|
||||||
|
@spec submit_form(AshPhoenix.Form.t(), map(), Mv.Accounts.User.t() | nil) ::
|
||||||
|
{:ok, Ash.Resource.t()} | {:error, AshPhoenix.Form.t()}
|
||||||
|
def submit_form(form, params, actor) do
|
||||||
|
AshPhoenix.Form.submit(form, params: params, action_opts: ash_actor_opts(actor))
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ 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
|
||||||
|
|
|
||||||
1
mix.exs
1
mix.exs
|
|
@ -76,6 +76,7 @@ defmodule Mv.MixProject do
|
||||||
{:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false},
|
{:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false},
|
||||||
{:sobelow, "~> 0.14", only: [:dev, :test], runtime: false},
|
{:sobelow, "~> 0.14", only: [:dev, :test], runtime: false},
|
||||||
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
|
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
|
||||||
|
{:picosat_elixir, "~> 0.1", only: [:dev, :test]},
|
||||||
{:ecto_commons, "~> 0.3"},
|
{:ecto_commons, "~> 0.3"},
|
||||||
{:slugify, "~> 1.3"}
|
{:slugify, "~> 1.3"}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
1
mix.lock
1
mix.lock
|
|
@ -60,6 +60,7 @@
|
||||||
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
|
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
|
||||||
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
|
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
|
||||||
"phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"},
|
"phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"},
|
||||||
|
"picosat_elixir": {:hex, :picosat_elixir, "0.2.3", "bf326d0f179fbb3b706bb2c15fbc367dacfa2517157d090fdfc32edae004c597", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f76c9db2dec9d2561ffaa9be35f65403d53e984e8cd99c832383b7ab78c16c66"},
|
||||||
"plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
|
"plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
|
||||||
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
|
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
|
||||||
"postgrex": {:hex, :postgrex, "0.21.1", "2c5cc830ec11e7a0067dd4d623c049b3ef807e9507a424985b8dcf921224cd88", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "27d8d21c103c3cc68851b533ff99eef353e6a0ff98dc444ea751de43eb48bdac"},
|
"postgrex": {:hex, :postgrex, "0.21.1", "2c5cc830ec11e7a0067dd4d623c049b3ef807e9507a424985b8dcf921224cd88", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "27d8d21c103c3cc68851b533ff99eef353e6a0ff98dc444ea751de43eb48bdac"},
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -8,7 +8,7 @@
|
||||||
## to merge POT files into PO files.
|
## to merge POT files into PO files.
|
||||||
msgid ""
|
msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Language: en\n"
|
"Language: de\n"
|
||||||
|
|
||||||
## From Ecto.Changeset.cast/4
|
## From Ecto.Changeset.cast/4
|
||||||
msgid "can't be blank"
|
msgid "can't be blank"
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -162,6 +162,17 @@ if admin_role do
|
||||||
|> Ash.update!()
|
|> Ash.update!()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Load admin user with role for use as actor in member operations
|
||||||
|
# This ensures all member operations have proper authorization
|
||||||
|
# If admin role creation failed, we cannot proceed with member operations
|
||||||
|
admin_user_with_role =
|
||||||
|
if admin_role do
|
||||||
|
admin_user
|
||||||
|
|> Ash.load!(:role)
|
||||||
|
else
|
||||||
|
raise "Failed to create or find admin role. Cannot proceed with member seeding."
|
||||||
|
end
|
||||||
|
|
||||||
# Load all membership fee types for assignment
|
# Load all membership fee types for assignment
|
||||||
# Sort by name to ensure deterministic order
|
# Sort by name to ensure deterministic order
|
||||||
all_fee_types =
|
all_fee_types =
|
||||||
|
|
@ -236,7 +247,8 @@ Enum.each(member_attrs_list, fn member_attrs ->
|
||||||
member =
|
member =
|
||||||
Membership.create_member!(member_attrs_without_fee_type,
|
Membership.create_member!(member_attrs_without_fee_type,
|
||||||
upsert?: true,
|
upsert?: true,
|
||||||
upsert_identity: :unique_email
|
upsert_identity: :unique_email,
|
||||||
|
actor: admin_user_with_role
|
||||||
)
|
)
|
||||||
|
|
||||||
# Only set membership_fee_type_id if member doesn't have one yet (idempotent)
|
# Only set membership_fee_type_id if member doesn't have one yet (idempotent)
|
||||||
|
|
@ -247,7 +259,8 @@ Enum.each(member_attrs_list, fn member_attrs ->
|
||||||
|> Ash.Changeset.for_update(:update_member, %{
|
|> Ash.Changeset.for_update(:update_member, %{
|
||||||
membership_fee_type_id: member_attrs_without_status.membership_fee_type_id
|
membership_fee_type_id: member_attrs_without_status.membership_fee_type_id
|
||||||
})
|
})
|
||||||
|> Ash.update!()
|
|> Ash.Changeset.put_context(:actor, admin_user_with_role)
|
||||||
|
|> Ash.update!(actor: admin_user_with_role)
|
||||||
else
|
else
|
||||||
member
|
member
|
||||||
end
|
end
|
||||||
|
|
@ -264,7 +277,10 @@ Enum.each(member_attrs_list, fn member_attrs ->
|
||||||
if Enum.empty?(member_with_cycles.membership_fee_cycles) do
|
if Enum.empty?(member_with_cycles.membership_fee_cycles) do
|
||||||
# Generate cycles
|
# Generate cycles
|
||||||
{:ok, new_cycles, _notifications} =
|
{:ok, new_cycles, _notifications} =
|
||||||
CycleGenerator.generate_cycles_for_member(final_member.id, skip_lock?: true)
|
CycleGenerator.generate_cycles_for_member(final_member.id,
|
||||||
|
skip_lock?: true,
|
||||||
|
actor: admin_user_with_role
|
||||||
|
)
|
||||||
|
|
||||||
new_cycles
|
new_cycles
|
||||||
else
|
else
|
||||||
|
|
@ -299,7 +315,7 @@ Enum.each(member_attrs_list, fn member_attrs ->
|
||||||
if cycle.status != status do
|
if cycle.status != status do
|
||||||
cycle
|
cycle
|
||||||
|> Ash.Changeset.for_update(:update, %{status: status})
|
|> Ash.Changeset.for_update(:update, %{status: status})
|
||||||
|> Ash.update!()
|
|> Ash.update!(actor: admin_user_with_role)
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
@ -371,13 +387,15 @@ Enum.with_index(linked_members)
|
||||||
Membership.create_member!(
|
Membership.create_member!(
|
||||||
Map.put(member_attrs_without_fee_type, :user, %{id: user.id}),
|
Map.put(member_attrs_without_fee_type, :user, %{id: user.id}),
|
||||||
upsert?: true,
|
upsert?: true,
|
||||||
upsert_identity: :unique_email
|
upsert_identity: :unique_email,
|
||||||
|
actor: admin_user_with_role
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
# User already has a member, just create the member without linking - use upsert to prevent duplicates
|
# User already has a member, just create the member without linking - use upsert to prevent duplicates
|
||||||
Membership.create_member!(member_attrs_without_fee_type,
|
Membership.create_member!(member_attrs_without_fee_type,
|
||||||
upsert?: true,
|
upsert?: true,
|
||||||
upsert_identity: :unique_email
|
upsert_identity: :unique_email,
|
||||||
|
actor: admin_user_with_role
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -391,7 +409,7 @@ Enum.with_index(linked_members)
|
||||||
|
|
||||||
member
|
member
|
||||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
||||||
|> Ash.update!()
|
|> Ash.update!(actor: admin_user_with_role)
|
||||||
else
|
else
|
||||||
member
|
member
|
||||||
end
|
end
|
||||||
|
|
@ -408,7 +426,10 @@ Enum.with_index(linked_members)
|
||||||
if Enum.empty?(member_with_cycles.membership_fee_cycles) do
|
if Enum.empty?(member_with_cycles.membership_fee_cycles) do
|
||||||
# Generate cycles
|
# Generate cycles
|
||||||
{:ok, new_cycles, _notifications} =
|
{:ok, new_cycles, _notifications} =
|
||||||
CycleGenerator.generate_cycles_for_member(final_member.id, skip_lock?: true)
|
CycleGenerator.generate_cycles_for_member(final_member.id,
|
||||||
|
skip_lock?: true,
|
||||||
|
actor: admin_user_with_role
|
||||||
|
)
|
||||||
|
|
||||||
new_cycles
|
new_cycles
|
||||||
else
|
else
|
||||||
|
|
@ -435,7 +456,7 @@ Enum.with_index(linked_members)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
# Create sample custom field values for some members
|
# Create sample custom field values for some members
|
||||||
all_members = Ash.read!(Membership.Member)
|
all_members = Ash.read!(Membership.Member, actor: admin_user_with_role)
|
||||||
all_custom_fields = Ash.read!(Membership.CustomField)
|
all_custom_fields = Ash.read!(Membership.CustomField)
|
||||||
|
|
||||||
# Helper function to find custom field by name
|
# Helper function to find custom field by name
|
||||||
|
|
@ -463,7 +484,11 @@ if hans = find_member.("hans.mueller@example.de") do
|
||||||
custom_field_id: field.id,
|
custom_field_id: field.id,
|
||||||
value: value
|
value: value
|
||||||
})
|
})
|
||||||
|> Ash.create!(upsert?: true, upsert_identity: :unique_custom_field_per_member)
|
|> Ash.create!(
|
||||||
|
upsert?: true,
|
||||||
|
upsert_identity: :unique_custom_field_per_member,
|
||||||
|
actor: admin_user_with_role
|
||||||
|
)
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
@ -488,7 +513,11 @@ if greta = find_member.("greta.schmidt@example.de") do
|
||||||
custom_field_id: field.id,
|
custom_field_id: field.id,
|
||||||
value: value
|
value: value
|
||||||
})
|
})
|
||||||
|> Ash.create!(upsert?: true, upsert_identity: :unique_custom_field_per_member)
|
|> Ash.create!(
|
||||||
|
upsert?: true,
|
||||||
|
upsert_identity: :unique_custom_field_per_member,
|
||||||
|
actor: admin_user_with_role
|
||||||
|
)
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
@ -514,7 +543,11 @@ if friedrich = find_member.("friedrich.wagner@example.de") do
|
||||||
custom_field_id: field.id,
|
custom_field_id: field.id,
|
||||||
value: value
|
value: value
|
||||||
})
|
})
|
||||||
|> Ash.create!(upsert?: true, upsert_identity: :unique_custom_field_per_member)
|
|> Ash.create!(
|
||||||
|
upsert?: true,
|
||||||
|
upsert_identity: :unique_custom_field_per_member,
|
||||||
|
actor: admin_user_with_role
|
||||||
|
)
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
@ -525,10 +558,39 @@ 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
|
||||||
if existing_settings.club_name != default_club_name do
|
# Also ensure exit_date is set to false by default if not already configured
|
||||||
{:ok, _updated} =
|
updates =
|
||||||
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!")
|
||||||
|
|
|
||||||
5
priv/static/images/mila.svg
Normal file
5
priv/static/images/mila.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<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>
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 285 B |
2
priv/static/templates/member_import_de.csv
Normal file
2
priv/static/templates/member_import_de.csv
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
Vorname;Nachname;E-Mail;Straße;PLZ;Stadt
|
||||||
|
Max;Mustermann;max.mustermann@example.com;Hauptstraße;10115;Berlin
|
||||||
|
2
priv/static/templates/member_import_en.csv
Normal file
2
priv/static/templates/member_import_en.csv
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
first_name;last_name;email;street;postal_code;city
|
||||||
|
John;Doe;john.doe@example.com;Main Street;12345;Berlin
|
||||||
|
|
|
@ -13,14 +13,17 @@ 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" do
|
test "returns true for all member fields by default, except exit_date" 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 ->
|
||||||
assert Member.show_in_overview?(field) == true,
|
expected_visibility = if field == :exit_date, do: false, else: 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
|
||||||
|
|
||||||
|
|
@ -77,4 +80,72 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
defmodule Mv.Authorization.Checks.HasPermissionFailClosedTest do
|
||||||
|
@moduledoc """
|
||||||
|
Regression tests to ensure deny-filter behavior is fail-closed (matches no records).
|
||||||
|
|
||||||
|
These tests verify that when HasPermission.auto_filter returns a deny-filter
|
||||||
|
(e.g., when actor is nil or no permission is found), the filter actually
|
||||||
|
matches zero records in the database.
|
||||||
|
|
||||||
|
This prevents regressions like the previous bug where [id: {:not, {:in, []}}]
|
||||||
|
was used, which logically evaluates to "NOT (id IN [])" = true for all IDs,
|
||||||
|
effectively allowing all records instead of denying them.
|
||||||
|
"""
|
||||||
|
use Mv.DataCase, async: true
|
||||||
|
|
||||||
|
alias Mv.Authorization.Checks.HasPermission
|
||||||
|
|
||||||
|
import Mv.Fixtures
|
||||||
|
|
||||||
|
test "auto_filter deny-filter matches no records (regression for NOT IN [] allow-all bug)" do
|
||||||
|
# Arrange: create some members in DB
|
||||||
|
_m1 = member_fixture()
|
||||||
|
_m2 = member_fixture()
|
||||||
|
|
||||||
|
# Build a minimal authorizer with a stable action type (:read)
|
||||||
|
authorizer = %Ash.Policy.Authorizer{
|
||||||
|
resource: Mv.Membership.Member,
|
||||||
|
action: %{type: :read}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Act: missing actor must yield a deny-all filter (fail-closed)
|
||||||
|
deny_filter = HasPermission.auto_filter(nil, authorizer, [])
|
||||||
|
|
||||||
|
# Apply the returned filter to a real DB query (no authorization involved)
|
||||||
|
query =
|
||||||
|
Mv.Membership.Member
|
||||||
|
|> Ash.Query.new()
|
||||||
|
|> Ash.Query.filter_input(deny_filter)
|
||||||
|
|
||||||
|
{:ok, results} = Ash.read(query, domain: Mv.Membership, authorize?: false)
|
||||||
|
|
||||||
|
# Assert: deny-filter must match nothing
|
||||||
|
assert results == []
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -14,16 +14,22 @@ defmodule Mv.Authorization.Checks.HasPermissionIntegrationTest do
|
||||||
alias Mv.Authorization.Checks.HasPermission
|
alias Mv.Authorization.Checks.HasPermission
|
||||||
|
|
||||||
# Helper to create mock actor with role
|
# Helper to create mock actor with role
|
||||||
defp create_actor_with_role(permission_set_name) do
|
defp create_actor_with_role(permission_set_name, opts \\ []) do
|
||||||
%{
|
actor = %{
|
||||||
id: "user-#{System.unique_integer([:positive])}",
|
id: "user-#{System.unique_integer([:positive])}",
|
||||||
role: %{permission_set_name: permission_set_name}
|
role: %{permission_set_name: permission_set_name}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Add member_id if provided (needed for :linked scope tests)
|
||||||
|
case Keyword.get(opts, :member_id) do
|
||||||
|
nil -> actor
|
||||||
|
member_id -> Map.put(actor, :member_id, member_id)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "Filter Expression Structure - :linked scope" do
|
describe "Filter Expression Structure - :linked scope" do
|
||||||
test "Member filter uses user.id relationship path" do
|
test "Member filter uses actor.member_id (inverse relationship)" do
|
||||||
actor = create_actor_with_role("own_data")
|
actor = create_actor_with_role("own_data", member_id: "member-123")
|
||||||
authorizer = create_authorizer(Mv.Membership.Member, :read)
|
authorizer = create_authorizer(Mv.Membership.Member, :read)
|
||||||
|
|
||||||
filter = HasPermission.auto_filter(actor, authorizer, [])
|
filter = HasPermission.auto_filter(actor, authorizer, [])
|
||||||
|
|
@ -36,8 +42,8 @@ defmodule Mv.Authorization.Checks.HasPermissionIntegrationTest do
|
||||||
assert is_list(filter) or is_map(filter)
|
assert is_list(filter) or is_map(filter)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "CustomFieldValue filter uses member.user.id relationship path" do
|
test "CustomFieldValue filter uses actor.member_id (via member relationship)" do
|
||||||
actor = create_actor_with_role("own_data")
|
actor = create_actor_with_role("own_data", member_id: "member-123")
|
||||||
authorizer = create_authorizer(Mv.Membership.CustomFieldValue, :read)
|
authorizer = create_authorizer(Mv.Membership.CustomFieldValue, :read)
|
||||||
|
|
||||||
filter = HasPermission.auto_filter(actor, authorizer, [])
|
filter = HasPermission.auto_filter(actor, authorizer, [])
|
||||||
|
|
@ -66,14 +72,15 @@ defmodule Mv.Authorization.Checks.HasPermissionIntegrationTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "Filter Expression Structure - :all scope" do
|
describe "Filter Expression Structure - :all scope" do
|
||||||
test "Admin can read all members without filter" do
|
test "Admin can read all members without filter (returns expr(true))" do
|
||||||
actor = create_actor_with_role("admin")
|
actor = create_actor_with_role("admin")
|
||||||
authorizer = create_authorizer(Mv.Membership.Member, :read)
|
authorizer = create_authorizer(Mv.Membership.Member, :read)
|
||||||
|
|
||||||
filter = HasPermission.auto_filter(actor, authorizer, [])
|
filter = HasPermission.auto_filter(actor, authorizer, [])
|
||||||
|
|
||||||
# :all scope should return nil (no filter needed)
|
# :all scope should return [] (empty keyword list = no filter = allow all records)
|
||||||
assert is_nil(filter)
|
# After auto_filter fix: no longer returns nil, returns [] instead
|
||||||
|
assert filter == []
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -81,7 +88,10 @@ defmodule Mv.Authorization.Checks.HasPermissionIntegrationTest do
|
||||||
defp create_authorizer(resource, action) do
|
defp create_authorizer(resource, action) do
|
||||||
%Ash.Policy.Authorizer{
|
%Ash.Policy.Authorizer{
|
||||||
resource: resource,
|
resource: resource,
|
||||||
subject: %{action: %{name: action}}
|
subject: %{
|
||||||
|
action: %{type: action},
|
||||||
|
data: nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -13,16 +13,25 @@ defmodule Mv.Authorization.Checks.HasPermissionTest do
|
||||||
defp create_authorizer(resource, action) do
|
defp create_authorizer(resource, action) do
|
||||||
%Ash.Policy.Authorizer{
|
%Ash.Policy.Authorizer{
|
||||||
resource: resource,
|
resource: resource,
|
||||||
subject: %{action: %{name: action}}
|
subject: %{
|
||||||
|
action: %{type: action},
|
||||||
|
data: nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper to create actor with role
|
# Helper to create actor with role
|
||||||
defp create_actor(id, permission_set_name) do
|
defp create_actor(id, permission_set_name, opts \\ []) do
|
||||||
%{
|
actor = %{
|
||||||
id: id,
|
id: id,
|
||||||
role: %{permission_set_name: permission_set_name}
|
role: %{permission_set_name: permission_set_name}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Add member_id if provided (needed for :linked scope tests)
|
||||||
|
case Keyword.get(opts, :member_id) do
|
||||||
|
nil -> actor
|
||||||
|
member_id -> Map.put(actor, :member_id, member_id)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "describe/1" do
|
describe "describe/1" do
|
||||||
|
|
@ -120,7 +129,7 @@ defmodule Mv.Authorization.Checks.HasPermissionTest do
|
||||||
|
|
||||||
describe "auto_filter/3 - Scope :linked" do
|
describe "auto_filter/3 - Scope :linked" do
|
||||||
test "scope :linked for Member returns user_id filter" do
|
test "scope :linked for Member returns user_id filter" do
|
||||||
user = create_actor("user-123", "own_data")
|
user = create_actor("user-123", "own_data", member_id: "member-456")
|
||||||
authorizer = create_authorizer(Mv.Membership.Member, :read)
|
authorizer = create_authorizer(Mv.Membership.Member, :read)
|
||||||
|
|
||||||
filter = HasPermission.auto_filter(user, authorizer, [])
|
filter = HasPermission.auto_filter(user, authorizer, [])
|
||||||
|
|
@ -130,7 +139,7 @@ defmodule Mv.Authorization.Checks.HasPermissionTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "scope :linked for CustomFieldValue returns member.user_id filter" do
|
test "scope :linked for CustomFieldValue returns member.user_id filter" do
|
||||||
user = create_actor("user-123", "own_data")
|
user = create_actor("user-123", "own_data", member_id: "member-456")
|
||||||
authorizer = create_authorizer(Mv.Membership.CustomFieldValue, :update)
|
authorizer = create_authorizer(Mv.Membership.CustomFieldValue, :update)
|
||||||
|
|
||||||
filter = HasPermission.auto_filter(user, authorizer, [])
|
filter = HasPermission.auto_filter(user, authorizer, [])
|
||||||
|
|
|
||||||
430
test/mv/membership/member_policies_test.exs
Normal file
430
test/mv/membership/member_policies_test.exs
Normal file
|
|
@ -0,0 +1,430 @@
|
||||||
|
defmodule Mv.Membership.MemberPoliciesTest do
|
||||||
|
@moduledoc """
|
||||||
|
Tests for Member resource authorization policies.
|
||||||
|
|
||||||
|
Tests all 4 permission sets (own_data, read_only, normal_user, admin)
|
||||||
|
and verifies that policies correctly enforce access control based on
|
||||||
|
user roles and permission sets.
|
||||||
|
"""
|
||||||
|
# async: false because we need database commits to be visible across queries
|
||||||
|
# in the same test (especially for unlinked members)
|
||||||
|
use Mv.DataCase, async: false
|
||||||
|
|
||||||
|
alias Mv.Membership
|
||||||
|
alias Mv.Accounts
|
||||||
|
alias Mv.Authorization
|
||||||
|
|
||||||
|
require Ash.Query
|
||||||
|
|
||||||
|
# Helper to create a role with a specific permission set
|
||||||
|
defp create_role_with_permission_set(permission_set_name) do
|
||||||
|
role_name = "Test Role #{permission_set_name} #{System.unique_integer([:positive])}"
|
||||||
|
|
||||||
|
case Authorization.create_role(%{
|
||||||
|
name: role_name,
|
||||||
|
description: "Test role for #{permission_set_name}",
|
||||||
|
permission_set_name: permission_set_name
|
||||||
|
}) do
|
||||||
|
{:ok, role} -> role
|
||||||
|
{:error, error} -> raise "Failed to create role: #{inspect(error)}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Helper to create a user with a specific permission set
|
||||||
|
# Returns user with role preloaded (required for authorization)
|
||||||
|
defp create_user_with_permission_set(permission_set_name) do
|
||||||
|
# Create role with permission set
|
||||||
|
role = create_role_with_permission_set(permission_set_name)
|
||||||
|
|
||||||
|
# Create user
|
||||||
|
{:ok, user} =
|
||||||
|
Accounts.User
|
||||||
|
|> Ash.Changeset.for_create(:register_with_password, %{
|
||||||
|
email: "user#{System.unique_integer([:positive])}@example.com",
|
||||||
|
password: "testpassword123"
|
||||||
|
})
|
||||||
|
|> Ash.create()
|
||||||
|
|
||||||
|
# Assign role to user
|
||||||
|
{:ok, user} =
|
||||||
|
user
|
||||||
|
|> Ash.Changeset.for_update(:update, %{})
|
||||||
|
|> Ash.Changeset.manage_relationship(:role, role, type: :append_and_remove)
|
||||||
|
|> Ash.update()
|
||||||
|
|
||||||
|
# Reload user with role preloaded (critical for authorization!)
|
||||||
|
{:ok, user_with_role} = Ash.load(user, :role, domain: Mv.Accounts)
|
||||||
|
user_with_role
|
||||||
|
end
|
||||||
|
|
||||||
|
# Helper to create an admin user (for creating test fixtures)
|
||||||
|
defp create_admin_user do
|
||||||
|
create_user_with_permission_set("admin")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Helper to create a member linked to a user
|
||||||
|
defp create_linked_member_for_user(user) do
|
||||||
|
admin = create_admin_user()
|
||||||
|
|
||||||
|
# Create member
|
||||||
|
# NOTE: We need to ensure the member is actually persisted to the database
|
||||||
|
# before we try to link it. Ash may delay writes, so we explicitly return the struct.
|
||||||
|
{:ok, member} =
|
||||||
|
Membership.Member
|
||||||
|
|> Ash.Changeset.for_create(:create_member, %{
|
||||||
|
first_name: "Linked",
|
||||||
|
last_name: "Member",
|
||||||
|
email: "linked#{System.unique_integer([:positive])}@example.com"
|
||||||
|
})
|
||||||
|
|> Ash.create(actor: admin, return_notifications?: false)
|
||||||
|
|
||||||
|
# Link member to user (User.member_id = member.id)
|
||||||
|
# We use force_change_attribute because the member already exists and we just
|
||||||
|
# need to set the foreign key. This avoids the issue where manage_relationship
|
||||||
|
# tries to query the member without the actor context.
|
||||||
|
result =
|
||||||
|
user
|
||||||
|
|> Ash.Changeset.for_update(:update, %{})
|
||||||
|
|> Ash.Changeset.force_change_attribute(:member_id, member.id)
|
||||||
|
|> Ash.update(actor: admin, domain: Mv.Accounts, return_notifications?: false)
|
||||||
|
|
||||||
|
{:ok, _user} = result
|
||||||
|
|
||||||
|
# Return the member struct directly - no need to reload since we just created it
|
||||||
|
# and we're in the same transaction/sandbox
|
||||||
|
member
|
||||||
|
end
|
||||||
|
|
||||||
|
# Helper to create an unlinked member (no user relationship)
|
||||||
|
defp create_unlinked_member do
|
||||||
|
admin = create_admin_user()
|
||||||
|
|
||||||
|
{:ok, member} =
|
||||||
|
Membership.Member
|
||||||
|
|> Ash.Changeset.for_create(:create_member, %{
|
||||||
|
first_name: "Unlinked",
|
||||||
|
last_name: "Member",
|
||||||
|
email: "unlinked#{System.unique_integer([:positive])}@example.com"
|
||||||
|
})
|
||||||
|
|> Ash.create(actor: admin)
|
||||||
|
|
||||||
|
member
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "own_data permission set (Mitglied)" do
|
||||||
|
setup do
|
||||||
|
user = create_user_with_permission_set("own_data")
|
||||||
|
linked_member = create_linked_member_for_user(user)
|
||||||
|
unlinked_member = create_unlinked_member()
|
||||||
|
|
||||||
|
# Reload user to get updated member_id
|
||||||
|
{:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role])
|
||||||
|
{:ok, user} = Ash.load(user, :member, domain: Mv.Accounts)
|
||||||
|
|
||||||
|
%{user: user, linked_member: linked_member, unlinked_member: unlinked_member}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can read linked member", %{user: user, linked_member: linked_member} do
|
||||||
|
{:ok, member} =
|
||||||
|
Ash.get(Membership.Member, linked_member.id, actor: user, domain: Mv.Membership)
|
||||||
|
|
||||||
|
assert member.id == linked_member.id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can update linked member", %{user: user, linked_member: linked_member} do
|
||||||
|
# Update is allowed via HasPermission check with :linked scope (not via special case)
|
||||||
|
# The special case policy only applies to :read actions
|
||||||
|
{:ok, updated_member} =
|
||||||
|
linked_member
|
||||||
|
|> Ash.Changeset.for_update(:update_member, %{first_name: "Updated"})
|
||||||
|
|> Ash.update(actor: user)
|
||||||
|
|
||||||
|
assert updated_member.first_name == "Updated"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "cannot read unlinked member (returns forbidden)", %{
|
||||||
|
user: user,
|
||||||
|
unlinked_member: unlinked_member
|
||||||
|
} do
|
||||||
|
# Note: With auto_filter policies, when a user tries to read a member that doesn't
|
||||||
|
# match the filter (id == actor.member_id), Ash returns NotFound, not Forbidden.
|
||||||
|
# This is the expected behavior - the filter makes the record "invisible" to the user.
|
||||||
|
assert_raise Ash.Error.Invalid, fn ->
|
||||||
|
Ash.get!(Membership.Member, unlinked_member.id, actor: user, domain: Mv.Membership)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "cannot update unlinked member (returns forbidden)", %{
|
||||||
|
user: user,
|
||||||
|
unlinked_member: unlinked_member
|
||||||
|
} do
|
||||||
|
assert_raise Ash.Error.Forbidden, fn ->
|
||||||
|
unlinked_member
|
||||||
|
|> Ash.Changeset.for_update(:update_member, %{first_name: "Updated"})
|
||||||
|
|> Ash.update!(actor: user)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "list members returns only linked member", %{user: user, linked_member: linked_member} do
|
||||||
|
{:ok, members} = Ash.read(Membership.Member, actor: user, domain: Mv.Membership)
|
||||||
|
|
||||||
|
# Should only return the linked member (scope :linked filters)
|
||||||
|
assert length(members) == 1
|
||||||
|
assert hd(members).id == linked_member.id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "cannot create member (returns forbidden)", %{user: user} do
|
||||||
|
assert_raise Ash.Error.Forbidden, fn ->
|
||||||
|
Membership.Member
|
||||||
|
|> Ash.Changeset.for_create(:create_member, %{
|
||||||
|
first_name: "New",
|
||||||
|
last_name: "Member",
|
||||||
|
email: "new#{System.unique_integer([:positive])}@example.com"
|
||||||
|
})
|
||||||
|
|> Ash.create!(actor: user)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "cannot destroy member (returns forbidden)", %{user: user, linked_member: linked_member} do
|
||||||
|
assert_raise Ash.Error.Forbidden, fn ->
|
||||||
|
Ash.destroy!(linked_member, actor: user)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "read_only permission set (Vorstand/Buchhaltung)" do
|
||||||
|
setup do
|
||||||
|
user = create_user_with_permission_set("read_only")
|
||||||
|
linked_member = create_linked_member_for_user(user)
|
||||||
|
unlinked_member = create_unlinked_member()
|
||||||
|
|
||||||
|
# Reload user to get updated member_id
|
||||||
|
{:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role])
|
||||||
|
|
||||||
|
%{user: user, linked_member: linked_member, unlinked_member: unlinked_member}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can read all members", %{
|
||||||
|
user: user,
|
||||||
|
linked_member: linked_member,
|
||||||
|
unlinked_member: unlinked_member
|
||||||
|
} do
|
||||||
|
{:ok, members} = Ash.read(Membership.Member, actor: user, domain: Mv.Membership)
|
||||||
|
|
||||||
|
# Should return all members (scope :all)
|
||||||
|
member_ids = Enum.map(members, & &1.id)
|
||||||
|
assert linked_member.id in member_ids
|
||||||
|
assert unlinked_member.id in member_ids
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can read individual member", %{user: user, unlinked_member: unlinked_member} do
|
||||||
|
{:ok, member} =
|
||||||
|
Ash.get(Membership.Member, unlinked_member.id, actor: user, domain: Mv.Membership)
|
||||||
|
|
||||||
|
assert member.id == unlinked_member.id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "cannot create member (returns forbidden)", %{user: user} do
|
||||||
|
assert_raise Ash.Error.Forbidden, fn ->
|
||||||
|
Membership.Member
|
||||||
|
|> Ash.Changeset.for_create(:create_member, %{
|
||||||
|
first_name: "New",
|
||||||
|
last_name: "Member",
|
||||||
|
email: "new#{System.unique_integer([:positive])}@example.com"
|
||||||
|
})
|
||||||
|
|> Ash.create!(actor: user)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "cannot update any member (returns forbidden)", %{
|
||||||
|
user: user,
|
||||||
|
linked_member: linked_member
|
||||||
|
} do
|
||||||
|
assert_raise Ash.Error.Forbidden, fn ->
|
||||||
|
linked_member
|
||||||
|
|> Ash.Changeset.for_update(:update_member, %{first_name: "Updated"})
|
||||||
|
|> Ash.update!(actor: user)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "cannot destroy any member (returns forbidden)", %{
|
||||||
|
user: user,
|
||||||
|
unlinked_member: unlinked_member
|
||||||
|
} do
|
||||||
|
assert_raise Ash.Error.Forbidden, fn ->
|
||||||
|
Ash.destroy!(unlinked_member, actor: user)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "normal_user permission set (Kassenwart)" do
|
||||||
|
setup do
|
||||||
|
user = create_user_with_permission_set("normal_user")
|
||||||
|
linked_member = create_linked_member_for_user(user)
|
||||||
|
unlinked_member = create_unlinked_member()
|
||||||
|
|
||||||
|
# Reload user to get updated member_id
|
||||||
|
{:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role])
|
||||||
|
|
||||||
|
%{user: user, linked_member: linked_member, unlinked_member: unlinked_member}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can read all members", %{
|
||||||
|
user: user,
|
||||||
|
linked_member: linked_member,
|
||||||
|
unlinked_member: unlinked_member
|
||||||
|
} do
|
||||||
|
{:ok, members} = Ash.read(Membership.Member, actor: user, domain: Mv.Membership)
|
||||||
|
|
||||||
|
# Should return all members (scope :all)
|
||||||
|
member_ids = Enum.map(members, & &1.id)
|
||||||
|
assert linked_member.id in member_ids
|
||||||
|
assert unlinked_member.id in member_ids
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can create member", %{user: user} do
|
||||||
|
{:ok, member} =
|
||||||
|
Membership.Member
|
||||||
|
|> Ash.Changeset.for_create(:create_member, %{
|
||||||
|
first_name: "New",
|
||||||
|
last_name: "Member",
|
||||||
|
email: "new#{System.unique_integer([:positive])}@example.com"
|
||||||
|
})
|
||||||
|
|> Ash.create(actor: user)
|
||||||
|
|
||||||
|
assert member.first_name == "New"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can update any member", %{user: user, unlinked_member: unlinked_member} do
|
||||||
|
{:ok, updated_member} =
|
||||||
|
unlinked_member
|
||||||
|
|> Ash.Changeset.for_update(:update_member, %{first_name: "Updated"})
|
||||||
|
|> Ash.update(actor: user)
|
||||||
|
|
||||||
|
assert updated_member.first_name == "Updated"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "cannot destroy member (safety - not in permission set)", %{
|
||||||
|
user: user,
|
||||||
|
unlinked_member: unlinked_member
|
||||||
|
} do
|
||||||
|
assert_raise Ash.Error.Forbidden, fn ->
|
||||||
|
Ash.destroy!(unlinked_member, actor: user)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "admin permission set" do
|
||||||
|
setup do
|
||||||
|
user = create_user_with_permission_set("admin")
|
||||||
|
linked_member = create_linked_member_for_user(user)
|
||||||
|
unlinked_member = create_unlinked_member()
|
||||||
|
|
||||||
|
# Reload user to get updated member_id
|
||||||
|
{:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role])
|
||||||
|
|
||||||
|
%{user: user, linked_member: linked_member, unlinked_member: unlinked_member}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can read all members", %{
|
||||||
|
user: user,
|
||||||
|
linked_member: linked_member,
|
||||||
|
unlinked_member: unlinked_member
|
||||||
|
} do
|
||||||
|
{:ok, members} = Ash.read(Membership.Member, actor: user, domain: Mv.Membership)
|
||||||
|
|
||||||
|
# Should return all members (scope :all)
|
||||||
|
member_ids = Enum.map(members, & &1.id)
|
||||||
|
assert linked_member.id in member_ids
|
||||||
|
assert unlinked_member.id in member_ids
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can create member", %{user: user} do
|
||||||
|
{:ok, member} =
|
||||||
|
Membership.Member
|
||||||
|
|> Ash.Changeset.for_create(:create_member, %{
|
||||||
|
first_name: "New",
|
||||||
|
last_name: "Member",
|
||||||
|
email: "new#{System.unique_integer([:positive])}@example.com"
|
||||||
|
})
|
||||||
|
|> Ash.create(actor: user)
|
||||||
|
|
||||||
|
assert member.first_name == "New"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can update any member", %{user: user, unlinked_member: unlinked_member} do
|
||||||
|
{:ok, updated_member} =
|
||||||
|
unlinked_member
|
||||||
|
|> Ash.Changeset.for_update(:update_member, %{first_name: "Updated"})
|
||||||
|
|> Ash.update(actor: user)
|
||||||
|
|
||||||
|
assert updated_member.first_name == "Updated"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can destroy any member", %{user: user, unlinked_member: unlinked_member} do
|
||||||
|
:ok = Ash.destroy(unlinked_member, actor: user)
|
||||||
|
|
||||||
|
# Verify member is deleted
|
||||||
|
assert {:error, _} = Ash.get(Membership.Member, unlinked_member.id, domain: Mv.Membership)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "special case: user can always READ linked member" do
|
||||||
|
# Note: The special case policy only applies to :read actions.
|
||||||
|
# Updates are handled by HasPermission with :linked scope (if permission exists).
|
||||||
|
|
||||||
|
test "read_only user can read linked member (via special case bypass)" do
|
||||||
|
# read_only has Member.read scope :all, but the special case ensures
|
||||||
|
# users can ALWAYS read their linked member, even if they had no read permission.
|
||||||
|
# This test verifies the special case works independently of permission sets.
|
||||||
|
user = create_user_with_permission_set("read_only")
|
||||||
|
linked_member = create_linked_member_for_user(user)
|
||||||
|
|
||||||
|
# Reload user to get updated member_id
|
||||||
|
{:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role])
|
||||||
|
{:ok, user} = Ash.load(user, :member, domain: Mv.Accounts)
|
||||||
|
|
||||||
|
# Should succeed (special case bypass policy for :read takes precedence)
|
||||||
|
{:ok, member} =
|
||||||
|
Ash.get(Membership.Member, linked_member.id, actor: user, domain: Mv.Membership)
|
||||||
|
|
||||||
|
assert member.id == linked_member.id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "own_data user can read linked member (via special case bypass)" do
|
||||||
|
# own_data has Member.read scope :linked, but the special case ensures
|
||||||
|
# users can ALWAYS read their linked member regardless of permission set.
|
||||||
|
user = create_user_with_permission_set("own_data")
|
||||||
|
linked_member = create_linked_member_for_user(user)
|
||||||
|
|
||||||
|
# Reload user to get updated member_id
|
||||||
|
{:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role])
|
||||||
|
{:ok, user} = Ash.load(user, :member, domain: Mv.Accounts)
|
||||||
|
|
||||||
|
# Should succeed (special case bypass policy for :read takes precedence)
|
||||||
|
{:ok, member} =
|
||||||
|
Ash.get(Membership.Member, linked_member.id, actor: user, domain: Mv.Membership)
|
||||||
|
|
||||||
|
assert member.id == linked_member.id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "own_data user can update linked member (via HasPermission :linked scope)" do
|
||||||
|
# Update is NOT handled by special case - it's handled by HasPermission
|
||||||
|
# with :linked scope. own_data has Member.update scope :linked.
|
||||||
|
user = create_user_with_permission_set("own_data")
|
||||||
|
linked_member = create_linked_member_for_user(user)
|
||||||
|
|
||||||
|
# Reload user to get updated member_id
|
||||||
|
{:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role])
|
||||||
|
{:ok, user} = Ash.load(user, :member, domain: Mv.Accounts)
|
||||||
|
|
||||||
|
# Should succeed via HasPermission check (not special case)
|
||||||
|
{:ok, updated_member} =
|
||||||
|
linked_member
|
||||||
|
|> Ash.Changeset.for_update(:update_member, %{first_name: "Updated"})
|
||||||
|
|> Ash.update(actor: user)
|
||||||
|
|
||||||
|
assert updated_member.first_name == "Updated"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
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
|
|
||||||
850
test/mv_web/components/layouts/sidebar_test.exs
Normal file
850
test/mv_web/components/layouts/sidebar_test.exs
Normal file
|
|
@ -0,0 +1,850 @@
|
||||||
|
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
|
||||||
|
|
@ -1,21 +1,42 @@
|
||||||
defmodule MvWeb.AuthControllerTest do
|
defmodule MvWeb.AuthControllerTest do
|
||||||
use MvWeb.ConnCase, async: true
|
use MvWeb.ConnCase, async: true
|
||||||
import Phoenix.LiveViewTest
|
import Phoenix.LiveViewTest
|
||||||
|
import Phoenix.ConnTest
|
||||||
|
|
||||||
|
# Helper to create an unauthenticated conn (preserves sandbox metadata)
|
||||||
|
defp build_unauthenticated_conn(authenticated_conn) do
|
||||||
|
# Create new conn but preserve sandbox metadata for database access
|
||||||
|
new_conn = build_conn()
|
||||||
|
|
||||||
|
# Copy sandbox metadata from authenticated conn
|
||||||
|
if authenticated_conn.private[:ecto_sandbox] do
|
||||||
|
Plug.Conn.put_private(new_conn, :ecto_sandbox, authenticated_conn.private[:ecto_sandbox])
|
||||||
|
else
|
||||||
|
new_conn
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Basic UI tests
|
# Basic UI tests
|
||||||
test "GET /sign-in shows sign in form", %{conn: conn} do
|
test "GET /sign-in shows sign in form", %{conn: authenticated_conn} do
|
||||||
|
# Create unauthenticated conn for this test
|
||||||
|
conn = build_unauthenticated_conn(authenticated_conn)
|
||||||
conn = get(conn, ~p"/sign-in")
|
conn = get(conn, ~p"/sign-in")
|
||||||
assert html_response(conn, 200) =~ "Sign in"
|
assert html_response(conn, 200) =~ "Sign in"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "GET /sign-out redirects to home", %{conn: conn} do
|
test "GET /sign-out redirects to home", %{conn: authenticated_conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(authenticated_conn)
|
||||||
conn = get(conn, ~p"/sign-out")
|
conn = get(conn, ~p"/sign-out")
|
||||||
assert redirected_to(conn) == ~p"/"
|
assert redirected_to(conn) == ~p"/"
|
||||||
end
|
end
|
||||||
|
|
||||||
# Password authentication (LiveView)
|
# Password authentication (LiveView)
|
||||||
test "password user can sign in with valid credentials via LiveView", %{conn: conn} do
|
test "password user can sign in with valid credentials via LiveView", %{
|
||||||
|
conn: authenticated_conn
|
||||||
|
} do
|
||||||
|
# Create unauthenticated conn for this test
|
||||||
|
conn = build_unauthenticated_conn(authenticated_conn)
|
||||||
|
|
||||||
_user =
|
_user =
|
||||||
create_test_user(%{
|
create_test_user(%{
|
||||||
email: "password@example.com",
|
email: "password@example.com",
|
||||||
|
|
@ -35,7 +56,12 @@ defmodule MvWeb.AuthControllerTest do
|
||||||
assert to =~ "/auth/user/password/sign_in_with_token"
|
assert to =~ "/auth/user/password/sign_in_with_token"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "password user with invalid credentials shows error via LiveView", %{conn: conn} do
|
test "password user with invalid credentials shows error via LiveView", %{
|
||||||
|
conn: authenticated_conn
|
||||||
|
} do
|
||||||
|
# Create unauthenticated conn for this test
|
||||||
|
conn = build_unauthenticated_conn(authenticated_conn)
|
||||||
|
|
||||||
_user =
|
_user =
|
||||||
create_test_user(%{
|
create_test_user(%{
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
|
|
@ -55,7 +81,12 @@ defmodule MvWeb.AuthControllerTest do
|
||||||
assert html =~ "Email or password was incorrect"
|
assert html =~ "Email or password was incorrect"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "password user with non-existent email shows error via LiveView", %{conn: conn} do
|
test "password user with non-existent email shows error via LiveView", %{
|
||||||
|
conn: authenticated_conn
|
||||||
|
} do
|
||||||
|
# Create unauthenticated conn for this test
|
||||||
|
conn = build_unauthenticated_conn(authenticated_conn)
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/sign-in")
|
{:ok, view, _html} = live(conn, "/sign-in")
|
||||||
|
|
||||||
html =
|
html =
|
||||||
|
|
@ -69,7 +100,10 @@ defmodule MvWeb.AuthControllerTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
# Registration (LiveView)
|
# Registration (LiveView)
|
||||||
test "user can register with valid credentials via LiveView", %{conn: conn} do
|
test "user can register with valid credentials via LiveView", %{conn: authenticated_conn} do
|
||||||
|
# Create unauthenticated conn for this test
|
||||||
|
conn = build_unauthenticated_conn(authenticated_conn)
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/register")
|
{:ok, view, _html} = live(conn, "/register")
|
||||||
|
|
||||||
{:error, {:redirect, %{to: to}}} =
|
{:error, {:redirect, %{to: to}}} =
|
||||||
|
|
@ -82,7 +116,10 @@ defmodule MvWeb.AuthControllerTest do
|
||||||
assert to =~ "/auth/user/password/sign_in_with_token"
|
assert to =~ "/auth/user/password/sign_in_with_token"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "registration with existing email shows error via LiveView", %{conn: conn} do
|
test "registration with existing email shows error via LiveView", %{conn: authenticated_conn} do
|
||||||
|
# Create unauthenticated conn for this test
|
||||||
|
conn = build_unauthenticated_conn(authenticated_conn)
|
||||||
|
|
||||||
_user =
|
_user =
|
||||||
create_test_user(%{
|
create_test_user(%{
|
||||||
email: "existing@example.com",
|
email: "existing@example.com",
|
||||||
|
|
@ -102,7 +139,10 @@ defmodule MvWeb.AuthControllerTest do
|
||||||
assert html =~ "has already been taken"
|
assert html =~ "has already been taken"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "registration with weak password shows error via LiveView", %{conn: conn} do
|
test "registration with weak password shows error via LiveView", %{conn: authenticated_conn} do
|
||||||
|
# Create unauthenticated conn for this test
|
||||||
|
conn = build_unauthenticated_conn(authenticated_conn)
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/register")
|
{:ok, view, _html} = live(conn, "/register")
|
||||||
|
|
||||||
html =
|
html =
|
||||||
|
|
@ -116,18 +156,27 @@ defmodule MvWeb.AuthControllerTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
# Access control
|
# Access control
|
||||||
test "unauthenticated user accessing protected route gets redirected to sign-in", %{conn: conn} do
|
test "unauthenticated user accessing protected route gets redirected to sign-in", %{
|
||||||
|
conn: authenticated_conn
|
||||||
|
} do
|
||||||
|
# Create unauthenticated conn for this test
|
||||||
|
conn = build_unauthenticated_conn(authenticated_conn)
|
||||||
conn = get(conn, ~p"/members")
|
conn = get(conn, ~p"/members")
|
||||||
assert redirected_to(conn) == ~p"/sign-in"
|
assert redirected_to(conn) == ~p"/sign-in"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "authenticated user can access protected route", %{conn: conn} do
|
test "authenticated user can access protected route", %{conn: authenticated_conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(authenticated_conn)
|
||||||
conn = get(conn, ~p"/members")
|
conn = get(conn, ~p"/members")
|
||||||
assert conn.status == 200
|
assert conn.status == 200
|
||||||
end
|
end
|
||||||
|
|
||||||
test "password authenticated user can access protected route via LiveView", %{conn: conn} do
|
test "password authenticated user can access protected route via LiveView", %{
|
||||||
|
conn: authenticated_conn
|
||||||
|
} do
|
||||||
|
# Create unauthenticated conn for this test
|
||||||
|
conn = build_unauthenticated_conn(authenticated_conn)
|
||||||
|
|
||||||
_user =
|
_user =
|
||||||
create_test_user(%{
|
create_test_user(%{
|
||||||
email: "auth@example.com",
|
email: "auth@example.com",
|
||||||
|
|
@ -150,7 +199,12 @@ defmodule MvWeb.AuthControllerTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
# Edge cases
|
# Edge cases
|
||||||
test "user with nil oidc_id can still sign in with password via LiveView", %{conn: conn} do
|
test "user with nil oidc_id can still sign in with password via LiveView", %{
|
||||||
|
conn: authenticated_conn
|
||||||
|
} do
|
||||||
|
# Create unauthenticated conn for this test
|
||||||
|
conn = build_unauthenticated_conn(authenticated_conn)
|
||||||
|
|
||||||
_user =
|
_user =
|
||||||
create_test_user(%{
|
create_test_user(%{
|
||||||
email: "nil_oidc@example.com",
|
email: "nil_oidc@example.com",
|
||||||
|
|
@ -170,7 +224,12 @@ defmodule MvWeb.AuthControllerTest do
|
||||||
assert to =~ "/auth/user/password/sign_in_with_token"
|
assert to =~ "/auth/user/password/sign_in_with_token"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "user with empty string oidc_id is handled correctly via LiveView", %{conn: conn} do
|
test "user with empty string oidc_id is handled correctly via LiveView", %{
|
||||||
|
conn: authenticated_conn
|
||||||
|
} do
|
||||||
|
# Create unauthenticated conn for this test
|
||||||
|
conn = build_unauthenticated_conn(authenticated_conn)
|
||||||
|
|
||||||
_user =
|
_user =
|
||||||
create_test_user(%{
|
create_test_user(%{
|
||||||
email: "empty_oidc@example.com",
|
email: "empty_oidc@example.com",
|
||||||
|
|
|
||||||
|
|
@ -154,7 +154,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Should show success message
|
# Should show success message
|
||||||
assert render(view) =~ "Custom field deleted successfully"
|
assert render(view) =~ "Data 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,5 +64,21 @@ 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
|
||||||
|
|
|
||||||
124
test/mv_web/live/member_field_live/index_component_test.exs
Normal file
124
test/mv_web/live/member_field_live/index_component_test.exs
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
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
|
||||||
|
|
@ -11,19 +11,8 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
setup %{conn: conn} do
|
# Use global setup from ConnCase which provides admin user with role
|
||||||
# Create admin user
|
# No custom setup needed
|
||||||
{:ok, user} =
|
|
||||||
Mv.Accounts.User
|
|
||||||
|> Ash.Changeset.for_create(:register_with_password, %{
|
|
||||||
email: "admin#{System.unique_integer([:positive])}@mv.local",
|
|
||||||
password: "testpassword123"
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
authenticated_conn = conn_with_password_user(conn, user)
|
|
||||||
%{conn: authenticated_conn, user: user}
|
|
||||||
end
|
|
||||||
|
|
||||||
# Helper to create a membership fee type
|
# Helper to create a membership fee type
|
||||||
defp create_fee_type(attrs) do
|
defp create_fee_type(attrs) do
|
||||||
|
|
@ -41,7 +30,8 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper to create a member
|
# Helper to create a member
|
||||||
defp create_member(attrs) do
|
# Uses admin actor from global setup to ensure authorization
|
||||||
|
defp create_member(attrs, actor) do
|
||||||
default_attrs = %{
|
default_attrs = %{
|
||||||
first_name: "Test",
|
first_name: "Test",
|
||||||
last_name: "Member",
|
last_name: "Member",
|
||||||
|
|
@ -50,9 +40,11 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
|
||||||
|
|
||||||
attrs = Map.merge(default_attrs, attrs)
|
attrs = Map.merge(default_attrs, attrs)
|
||||||
|
|
||||||
|
opts = if actor, do: [actor: actor], else: []
|
||||||
|
|
||||||
Member
|
Member
|
||||||
|> Ash.Changeset.for_create(:create_member, attrs)
|
|> Ash.Changeset.for_create(:create_member, attrs)
|
||||||
|> Ash.create!()
|
|> Ash.create!(opts)
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "list display" do
|
describe "list display" do
|
||||||
|
|
@ -72,12 +64,12 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
|
||||||
assert html =~ "Yearly" || html =~ "Jährlich"
|
assert html =~ "Yearly" || html =~ "Jährlich"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "member count column shows correct count", %{conn: conn} do
|
test "member count column shows correct count", %{conn: conn, current_user: admin_user} do
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly})
|
||||||
|
|
||||||
# Create 3 members with this fee type
|
# Create 3 members with this fee type
|
||||||
Enum.each(1..3, fn _ ->
|
Enum.each(1..3, fn _ ->
|
||||||
create_member(%{membership_fee_type_id: fee_type.id})
|
create_member(%{membership_fee_type_id: fee_type.id}, admin_user)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
{:ok, _view, html} = live(conn, "/membership_fee_types")
|
{:ok, _view, html} = live(conn, "/membership_fee_types")
|
||||||
|
|
@ -111,9 +103,9 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "delete functionality" do
|
describe "delete functionality" do
|
||||||
test "delete button disabled if type is in use", %{conn: conn} do
|
test "delete button disabled if type is in use", %{conn: conn, current_user: admin_user} do
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly})
|
||||||
create_member(%{membership_fee_type_id: fee_type.id})
|
create_member(%{membership_fee_type_id: fee_type.id}, admin_user)
|
||||||
|
|
||||||
{:ok, _view, html} = live(conn, "/membership_fee_types")
|
{:ok, _view, html} = live(conn, "/membership_fee_types")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ defmodule MvWeb.ProfileNavigationTest do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "navbar" do
|
describe "sidebar" 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 navbar (which requires current_user) should be visible
|
# The sidebar (which requires current_user) should be visible
|
||||||
assert html =~ "navbar"
|
assert html =~ "sidebar"
|
||||||
# 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
|
||||||
|
|
|
||||||
|
|
@ -11,20 +11,6 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
setup %{conn: conn} do
|
|
||||||
# Create admin user
|
|
||||||
{:ok, user} =
|
|
||||||
Mv.Accounts.User
|
|
||||||
|> Ash.Changeset.for_create(:register_with_password, %{
|
|
||||||
email: "admin#{System.unique_integer([:positive])}@mv.local",
|
|
||||||
password: "testpassword123"
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
authenticated_conn = conn_with_password_user(conn, user)
|
|
||||||
%{conn: authenticated_conn, user: user}
|
|
||||||
end
|
|
||||||
|
|
||||||
# Helper to create a membership fee type
|
# Helper to create a membership fee type
|
||||||
defp create_fee_type(attrs) do
|
defp create_fee_type(attrs) do
|
||||||
default_attrs = %{
|
default_attrs = %{
|
||||||
|
|
@ -164,4 +150,153 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
|
||||||
assert html =~ fee_type.name || html =~ "selected"
|
assert html =~ fee_type.name || html =~ "selected"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "custom field value preservation" do
|
||||||
|
test "custom field values preserved when membership fee type changes", %{
|
||||||
|
conn: conn,
|
||||||
|
current_user: admin_user
|
||||||
|
} do
|
||||||
|
# Create custom field
|
||||||
|
custom_field =
|
||||||
|
Mv.Membership.CustomField
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
name: "Test Field",
|
||||||
|
value_type: :string,
|
||||||
|
required: false
|
||||||
|
})
|
||||||
|
|> Ash.create!()
|
||||||
|
|
||||||
|
# Create two fee types with same interval
|
||||||
|
fee_type1 = create_fee_type(%{name: "Type 1", interval: :yearly})
|
||||||
|
fee_type2 = create_fee_type(%{name: "Type 2", interval: :yearly})
|
||||||
|
|
||||||
|
# Create member with fee type 1 and custom field value
|
||||||
|
member =
|
||||||
|
Member
|
||||||
|
|> Ash.Changeset.for_create(:create_member, %{
|
||||||
|
first_name: "Test",
|
||||||
|
last_name: "Member",
|
||||||
|
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||||
|
membership_fee_type_id: fee_type1.id
|
||||||
|
})
|
||||||
|
|> Ash.create!(actor: admin_user)
|
||||||
|
|
||||||
|
# Add custom field value
|
||||||
|
Mv.Membership.CustomFieldValue
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
member_id: member.id,
|
||||||
|
custom_field_id: custom_field.id,
|
||||||
|
value: %{"_union_type" => "string", "_union_value" => "Test Value"}
|
||||||
|
})
|
||||||
|
|> Ash.create!(actor: admin_user)
|
||||||
|
|
||||||
|
{:ok, view, _html} = live(conn, "/members/#{member.id}/edit")
|
||||||
|
|
||||||
|
# Change membership fee type dropdown
|
||||||
|
html =
|
||||||
|
view
|
||||||
|
|> form("#member-form", %{"member[membership_fee_type_id]" => fee_type2.id})
|
||||||
|
|> render_change()
|
||||||
|
|
||||||
|
# Verify custom field value is still present (check for field name or value)
|
||||||
|
assert html =~ custom_field.name || html =~ "Test Value"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "union/typed values roundtrip correctly", %{conn: conn, current_user: admin_user} do
|
||||||
|
# Create date custom field
|
||||||
|
custom_field =
|
||||||
|
Mv.Membership.CustomField
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
name: "Date Field",
|
||||||
|
value_type: :date,
|
||||||
|
required: false
|
||||||
|
})
|
||||||
|
|> Ash.create!()
|
||||||
|
|
||||||
|
fee_type = create_fee_type(%{interval: :yearly})
|
||||||
|
|
||||||
|
# Create member with date custom field value
|
||||||
|
member =
|
||||||
|
Member
|
||||||
|
|> Ash.Changeset.for_create(:create_member, %{
|
||||||
|
first_name: "Test",
|
||||||
|
last_name: "Member",
|
||||||
|
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||||
|
membership_fee_type_id: fee_type.id
|
||||||
|
})
|
||||||
|
|> Ash.create!(actor: admin_user)
|
||||||
|
|
||||||
|
test_date = ~D[2024-01-15]
|
||||||
|
|
||||||
|
# Add date custom field value
|
||||||
|
Mv.Membership.CustomFieldValue
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
member_id: member.id,
|
||||||
|
custom_field_id: custom_field.id,
|
||||||
|
value: %{"_union_type" => "date", "_union_value" => test_date}
|
||||||
|
})
|
||||||
|
|> Ash.create!(actor: admin_user)
|
||||||
|
|
||||||
|
{:ok, view, _html} = live(conn, "/members/#{member.id}/edit")
|
||||||
|
|
||||||
|
# Trigger validation (simulates dropdown change)
|
||||||
|
html =
|
||||||
|
view
|
||||||
|
|> form("#member-form", %{"member[membership_fee_type_id]" => fee_type.id})
|
||||||
|
|> render_change()
|
||||||
|
|
||||||
|
# Verify date value is still present (check for date input or formatted date)
|
||||||
|
assert html =~ "2024" || html =~ "date"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "removing custom field values works correctly", %{conn: conn, current_user: admin_user} do
|
||||||
|
# Create custom field
|
||||||
|
custom_field =
|
||||||
|
Mv.Membership.CustomField
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
name: "Test Field",
|
||||||
|
value_type: :string,
|
||||||
|
required: false
|
||||||
|
})
|
||||||
|
|> Ash.create!()
|
||||||
|
|
||||||
|
fee_type = create_fee_type(%{interval: :yearly})
|
||||||
|
|
||||||
|
# Create member with custom field value
|
||||||
|
member =
|
||||||
|
Member
|
||||||
|
|> Ash.Changeset.for_create(:create_member, %{
|
||||||
|
first_name: "Test",
|
||||||
|
last_name: "Member",
|
||||||
|
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||||
|
membership_fee_type_id: fee_type.id
|
||||||
|
})
|
||||||
|
|> Ash.create!(actor: admin_user)
|
||||||
|
|
||||||
|
# Add custom field value
|
||||||
|
_cfv =
|
||||||
|
Mv.Membership.CustomFieldValue
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
member_id: member.id,
|
||||||
|
custom_field_id: custom_field.id,
|
||||||
|
value: %{"_union_type" => "string", "_union_value" => "Test Value"}
|
||||||
|
})
|
||||||
|
|> Ash.create!(actor: admin_user)
|
||||||
|
|
||||||
|
{:ok, view, _html} = live(conn, "/members/#{member.id}/edit")
|
||||||
|
|
||||||
|
# Change membership fee type to trigger validation
|
||||||
|
# This should preserve the custom field value
|
||||||
|
html =
|
||||||
|
view
|
||||||
|
|> form("#member-form", %{
|
||||||
|
"member[membership_fee_type_id]" => fee_type.id
|
||||||
|
})
|
||||||
|
|> render_change()
|
||||||
|
|
||||||
|
# Form should still be valid and custom field value should be preserved
|
||||||
|
# The custom field value should still be visible in the form
|
||||||
|
assert html =~ "Test Value" || html =~ custom_field.name
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,141 +0,0 @@
|
||||||
defmodule MvWeb.Helpers.MemberHelpersTest do
|
|
||||||
@moduledoc """
|
|
||||||
Tests for the display_name/1 helper function in MemberHelpers.
|
|
||||||
"""
|
|
||||||
use Mv.DataCase, async: true
|
|
||||||
|
|
||||||
alias Mv.Membership.Member
|
|
||||||
alias MvWeb.Helpers.MemberHelpers
|
|
||||||
|
|
||||||
describe "display_name/1" do
|
|
||||||
test "returns full name when both first_name and last_name are present" do
|
|
||||||
member = %Member{
|
|
||||||
first_name: "John",
|
|
||||||
last_name: "Doe",
|
|
||||||
email: "john@example.com"
|
|
||||||
}
|
|
||||||
|
|
||||||
assert MemberHelpers.display_name(member) == "John Doe"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "returns email when both first_name and last_name are nil" do
|
|
||||||
member = %Member{
|
|
||||||
first_name: nil,
|
|
||||||
last_name: nil,
|
|
||||||
email: "john@example.com"
|
|
||||||
}
|
|
||||||
|
|
||||||
assert MemberHelpers.display_name(member) == "john@example.com"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "returns first_name only when last_name is nil" do
|
|
||||||
member = %Member{
|
|
||||||
first_name: "John",
|
|
||||||
last_name: nil,
|
|
||||||
email: "john@example.com"
|
|
||||||
}
|
|
||||||
|
|
||||||
assert MemberHelpers.display_name(member) == "John"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "returns last_name only when first_name is nil" do
|
|
||||||
member = %Member{
|
|
||||||
first_name: nil,
|
|
||||||
last_name: "Doe",
|
|
||||||
email: "john@example.com"
|
|
||||||
}
|
|
||||||
|
|
||||||
assert MemberHelpers.display_name(member) == "Doe"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "returns email when first_name and last_name are empty strings" do
|
|
||||||
member = %Member{
|
|
||||||
first_name: "",
|
|
||||||
last_name: "",
|
|
||||||
email: "john@example.com"
|
|
||||||
}
|
|
||||||
|
|
||||||
assert MemberHelpers.display_name(member) == "john@example.com"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "returns email when first_name and last_name are whitespace only" do
|
|
||||||
member = %Member{
|
|
||||||
first_name: " ",
|
|
||||||
last_name: " \t ",
|
|
||||||
email: "john@example.com"
|
|
||||||
}
|
|
||||||
|
|
||||||
assert MemberHelpers.display_name(member) == "john@example.com"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "trims whitespace from name parts" do
|
|
||||||
member = %Member{
|
|
||||||
first_name: " John ",
|
|
||||||
last_name: " Doe ",
|
|
||||||
email: "john@example.com"
|
|
||||||
}
|
|
||||||
|
|
||||||
assert MemberHelpers.display_name(member) == "John Doe"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "handles one empty string and one nil" do
|
|
||||||
member = %Member{
|
|
||||||
first_name: "",
|
|
||||||
last_name: nil,
|
|
||||||
email: "john@example.com"
|
|
||||||
}
|
|
||||||
|
|
||||||
assert MemberHelpers.display_name(member) == "john@example.com"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "handles one nil and one empty string" do
|
|
||||||
member = %Member{
|
|
||||||
first_name: nil,
|
|
||||||
last_name: "",
|
|
||||||
email: "john@example.com"
|
|
||||||
}
|
|
||||||
|
|
||||||
assert MemberHelpers.display_name(member) == "john@example.com"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "handles one whitespace and one nil" do
|
|
||||||
member = %Member{
|
|
||||||
first_name: " ",
|
|
||||||
last_name: nil,
|
|
||||||
email: "john@example.com"
|
|
||||||
}
|
|
||||||
|
|
||||||
assert MemberHelpers.display_name(member) == "john@example.com"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "handles one valid name and one whitespace" do
|
|
||||||
member = %Member{
|
|
||||||
first_name: "John",
|
|
||||||
last_name: " ",
|
|
||||||
email: "john@example.com"
|
|
||||||
}
|
|
||||||
|
|
||||||
assert MemberHelpers.display_name(member) == "John"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "handles member with only first_name containing whitespace" do
|
|
||||||
member = %Member{
|
|
||||||
first_name: " John ",
|
|
||||||
last_name: nil,
|
|
||||||
email: "john@example.com"
|
|
||||||
}
|
|
||||||
|
|
||||||
assert MemberHelpers.display_name(member) == "John"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "handles member with only last_name containing whitespace" do
|
|
||||||
member = %Member{
|
|
||||||
first_name: nil,
|
|
||||||
last_name: " Doe ",
|
|
||||||
email: "john@example.com"
|
|
||||||
}
|
|
||||||
|
|
||||||
assert MemberHelpers.display_name(member) == "Doe"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -12,20 +12,6 @@ defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
setup %{conn: conn} do
|
|
||||||
# Create admin user
|
|
||||||
{:ok, user} =
|
|
||||||
Mv.Accounts.User
|
|
||||||
|> Ash.Changeset.for_create(:register_with_password, %{
|
|
||||||
email: "admin#{System.unique_integer([:positive])}@mv.local",
|
|
||||||
password: "testpassword123"
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
conn = conn_with_password_user(conn, user)
|
|
||||||
%{conn: conn, user: user}
|
|
||||||
end
|
|
||||||
|
|
||||||
# Helper to create a membership fee type
|
# Helper to create a membership fee type
|
||||||
defp create_fee_type(attrs) do
|
defp create_fee_type(attrs) do
|
||||||
default_attrs = %{
|
default_attrs = %{
|
||||||
|
|
|
||||||
|
|
@ -12,20 +12,6 @@ defmodule MvWeb.MemberLive.MembershipFeeIntegrationTest do
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
setup do
|
|
||||||
# Create admin user
|
|
||||||
{:ok, user} =
|
|
||||||
Mv.Accounts.User
|
|
||||||
|> Ash.Changeset.for_create(:register_with_password, %{
|
|
||||||
email: "admin#{System.unique_integer([:positive])}@mv.local",
|
|
||||||
password: "testpassword123"
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
conn = conn_with_password_user(build_conn(), user)
|
|
||||||
%{conn: conn, user: user}
|
|
||||||
end
|
|
||||||
|
|
||||||
# Helper to create a membership fee type
|
# Helper to create a membership fee type
|
||||||
defp create_fee_type(attrs) do
|
defp create_fee_type(attrs) do
|
||||||
default_attrs = %{
|
default_attrs = %{
|
||||||
|
|
|
||||||
|
|
@ -12,20 +12,6 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
setup %{conn: conn} do
|
|
||||||
# Create admin user
|
|
||||||
{:ok, user} =
|
|
||||||
Mv.Accounts.User
|
|
||||||
|> Ash.Changeset.for_create(:register_with_password, %{
|
|
||||||
email: "admin#{System.unique_integer([:positive])}@mv.local",
|
|
||||||
password: "testpassword123"
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
conn = conn_with_password_user(conn, user)
|
|
||||||
%{conn: conn, user: user}
|
|
||||||
end
|
|
||||||
|
|
||||||
# Helper to create a membership fee type
|
# Helper to create a membership fee type
|
||||||
defp create_fee_type(attrs) do
|
defp create_fee_type(attrs) do
|
||||||
default_attrs = %{
|
default_attrs = %{
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,13 @@ defmodule MvWeb.MemberLive.ShowTest do
|
||||||
- Custom Fields section visibility (Issue #282 regression test)
|
- Custom Fields section visibility (Issue #282 regression test)
|
||||||
- Custom field values formatting
|
- Custom field values formatting
|
||||||
|
|
||||||
## Note on async: false
|
## Note on async
|
||||||
Tests use `async: false` (not `async: true`) to prevent PostgreSQL deadlocks
|
Tests can run with `async: true` because:
|
||||||
when creating members and custom fields concurrently. This is intentional and
|
- Each test explicitly manages its own custom fields (creates/deletes as needed)
|
||||||
documented here to avoid confusion in commit messages.
|
- The SQL Sandbox provides proper isolation between parallel tests
|
||||||
|
- Custom field cleanup in tests ensures no interference between tests
|
||||||
"""
|
"""
|
||||||
# async: false to prevent PostgreSQL deadlocks when creating members and custom fields
|
use MvWeb.ConnCase, async: true
|
||||||
use MvWeb.ConnCase, async: false
|
|
||||||
import Phoenix.LiveViewTest
|
import Phoenix.LiveViewTest
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
use Gettext, backend: MvWeb.Gettext
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
|
@ -113,11 +113,22 @@ defmodule MvWeb.MemberLive.ShowTest do
|
||||||
conn: conn,
|
conn: conn,
|
||||||
member: member
|
member: member
|
||||||
} do
|
} do
|
||||||
|
# Ensure no custom fields exist for this test
|
||||||
|
# This ensures test isolation even if previous tests created custom fields
|
||||||
|
existing_custom_fields = Ash.read!(CustomField)
|
||||||
|
|
||||||
|
for cf <- existing_custom_fields do
|
||||||
|
Ash.destroy!(cf)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Verify no custom fields exist
|
||||||
|
assert Ash.read!(CustomField) == []
|
||||||
|
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, _view, html} = live(conn, ~p"/members/#{member}")
|
{:ok, _view, html} = live(conn, ~p"/members/#{member}")
|
||||||
|
|
||||||
# Custom Fields section should NOT be visible
|
# Custom Fields section should NOT be visible
|
||||||
refute html =~ gettext("Custom Fields")
|
refute html =~ gettext("Additional Data Fields")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,7 @@ defmodule MvWeb.ConnCase do
|
||||||
@doc """
|
@doc """
|
||||||
Signs in a user via OIDC and returns a connection with the user authenticated.
|
Signs in a user via OIDC and returns a connection with the user authenticated.
|
||||||
By default creates a user with "user@example.com" for consistency.
|
By default creates a user with "user@example.com" for consistency.
|
||||||
|
The user will have an admin role for authorization.
|
||||||
"""
|
"""
|
||||||
def conn_with_oidc_user(conn, user_attrs \\ %{}) do
|
def conn_with_oidc_user(conn, user_attrs \\ %{}) do
|
||||||
# Ensure unique email for OIDC users
|
# Ensure unique email for OIDC users
|
||||||
|
|
@ -109,8 +110,22 @@ defmodule MvWeb.ConnCase do
|
||||||
oidc_id: "oidc_#{unique_id}"
|
oidc_id: "oidc_#{unique_id}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Create user using Ash.Seed (supports oidc_id)
|
||||||
user = create_test_user(Map.merge(default_attrs, user_attrs))
|
user = create_test_user(Map.merge(default_attrs, user_attrs))
|
||||||
sign_in_user_via_oidc(conn, user)
|
|
||||||
|
# Create admin role and assign it
|
||||||
|
admin_role = Mv.Fixtures.role_fixture("admin")
|
||||||
|
|
||||||
|
{:ok, user} =
|
||||||
|
user
|
||||||
|
|> Ash.Changeset.for_update(:update, %{})
|
||||||
|
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||||
|
|> Ash.update()
|
||||||
|
|
||||||
|
# Load role for authorization
|
||||||
|
user_with_role = Ash.load!(user, :role, domain: Mv.Accounts)
|
||||||
|
|
||||||
|
sign_in_user_via_oidc(conn, user_with_role)
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|
@ -122,6 +137,15 @@ defmodule MvWeb.ConnCase do
|
||||||
|> AshAuthentication.Plug.Helpers.store_in_session(user)
|
|> AshAuthentication.Plug.Helpers.store_in_session(user)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Creates a connection with an authenticated user that has an admin role.
|
||||||
|
This is useful for tests that need full access to resources.
|
||||||
|
"""
|
||||||
|
def conn_with_admin_user(conn) do
|
||||||
|
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||||
|
conn_with_password_user(conn, admin_user)
|
||||||
|
end
|
||||||
|
|
||||||
setup tags do
|
setup tags do
|
||||||
pid = Mv.DataCase.setup_sandbox(tags)
|
pid = Mv.DataCase.setup_sandbox(tags)
|
||||||
|
|
||||||
|
|
@ -130,6 +154,36 @@ defmodule MvWeb.ConnCase do
|
||||||
# to share the test's database connection in async tests
|
# to share the test's database connection in async tests
|
||||||
conn = Plug.Conn.put_private(conn, :ecto_sandbox, pid)
|
conn = Plug.Conn.put_private(conn, :ecto_sandbox, pid)
|
||||||
|
|
||||||
{:ok, conn: conn}
|
# Handle role tags for future test extensions
|
||||||
|
# Default to admin to maintain backward compatibility with existing tests
|
||||||
|
role = Map.get(tags, :role, :admin)
|
||||||
|
|
||||||
|
{conn, user} =
|
||||||
|
case role do
|
||||||
|
:admin ->
|
||||||
|
# Create admin user with role for all tests (unless test overrides with its own user)
|
||||||
|
# This ensures all tests have an authenticated user with proper authorization
|
||||||
|
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||||
|
authenticated_conn = conn_with_password_user(conn, admin_user)
|
||||||
|
{authenticated_conn, admin_user}
|
||||||
|
|
||||||
|
:member ->
|
||||||
|
# Create member user for role-based testing
|
||||||
|
member_user = Mv.Fixtures.user_with_role_fixture("member")
|
||||||
|
authenticated_conn = conn_with_password_user(conn, member_user)
|
||||||
|
{authenticated_conn, member_user}
|
||||||
|
|
||||||
|
:unauthenticated ->
|
||||||
|
# No authentication for unauthenticated tests
|
||||||
|
{conn, nil}
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
# Fallback: treat unknown role as admin for safety
|
||||||
|
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||||
|
authenticated_conn = conn_with_password_user(conn, admin_user)
|
||||||
|
{authenticated_conn, admin_user}
|
||||||
|
end
|
||||||
|
|
||||||
|
{:ok, conn: conn, current_user: user}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -93,4 +93,104 @@ defmodule Mv.Fixtures do
|
||||||
|
|
||||||
{user, member}
|
{user, member}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Creates a role with a specific permission set.
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
- `permission_set_name` - The permission set name (e.g., "admin", "read_only", "normal_user", "own_data")
|
||||||
|
|
||||||
|
## Returns
|
||||||
|
- Role struct
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> role_fixture("admin")
|
||||||
|
%Mv.Authorization.Role{permission_set_name: "admin", ...}
|
||||||
|
|
||||||
|
"""
|
||||||
|
def role_fixture(permission_set_name) do
|
||||||
|
role_name = "Test Role #{permission_set_name} #{System.unique_integer([:positive])}"
|
||||||
|
|
||||||
|
case Mv.Authorization.create_role(%{
|
||||||
|
name: role_name,
|
||||||
|
description: "Test role for #{permission_set_name}",
|
||||||
|
permission_set_name: permission_set_name
|
||||||
|
}) do
|
||||||
|
{:ok, role} -> role
|
||||||
|
{:error, error} -> raise "Failed to create role: #{inspect(error)}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Creates a user with a specific permission set (role).
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
- `permission_set_name` - The permission set name (e.g., "admin", "read_only", "normal_user", "own_data")
|
||||||
|
- `user_attrs` - Optional user attributes
|
||||||
|
|
||||||
|
## Returns
|
||||||
|
- User struct with role preloaded
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> admin_user = user_with_role_fixture("admin")
|
||||||
|
iex> admin_user.role.permission_set_name
|
||||||
|
"admin"
|
||||||
|
|
||||||
|
"""
|
||||||
|
def user_with_role_fixture(permission_set_name \\ "admin", user_attrs \\ %{}) do
|
||||||
|
# Create role with permission set
|
||||||
|
role = role_fixture(permission_set_name)
|
||||||
|
|
||||||
|
# Create user
|
||||||
|
{:ok, user} =
|
||||||
|
user_attrs
|
||||||
|
|> Enum.into(%{
|
||||||
|
email: "user#{System.unique_integer([:positive])}@example.com"
|
||||||
|
})
|
||||||
|
|> Mv.Accounts.create_user()
|
||||||
|
|
||||||
|
# Assign role to user
|
||||||
|
{:ok, user} =
|
||||||
|
user
|
||||||
|
|> Ash.Changeset.for_update(:update, %{})
|
||||||
|
|> Ash.Changeset.manage_relationship(:role, role, type: :append_and_remove)
|
||||||
|
|> Ash.update()
|
||||||
|
|
||||||
|
# Reload user with role preloaded (critical for authorization!)
|
||||||
|
{:ok, user_with_role} = Ash.load(user, :role, domain: Mv.Accounts)
|
||||||
|
user_with_role
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Creates a member with an actor (for use in tests with policies).
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
- `attrs` - Map or keyword list of attributes to override defaults
|
||||||
|
- `actor` - The actor (user) to use for authorization
|
||||||
|
|
||||||
|
## Returns
|
||||||
|
- Member struct
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> admin = user_with_role_fixture("admin")
|
||||||
|
iex> member_fixture_with_actor(%{first_name: "Alice"}, admin)
|
||||||
|
%Mv.Membership.Member{first_name: "Alice", ...}
|
||||||
|
|
||||||
|
"""
|
||||||
|
def member_fixture_with_actor(attrs \\ %{}, actor) do
|
||||||
|
attrs
|
||||||
|
|> Enum.into(%{
|
||||||
|
first_name: "Test",
|
||||||
|
last_name: "Member",
|
||||||
|
email: "test#{System.unique_integer([:positive])}@example.com"
|
||||||
|
})
|
||||||
|
|> Mv.Membership.create_member(actor: actor)
|
||||||
|
|> case do
|
||||||
|
{:ok, member} -> member
|
||||||
|
{:error, error} -> raise "Failed to create member: #{inspect(error)}"
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue