mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	feat: currently selected nav item (#7182)
This commit is contained in:
		
							parent
							
								
									c8fa7e477a
								
							
						
					
					
						commit
						5a9b015022
					
				| @ -30,6 +30,7 @@ import { ReactComponent as ProjectIcon } from 'assets/icons/projectIconSmall.svg | |||||||
| import type { FC } from 'react'; | import type { FC } from 'react'; | ||||||
| import { styled } from '@mui/material'; | import { styled } from '@mui/material'; | ||||||
| 
 | 
 | ||||||
|  | // TODO: move to routes
 | ||||||
| const icons: Record<string, typeof SvgIcon> = { | const icons: Record<string, typeof SvgIcon> = { | ||||||
|     '/applications': ApplicationsIcon, |     '/applications': ApplicationsIcon, | ||||||
|     '/context': ContextFieldsIcon, |     '/context': ContextFieldsIcon, | ||||||
|  | |||||||
| @ -14,7 +14,7 @@ import type { Theme } from '@mui/material/styles/createTheme'; | |||||||
| const listItemButtonStyle = (theme: Theme) => ({ | const listItemButtonStyle = (theme: Theme) => ({ | ||||||
|     borderRadius: theme.spacing(0.5), |     borderRadius: theme.spacing(0.5), | ||||||
|     borderLeft: `${theme.spacing(0.5)} solid transparent`, |     borderLeft: `${theme.spacing(0.5)} solid transparent`, | ||||||
|     '&:hover': { |     '&.Mui-selected': { | ||||||
|         borderLeft: `${theme.spacing(0.5)} solid ${theme.palette.primary.main}`, |         borderLeft: `${theme.spacing(0.5)} solid ${theme.palette.primary.main}`, | ||||||
|     }, |     }, | ||||||
| }); | }); | ||||||
| @ -23,8 +23,9 @@ export const FullListItem: FC<{ | |||||||
|     href: string; |     href: string; | ||||||
|     text: string; |     text: string; | ||||||
|     badge?: ReactNode; |     badge?: ReactNode; | ||||||
|     onClick?: () => void; |     onClick: () => void; | ||||||
| }> = ({ href, text, badge, onClick, children }) => { |     selected?: boolean; | ||||||
|  | }> = ({ href, text, badge, onClick, selected, children }) => { | ||||||
|     return ( |     return ( | ||||||
|         <ListItem disablePadding onClick={onClick}> |         <ListItem disablePadding onClick={onClick}> | ||||||
|             <ListItemButton |             <ListItemButton | ||||||
| @ -32,6 +33,7 @@ export const FullListItem: FC<{ | |||||||
|                 component={Link} |                 component={Link} | ||||||
|                 to={href} |                 to={href} | ||||||
|                 sx={listItemButtonStyle} |                 sx={listItemButtonStyle} | ||||||
|  |                 selected={selected} | ||||||
|             > |             > | ||||||
|                 <ListItemIcon sx={(theme) => ({ minWidth: theme.spacing(4) })}> |                 <ListItemIcon sx={(theme) => ({ minWidth: theme.spacing(4) })}> | ||||||
|                     {children} |                     {children} | ||||||
| @ -82,18 +84,20 @@ export const SignOutItem = () => { | |||||||
|     ); |     ); | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export const MiniListItem: FC<{ href: string; text: string }> = ({ | export const MiniListItem: FC<{ | ||||||
|     href, |     href: string; | ||||||
|     text, |     text: string; | ||||||
|     children, |     selected?: boolean; | ||||||
| }) => { |     onClick: () => void; | ||||||
|  | }> = ({ href, text, selected, onClick, children }) => { | ||||||
|     return ( |     return ( | ||||||
|         <ListItem disablePadding> |         <ListItem disablePadding onClick={onClick}> | ||||||
|             <ListItemButton |             <ListItemButton | ||||||
|                 dense={true} |                 dense={true} | ||||||
|                 component={Link} |                 component={Link} | ||||||
|                 to={href} |                 to={href} | ||||||
|                 sx={listItemButtonStyle} |                 sx={listItemButtonStyle} | ||||||
|  |                 selected={selected} | ||||||
|             > |             > | ||||||
|                 <Tooltip title={text} placement='right'> |                 <Tooltip title={text} placement='right'> | ||||||
|                     <ListItemIcon |                     <ListItemIcon | ||||||
|  | |||||||
| @ -1,4 +1,4 @@ | |||||||
| import { type FC, type ReactNode, useCallback } from 'react'; | import { type FC, useCallback } from 'react'; | ||||||
| import type { INavigationMenuItem } from 'interfaces/route'; | import type { INavigationMenuItem } from 'interfaces/route'; | ||||||
| import type { NavigationMode } from './NavigationMode'; | import type { NavigationMode } from './NavigationMode'; | ||||||
| import { | import { | ||||||
| @ -48,34 +48,12 @@ const useShowBadge = () => { | |||||||
|     return showBadge; |     return showBadge; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export const ConfigureNavigationList: FC<{ | export const SecondaryNavigationList: FC<{ | ||||||
|     routes: INavigationMenuItem[]; |     routes: INavigationMenuItem[]; | ||||||
|     mode: NavigationMode; |     mode: NavigationMode; | ||||||
|     onClick?: () => void; |     onClick: (activeItem: string) => void; | ||||||
| }> = ({ routes, mode, onClick }) => { |     activeItem?: string; | ||||||
|     const DynamicListItem = mode === 'mini' ? MiniListItem : FullListItem; | }> = ({ routes, mode, onClick, activeItem }) => { | ||||||
| 
 |  | ||||||
|     return ( |  | ||||||
|         <List> |  | ||||||
|             {routes.map((route) => ( |  | ||||||
|                 <DynamicListItem |  | ||||||
|                     key={route.title} |  | ||||||
|                     href={route.path} |  | ||||||
|                     text={route.title} |  | ||||||
|                     onClick={onClick} |  | ||||||
|                 > |  | ||||||
|                     <IconRenderer path={route.path} /> |  | ||||||
|                 </DynamicListItem> |  | ||||||
|             ))} |  | ||||||
|         </List> |  | ||||||
|     ); |  | ||||||
| }; |  | ||||||
| export const AdminNavigationList: FC<{ |  | ||||||
|     routes: INavigationMenuItem[]; |  | ||||||
|     mode: NavigationMode; |  | ||||||
|     badge?: ReactNode; |  | ||||||
|     onClick?: () => void; |  | ||||||
| }> = ({ routes, mode, onClick, badge }) => { |  | ||||||
|     const showBadge = useShowBadge(); |     const showBadge = useShowBadge(); | ||||||
|     const DynamicListItem = mode === 'mini' ? MiniListItem : FullListItem; |     const DynamicListItem = mode === 'mini' ? MiniListItem : FullListItem; | ||||||
| 
 | 
 | ||||||
| @ -84,9 +62,10 @@ export const AdminNavigationList: FC<{ | |||||||
|             {routes.map((route) => ( |             {routes.map((route) => ( | ||||||
|                 <DynamicListItem |                 <DynamicListItem | ||||||
|                     key={route.title} |                     key={route.title} | ||||||
|                     onClick={onClick} |                     onClick={() => onClick(route.path)} | ||||||
|                     href={route.path} |                     href={route.path} | ||||||
|                     text={route.title} |                     text={route.title} | ||||||
|  |                     selected={activeItem === route.path} | ||||||
|                     badge={ |                     badge={ | ||||||
|                         showBadge(route?.menu?.mode) ? ( |                         showBadge(route?.menu?.mode) ? ( | ||||||
|                             <EnterprisePlanBadge /> |                             <EnterprisePlanBadge /> | ||||||
| @ -121,26 +100,43 @@ export const OtherLinksList = () => { | |||||||
| 
 | 
 | ||||||
| export const PrimaryNavigationList: FC<{ | export const PrimaryNavigationList: FC<{ | ||||||
|     mode: NavigationMode; |     mode: NavigationMode; | ||||||
|     onClick?: () => void; |     onClick: (activeItem: string) => void; | ||||||
| }> = ({ mode, onClick }) => { |     activeItem?: string; | ||||||
|  | }> = ({ mode, onClick, activeItem }) => { | ||||||
|     const DynamicListItem = mode === 'mini' ? MiniListItem : FullListItem; |     const DynamicListItem = mode === 'mini' ? MiniListItem : FullListItem; | ||||||
| 
 | 
 | ||||||
|     return ( |     return ( | ||||||
|         <List> |         <List> | ||||||
|             <DynamicListItem href='/projects' text='Projects' onClick={onClick}> |             <DynamicListItem | ||||||
|  |                 href='/projects' | ||||||
|  |                 text='Projects' | ||||||
|  |                 onClick={() => onClick('/projects')} | ||||||
|  |                 selected={activeItem === '/projects'} | ||||||
|  |             > | ||||||
|                 <StyledProjectIcon /> |                 <StyledProjectIcon /> | ||||||
|             </DynamicListItem> |             </DynamicListItem> | ||||||
|             <DynamicListItem href='/search' text='Search' onClick={onClick}> |             <DynamicListItem | ||||||
|  |                 href='/search' | ||||||
|  |                 text='Search' | ||||||
|  |                 onClick={() => onClick('/search')} | ||||||
|  |                 selected={activeItem === '/search'} | ||||||
|  |             > | ||||||
|                 <SearchIcon /> |                 <SearchIcon /> | ||||||
|             </DynamicListItem> |             </DynamicListItem> | ||||||
|             <DynamicListItem |             <DynamicListItem | ||||||
|                 href='/playground' |                 href='/playground' | ||||||
|                 text='Playground' |                 text='Playground' | ||||||
|                 onClick={onClick} |                 onClick={() => onClick('/playground')} | ||||||
|  |                 selected={activeItem === '/playground'} | ||||||
|             > |             > | ||||||
|                 <PlaygroundIcon /> |                 <PlaygroundIcon /> | ||||||
|             </DynamicListItem> |             </DynamicListItem> | ||||||
|             <DynamicListItem href='/insights' text='Insights' onClick={onClick}> |             <DynamicListItem | ||||||
|  |                 href='/insights' | ||||||
|  |                 text='Insights' | ||||||
|  |                 onClick={() => onClick('/insights')} | ||||||
|  |                 selected={activeItem === '/insights'} | ||||||
|  |             > | ||||||
|                 <InsightsIcon /> |                 <InsightsIcon /> | ||||||
|             </DynamicListItem> |             </DynamicListItem> | ||||||
|         </List> |         </List> | ||||||
| @ -163,23 +159,21 @@ const AccordionHeader: FC = ({ children }) => { | |||||||
| 
 | 
 | ||||||
| export const SecondaryNavigation: FC<{ | export const SecondaryNavigation: FC<{ | ||||||
|     expanded: boolean; |     expanded: boolean; | ||||||
|     onChange: (expanded: boolean) => void; |     onExpandChange: (expanded: boolean) => void; | ||||||
|     mode: NavigationMode; |     mode: NavigationMode; | ||||||
|     routes: INavigationMenuItem[]; |     title: string; | ||||||
| }> = ({ mode, expanded, onChange, routes, children }) => { | }> = ({ mode, expanded, onExpandChange, title, children }) => { | ||||||
|     return ( |     return ( | ||||||
|         <Accordion |         <Accordion | ||||||
|             disableGutters={true} |             disableGutters={true} | ||||||
|             sx={{ boxShadow: 'none' }} |             sx={{ boxShadow: 'none' }} | ||||||
|             expanded={expanded} |             expanded={expanded} | ||||||
|             onChange={(_, expand) => { |             onChange={(_, expand) => { | ||||||
|                 onChange(expand); |                 onExpandChange(expand); | ||||||
|             }} |             }} | ||||||
|         > |         > | ||||||
|             {mode === 'full' && <AccordionHeader>{children}</AccordionHeader>} |             {mode === 'full' && <AccordionHeader>{title}</AccordionHeader>} | ||||||
|             <AccordionDetails sx={{ p: 0 }}> |             <AccordionDetails sx={{ p: 0 }}>{children}</AccordionDetails> | ||||||
|                 <ConfigureNavigationList routes={routes} mode={mode} /> |  | ||||||
|             </AccordionDetails> |  | ||||||
|         </Accordion> |         </Accordion> | ||||||
|     ); |     ); | ||||||
| }; | }; | ||||||
|  | |||||||
| @ -2,6 +2,8 @@ import { render } from 'utils/testRenderer'; | |||||||
| import { NavigationSidebar } from './NavigationSidebar'; | import { NavigationSidebar } from './NavigationSidebar'; | ||||||
| import { screen, fireEvent, waitFor } from '@testing-library/react'; | import { screen, fireEvent, waitFor } from '@testing-library/react'; | ||||||
| import { createLocalStorage } from 'utils/createLocalStorage'; | import { createLocalStorage } from 'utils/createLocalStorage'; | ||||||
|  | import { Route, Routes } from 'react-router-dom'; | ||||||
|  | import { listItemButtonClasses as classes } from '@mui/material/ListItemButton'; | ||||||
| 
 | 
 | ||||||
| beforeEach(() => { | beforeEach(() => { | ||||||
|     window.localStorage.clear(); |     window.localStorage.clear(); | ||||||
| @ -54,3 +56,16 @@ test('persist navigation mode and expansion selection in storage', async () => { | |||||||
|         expect(expanded).toEqual(['admin']); |         expect(expanded).toEqual(['admin']); | ||||||
|     }); |     }); | ||||||
| }); | }); | ||||||
|  | 
 | ||||||
|  | test('select active item', async () => { | ||||||
|  |     render( | ||||||
|  |         <Routes> | ||||||
|  |             <Route path={'/search'} element={<NavigationSidebar />} /> | ||||||
|  |         </Routes>, | ||||||
|  |         { route: '/search' }, | ||||||
|  |     ); | ||||||
|  | 
 | ||||||
|  |     const links = screen.getAllByRole('link'); | ||||||
|  | 
 | ||||||
|  |     expect(links[1]).toHaveClass(classes.selected); | ||||||
|  | }); | ||||||
|  | |||||||
| @ -1,16 +1,16 @@ | |||||||
| import { Box, styled } from '@mui/material'; | import { Box, styled } from '@mui/material'; | ||||||
| import type { FC } from 'react'; | import { type FC, useState } from 'react'; | ||||||
| import { useNavigationMode } from './useNavigationMode'; | import { useNavigationMode } from './useNavigationMode'; | ||||||
| import { ShowHide } from './ShowHide'; | import { ShowHide } from './ShowHide'; | ||||||
| import { useRoutes } from './useRoutes'; | import { useRoutes } from './useRoutes'; | ||||||
| import { useExpanded } from './useExpanded'; | import { useExpanded } from './useExpanded'; | ||||||
| import { | import { | ||||||
|     AdminNavigationList, |  | ||||||
|     ConfigureNavigationList, |  | ||||||
|     OtherLinksList, |     OtherLinksList, | ||||||
|     PrimaryNavigationList, |     PrimaryNavigationList, | ||||||
|     SecondaryNavigation, |     SecondaryNavigation, | ||||||
|  |     SecondaryNavigationList, | ||||||
| } from './NavigationList'; | } from './NavigationList'; | ||||||
|  | import { useInitialPathname } from './useInitialPathname'; | ||||||
| 
 | 
 | ||||||
| export const MobileNavigationSidebar: FC<{ onClick: () => void }> = ({ | export const MobileNavigationSidebar: FC<{ onClick: () => void }> = ({ | ||||||
|     onClick, |     onClick, | ||||||
| @ -20,7 +20,7 @@ export const MobileNavigationSidebar: FC<{ onClick: () => void }> = ({ | |||||||
|     return ( |     return ( | ||||||
|         <> |         <> | ||||||
|             <PrimaryNavigationList mode='full' onClick={onClick} /> |             <PrimaryNavigationList mode='full' onClick={onClick} /> | ||||||
|             <ConfigureNavigationList |             <SecondaryNavigationList | ||||||
|                 routes={routes.mainNavRoutes} |                 routes={routes.mainNavRoutes} | ||||||
|                 mode='full' |                 mode='full' | ||||||
|                 onClick={onClick} |                 onClick={onClick} | ||||||
| @ -45,29 +45,46 @@ export const NavigationSidebar = () => { | |||||||
| 
 | 
 | ||||||
|     const [mode, setMode] = useNavigationMode(); |     const [mode, setMode] = useNavigationMode(); | ||||||
|     const [expanded, changeExpanded] = useExpanded<'configure' | 'admin'>(); |     const [expanded, changeExpanded] = useExpanded<'configure' | 'admin'>(); | ||||||
|  |     const initialPathname = useInitialPathname(); | ||||||
|  | 
 | ||||||
|  |     const [activeItem, setActiveItem] = useState(initialPathname); | ||||||
| 
 | 
 | ||||||
|     return ( |     return ( | ||||||
|         <StyledBox> |         <StyledBox> | ||||||
|             <PrimaryNavigationList mode={mode} /> |             <PrimaryNavigationList | ||||||
|  |                 mode={mode} | ||||||
|  |                 onClick={setActiveItem} | ||||||
|  |                 activeItem={activeItem} | ||||||
|  |             /> | ||||||
|             <SecondaryNavigation |             <SecondaryNavigation | ||||||
|                 expanded={expanded.includes('configure')} |                 expanded={expanded.includes('configure')} | ||||||
|                 onChange={(expand) => { |                 onExpandChange={(expand) => { | ||||||
|                     changeExpanded('configure', expand); |                     changeExpanded('configure', expand); | ||||||
|                 }} |                 }} | ||||||
|                 mode={mode} |                 mode={mode} | ||||||
|                 routes={routes.mainNavRoutes} |                 title='Configure' | ||||||
|             > |             > | ||||||
|                 Configure |                 <SecondaryNavigationList | ||||||
|  |                     routes={routes.mainNavRoutes} | ||||||
|  |                     mode={mode} | ||||||
|  |                     onClick={setActiveItem} | ||||||
|  |                     activeItem={activeItem} | ||||||
|  |                 /> | ||||||
|             </SecondaryNavigation> |             </SecondaryNavigation> | ||||||
|             <SecondaryNavigation |             <SecondaryNavigation | ||||||
|                 expanded={expanded.includes('admin')} |                 expanded={expanded.includes('admin')} | ||||||
|                 onChange={(expand) => { |                 onExpandChange={(expand) => { | ||||||
|                     changeExpanded('admin', expand); |                     changeExpanded('admin', expand); | ||||||
|                 }} |                 }} | ||||||
|                 mode={mode} |                 mode={mode} | ||||||
|                 routes={routes.adminRoutes} |                 title='Admin' | ||||||
|             > |             > | ||||||
|                 Admin |                 <SecondaryNavigationList | ||||||
|  |                     routes={routes.adminRoutes} | ||||||
|  |                     mode={mode} | ||||||
|  |                     onClick={setActiveItem} | ||||||
|  |                     activeItem={activeItem} | ||||||
|  |                 /> | ||||||
|             </SecondaryNavigation> |             </SecondaryNavigation> | ||||||
|             <ShowHide |             <ShowHide | ||||||
|                 mode={mode} |                 mode={mode} | ||||||
|  | |||||||
| @ -0,0 +1,18 @@ | |||||||
|  | import { normalizeTopLevelPath } from './useInitialPathname'; | ||||||
|  | 
 | ||||||
|  | test('normalization test', () => { | ||||||
|  |     expect(normalizeTopLevelPath('/')).toBe('/projects'); | ||||||
|  |     expect(normalizeTopLevelPath('')).toBe('/projects'); | ||||||
|  |     expect(normalizeTopLevelPath('/admin')).toBe('/projects'); | ||||||
|  |     expect(normalizeTopLevelPath('/admin/test')).toBe('/admin/test'); | ||||||
|  |     expect(normalizeTopLevelPath('/projects')).toBe('/projects'); | ||||||
|  |     expect(normalizeTopLevelPath('/projects/default')).toBe('/projects'); | ||||||
|  |     expect(normalizeTopLevelPath('/projects/default/test')).toBe('/projects'); | ||||||
|  |     expect(normalizeTopLevelPath('/insights/default/test')).toBe('/insights'); | ||||||
|  |     expect(normalizeTopLevelPath('/admin/networks/test')).toBe( | ||||||
|  |         '/admin/networks', | ||||||
|  |     ); | ||||||
|  |     expect(normalizeTopLevelPath('/admin/networks/test/another')).toBe( | ||||||
|  |         '/admin/networks', | ||||||
|  |     ); | ||||||
|  | }); | ||||||
| @ -0,0 +1,24 @@ | |||||||
|  | import { useLocation } from 'react-router-dom'; | ||||||
|  | 
 | ||||||
|  | export const normalizeTopLevelPath = (pathname: string) => { | ||||||
|  |     const parts = pathname.split('/').filter((part) => part); | ||||||
|  | 
 | ||||||
|  |     const isEmptyPath = | ||||||
|  |         parts.length === 0 || (parts[0] === 'admin' && parts.length === 1); | ||||||
|  |     if (isEmptyPath) { | ||||||
|  |         return '/projects'; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     const isAdminPath = parts[0] === 'admin' && parts.length > 1; | ||||||
|  |     if (isAdminPath) { | ||||||
|  |         return `/${parts[0]}/${parts[1]}`; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     return `/${parts[0]}`; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export const useInitialPathname = () => { | ||||||
|  |     const { pathname, state } = useLocation(); | ||||||
|  | 
 | ||||||
|  |     return normalizeTopLevelPath(pathname); | ||||||
|  | }; | ||||||
		Loading…
	
		Reference in New Issue
	
	Block a user