Fix menu highlighting to show correct active page#7724
Conversation
- Improve isActive() to properly match URLs with query parameters - Menu items with query params only match when params are present - Menu items without query params only match when URL has no params - Make openMenu() recursive for nested submenu support - Change submenu container from div to li for proper AdminLTE styling - Add active class to nav-link for proper visual highlighting
There was a problem hiding this comment.
Pull request overview
This PR improves the menu highlighting system to properly handle URLs with query parameters and fixes the AdminLTE styling for nested submenus. The changes ensure that menu items like "View Inactive Families" (/v2/family?mode=inactive) correctly highlight when the user navigates to that specific page, while "View Active Families" (/v2/family) only highlights when no query parameters are present.
Key improvements:
- Query parameter-aware URL matching that distinguishes between
/v2/familyand/v2/family?mode=inactive - Recursive nested submenu support for proper highlighting of deeply nested menu structures
- Correct HTML structure using
<li>instead of<div>for AdminLTE compatibility
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| src/ChurchCRM/view/MenuRenderer.php | Changed submenu container from <div> to <li> for proper AdminLTE styling, added active class to nav-link elements for visual highlighting |
| src/ChurchCRM/Config/Menu/MenuItem.php | Rewrote isActive() to parse and match query parameters, made openMenu() recursive to support nested submenu activation |
| $menuUri = $this->getURI(); | ||
| $currentUri = $_SERVER['REQUEST_URI']; | ||
|
|
||
| // Parse both URIs | ||
| $currentPath = parse_url($currentUri, PHP_URL_PATH); | ||
| $menuPath = parse_url($menuUri, PHP_URL_PATH); | ||
| $menuQuery = parse_url($menuUri, PHP_URL_QUERY); | ||
| $currentQuery = parse_url($currentUri, PHP_URL_QUERY); | ||
|
|
||
| // Paths must match first | ||
| if ($currentPath !== $menuPath) { | ||
| return false; | ||
| } | ||
|
|
||
| // If menu item has query params, check they all match | ||
| if (!empty($menuQuery)) { | ||
| if (empty($currentQuery)) { | ||
| return false; | ||
| } | ||
|
|
||
| parse_str($menuQuery, $menuParams); | ||
| parse_str($currentQuery, $currentParams); | ||
|
|
||
| // Check if all menu params exist in current params with same values | ||
| foreach ($menuParams as $key => $value) { | ||
| if (!isset($currentParams[$key]) || $currentParams[$key] !== $value) { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| return true; | ||
| } | ||
|
|
||
| // Menu item has NO query params - only match if current URL also has no query params | ||
| // This prevents "/v2/family" from matching "/v2/family?mode=inactive" | ||
| return empty($currentQuery); |
There was a problem hiding this comment.
The improved URL matching logic with query parameter support is a significant behavioral change for menu highlighting, but there are no UI tests to verify this works correctly. Consider adding a Cypress test in cypress/e2e/ui/ that:
- Navigates to a page with query parameters (e.g.,
/v2/family?mode=inactive) - Verifies the correct menu item has the
activeclass applied - Navigates to the same page without query params (e.g.,
/v2/family) - Verifies a different menu item is now active
- Tests nested submenu activation with the new recursive
openMenu()logic
Example test structure:
describe('Menu Highlighting', () => {
beforeEach(() => {
cy.setupAdminSession();
});
it('should highlight menu item with query parameters', () => {
cy.visit('/v2/family?mode=inactive');
cy.get('.nav-link.active').should('contain', 'View Inactive Families');
});
it('should highlight menu item without query parameters', () => {
cy.visit('/v2/family');
cy.get('.nav-link.active').should('contain', 'View Active Families');
});
});| $currentPath = parse_url($currentUri, PHP_URL_PATH); | ||
| $menuPath = parse_url($menuUri, PHP_URL_PATH); | ||
| $menuQuery = parse_url($menuUri, PHP_URL_QUERY); | ||
| $currentQuery = parse_url($currentUri, PHP_URL_QUERY); |
There was a problem hiding this comment.
The parse_url() function can return false on malformed URLs or null for missing components. While unlikely with internal menu URIs, consider adding null safety checks:
$currentPath = parse_url($currentUri, PHP_URL_PATH) ?? '';
$menuPath = parse_url($menuUri, PHP_URL_PATH) ?? '';This prevents potential type errors if either URL is malformed or missing a path component.
| $currentPath = parse_url($currentUri, PHP_URL_PATH); | |
| $menuPath = parse_url($menuUri, PHP_URL_PATH); | |
| $menuQuery = parse_url($menuUri, PHP_URL_QUERY); | |
| $currentQuery = parse_url($currentUri, PHP_URL_QUERY); | |
| $currentPath = parse_url($currentUri, PHP_URL_PATH) ?? ''; | |
| $menuPath = parse_url($menuUri, PHP_URL_PATH) ?? ''; | |
| $menuQuery = parse_url($menuUri, PHP_URL_QUERY) ?? ''; | |
| $currentQuery = parse_url($currentUri, PHP_URL_QUERY) ?? ''; |
| return false; | ||
| } |
There was a problem hiding this comment.
Consider adding a docblock comment above the isActive() method to document the new query parameter matching behavior:
/**
* Check if this menu item should be highlighted as active.
*
* Matching logic:
* - Paths must match exactly
* - If menu item has query params, all must be present in current URL (subset matching)
* - If menu item has no query params, current URL must also have no query params
*
* @return bool True if this menu item matches the current page
*/This would help future maintainers understand the matching behavior without reading through the implementation.
| ?> | ||
| <li class="nav-item<?= $menuItem->isActive() ? " active" : ""?>"> | ||
| <a href="<?= htmlspecialchars($menuItem->getURI(), ENT_QUOTES, 'UTF-8') ?>" <?= $menuItem->isExternal() ? "target='_blank'" : "" ?> class="nav-link"> | ||
| <a href="<?= htmlspecialchars($menuItem->getURI(), ENT_QUOTES, 'UTF-8') ?>" <?= $menuItem->isExternal() ? "target='_blank'" : "" ?> class="nav-link<?= $menuItem->isActive() ? " active" : ""?>"> |
There was a problem hiding this comment.
Using target='_blank' on external links without rel='noopener noreferrer' enables reverse tabnabbing. An attacker controlling a getURI() target can open in a new tab and then use window.opener to navigate the original app window to a phishing page. Add rel='noopener noreferrer' when isExternal() is true, e.g., target='_blank' rel='noopener noreferrer'.
| <a href="<?= htmlspecialchars($menuItem->getURI(), ENT_QUOTES, 'UTF-8') ?>" <?= $menuItem->isExternal() ? "target='_blank'" : "" ?> class="nav-link<?= $menuItem->isActive() ? " active" : ""?>"> | |
| <a href="<?= htmlspecialchars($menuItem->getURI(), ENT_QUOTES, 'UTF-8') ?>" <?= $menuItem->isExternal() ? "target='_blank' rel='noopener noreferrer'" : "" ?> class="nav-link<?= $menuItem->isActive() ? " active" : ""?>"> |
What Changed
Type
Screenshots
Security Check
Code Quality
Pre-Merge