[CLAUDE] FE: NavLink active check query string (khong chi pathname)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m51s

Bug: click leaf 'Duyet' (/purchase-evaluations?type=2&pendingMe=1) khien
leaf 'Danh sach' (/purchase-evaluations?type=2) cung highlight cung luc.
Nguyen nhan: NavLink 'end' prop chi match pathname. 2 leaf cung pathname
/purchase-evaluations → ca 2 active.

Fix: custom isActive voi queryMatches helper — compare query string dang
key-value set (thu tu param khong quan trong). 2 leaf chi active khi
pathname + query dung khop.

Dong bo ca fe-admin + fe-user. Anh huong tat ca menu leaf co ?query=
variants: Ct_* (Danh sach /contracts?type=N vs Duyet /contracts?type=N&
pendingMe=1), Pe_* (tuong tu /purchase-evaluations), admin workflow leaf
Wf_* + PeWf_* (khong dinh vi path khong query params).
This commit is contained in:
pqhuy1987
2026-04-24 11:23:56 +07:00
parent 79398fb41f
commit fc4b3d6078
2 changed files with 53 additions and 23 deletions

View File

@ -1,4 +1,4 @@
import { Link, NavLink, Outlet } from 'react-router-dom' import { Link, NavLink, Outlet, useLocation } from 'react-router-dom'
import { ChevronDown, Circle, type LucideIcon } from 'lucide-react' import { ChevronDown, Circle, type LucideIcon } from 'lucide-react'
import * as Icons from 'lucide-react' import * as Icons from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
@ -142,27 +142,39 @@ function MenuGroup({ node, depth }: { node: MenuNode; depth: number }) {
) )
} }
// So sánh 2 query string dạng key-value set (thứ tự param không quan trọng).
// Fix bug: /contracts?type=1 và ?type=1&pendingMe=1 cùng highlight vì NavLink
// built-in `end` prop chỉ match pathname, không check query string.
function queryMatches(current: string, target: string): boolean {
const a = new URLSearchParams(current)
const b = new URLSearchParams(target)
const aKeys = [...a.keys()].sort()
const bKeys = [...b.keys()].sort()
if (aKeys.length !== bKeys.length) return false
return aKeys.every((k, i) => bKeys[i] === k && a.get(k) === b.get(k))
}
function MenuLeaf({ node, depth }: { node: MenuNode; depth: number }) { function MenuLeaf({ node, depth }: { node: MenuNode; depth: number }) {
const Icon = getIcon(node.icon) const Icon = getIcon(node.icon)
const path = resolvePath(node.key) const path = resolvePath(node.key)
const location = useLocation()
if (!path) return null if (!path) return null
const isDeep = depth >= 2 const isDeep = depth >= 2
const [targetPath, targetQuery = ''] = path.split('?')
const isActive = location.pathname === targetPath
&& queryMatches(location.search.replace(/^\?/, ''), targetQuery)
return ( return (
<NavLink <NavLink
to={path} to={path}
// NavLink's default "startsWith" match causes /contracts?type=1 and className={cn(
// /contracts to both highlight. Use `end` for query-param variants.
end={path.includes('?')}
className={({ isActive }) =>
cn(
'flex items-center gap-2.5 rounded-md transition', 'flex items-center gap-2.5 rounded-md transition',
isDeep ? 'px-3 py-1 text-[12px]' : 'px-3 py-2 text-sm font-medium', isDeep ? 'px-3 py-1 text-[12px]' : 'px-3 py-2 text-sm font-medium',
isActive isActive
? 'bg-brand-50 text-brand-700' ? 'bg-brand-50 text-brand-700'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900', : 'text-slate-600 hover:bg-slate-100 hover:text-slate-900',
) )}
}
> >
<Icon className={cn(isDeep ? 'h-3.5 w-3.5' : 'h-4 w-4')} /> <Icon className={cn(isDeep ? 'h-3.5 w-3.5' : 'h-4 w-4')} />
{node.label} {node.label}

View File

@ -185,25 +185,43 @@ function MenuGroup({ node, depth }: { node: MenuNode; depth: number }) {
) )
} }
// So sánh 2 query string dạng key-value set (thứ tự param không quan trọng).
// Dùng để distinguish /path?type=2 vs /path?type=2&pendingMe=1 — NavLink isActive
// built-in chỉ match pathname, không check query string.
function queryMatches(current: string, target: string): boolean {
const a = new URLSearchParams(current)
const b = new URLSearchParams(target)
const aKeys = [...a.keys()].sort()
const bKeys = [...b.keys()].sort()
if (aKeys.length !== bKeys.length) return false
return aKeys.every((k, i) => bKeys[i] === k && a.get(k) === b.get(k))
}
function MenuLeaf({ node, depth }: { node: MenuNode; depth: number }) { function MenuLeaf({ node, depth }: { node: MenuNode; depth: number }) {
const Icon = getIcon(node.icon) const Icon = getIcon(node.icon)
const path = resolvePath(node.key) const path = resolvePath(node.key)
const location = useLocation()
if (!path) return null if (!path) return null
const isDeep = depth >= 2 const isDeep = depth >= 2
// Custom active check: pathname match + query string match (set equality).
// Fix bug: /purchase-evaluations?type=2 và ?type=2&pendingMe=1 cùng highlight
// vì NavLink default chỉ check pathname.
const [targetPath, targetQuery = ''] = path.split('?')
const pathnameMatches = location.pathname === targetPath
const qMatches = queryMatches(location.search.replace(/^\?/, ''), targetQuery)
const isActive = pathnameMatches && qMatches
return ( return (
<NavLink <NavLink
to={path} to={path}
end={path.includes('?')} className={cn(
className={({ isActive }) =>
cn(
'flex items-center gap-2.5 rounded-md transition', 'flex items-center gap-2.5 rounded-md transition',
isDeep ? 'px-3 py-1 text-[12px]' : 'px-3 py-2 text-sm font-medium', isDeep ? 'px-3 py-1 text-[12px]' : 'px-3 py-2 text-sm font-medium',
isActive isActive
? 'bg-brand-50 text-brand-700' ? 'bg-brand-50 text-brand-700'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900', : 'text-slate-600 hover:bg-slate-100 hover:text-slate-900',
) )}
}
> >
<Icon className={cn(isDeep ? 'h-3.5 w-3.5' : 'h-4 w-4')} /> <Icon className={cn(isDeep ? 'h-3.5 w-3.5' : 'h-4 w-4')} />
{node.label} {node.label}