diff --git a/frontend/src/component/layout/MainLayout/NavigationSidebar/IconRenderer.tsx b/frontend/src/component/layout/MainLayout/NavigationSidebar/IconRenderer.tsx index f87c1a751e..e7d26c1dbc 100644 --- a/frontend/src/component/layout/MainLayout/NavigationSidebar/IconRenderer.tsx +++ b/frontend/src/component/layout/MainLayout/NavigationSidebar/IconRenderer.tsx @@ -30,6 +30,7 @@ import { ReactComponent as ProjectIcon } from 'assets/icons/projectIconSmall.svg import type { FC } from 'react'; import { styled } from '@mui/material'; +// TODO: move to routes const icons: Record = { '/applications': ApplicationsIcon, '/context': ContextFieldsIcon, diff --git a/frontend/src/component/layout/MainLayout/NavigationSidebar/ListItems.tsx b/frontend/src/component/layout/MainLayout/NavigationSidebar/ListItems.tsx index c11ac7cb73..5375b7219e 100644 --- a/frontend/src/component/layout/MainLayout/NavigationSidebar/ListItems.tsx +++ b/frontend/src/component/layout/MainLayout/NavigationSidebar/ListItems.tsx @@ -14,7 +14,7 @@ import type { Theme } from '@mui/material/styles/createTheme'; const listItemButtonStyle = (theme: Theme) => ({ borderRadius: theme.spacing(0.5), borderLeft: `${theme.spacing(0.5)} solid transparent`, - '&:hover': { + '&.Mui-selected': { borderLeft: `${theme.spacing(0.5)} solid ${theme.palette.primary.main}`, }, }); @@ -23,8 +23,9 @@ export const FullListItem: FC<{ href: string; text: string; badge?: ReactNode; - onClick?: () => void; -}> = ({ href, text, badge, onClick, children }) => { + onClick: () => void; + selected?: boolean; +}> = ({ href, text, badge, onClick, selected, children }) => { return ( ({ minWidth: theme.spacing(4) })}> {children} @@ -82,18 +84,20 @@ export const SignOutItem = () => { ); }; -export const MiniListItem: FC<{ href: string; text: string }> = ({ - href, - text, - children, -}) => { +export const MiniListItem: FC<{ + href: string; + text: string; + selected?: boolean; + onClick: () => void; +}> = ({ href, text, selected, onClick, children }) => { return ( - + { return showBadge; }; -export const ConfigureNavigationList: FC<{ +export const SecondaryNavigationList: FC<{ routes: INavigationMenuItem[]; mode: NavigationMode; - onClick?: () => void; -}> = ({ routes, mode, onClick }) => { - const DynamicListItem = mode === 'mini' ? MiniListItem : FullListItem; - - return ( - - {routes.map((route) => ( - - - - ))} - - ); -}; -export const AdminNavigationList: FC<{ - routes: INavigationMenuItem[]; - mode: NavigationMode; - badge?: ReactNode; - onClick?: () => void; -}> = ({ routes, mode, onClick, badge }) => { + onClick: (activeItem: string) => void; + activeItem?: string; +}> = ({ routes, mode, onClick, activeItem }) => { const showBadge = useShowBadge(); const DynamicListItem = mode === 'mini' ? MiniListItem : FullListItem; @@ -84,9 +62,10 @@ export const AdminNavigationList: FC<{ {routes.map((route) => ( onClick(route.path)} href={route.path} text={route.title} + selected={activeItem === route.path} badge={ showBadge(route?.menu?.mode) ? ( @@ -121,26 +100,43 @@ export const OtherLinksList = () => { export const PrimaryNavigationList: FC<{ mode: NavigationMode; - onClick?: () => void; -}> = ({ mode, onClick }) => { + onClick: (activeItem: string) => void; + activeItem?: string; +}> = ({ mode, onClick, activeItem }) => { const DynamicListItem = mode === 'mini' ? MiniListItem : FullListItem; return ( - + onClick('/projects')} + selected={activeItem === '/projects'} + > - + onClick('/search')} + selected={activeItem === '/search'} + > onClick('/playground')} + selected={activeItem === '/playground'} > - + onClick('/insights')} + selected={activeItem === '/insights'} + > @@ -163,23 +159,21 @@ const AccordionHeader: FC = ({ children }) => { export const SecondaryNavigation: FC<{ expanded: boolean; - onChange: (expanded: boolean) => void; + onExpandChange: (expanded: boolean) => void; mode: NavigationMode; - routes: INavigationMenuItem[]; -}> = ({ mode, expanded, onChange, routes, children }) => { + title: string; +}> = ({ mode, expanded, onExpandChange, title, children }) => { return ( { - onChange(expand); + onExpandChange(expand); }} > - {mode === 'full' && {children}} - - - + {mode === 'full' && {title}} + {children} ); }; diff --git a/frontend/src/component/layout/MainLayout/NavigationSidebar/NavigationSidebar.test.tsx b/frontend/src/component/layout/MainLayout/NavigationSidebar/NavigationSidebar.test.tsx index b998bb839c..4a65500afa 100644 --- a/frontend/src/component/layout/MainLayout/NavigationSidebar/NavigationSidebar.test.tsx +++ b/frontend/src/component/layout/MainLayout/NavigationSidebar/NavigationSidebar.test.tsx @@ -2,6 +2,8 @@ import { render } from 'utils/testRenderer'; import { NavigationSidebar } from './NavigationSidebar'; import { screen, fireEvent, waitFor } from '@testing-library/react'; import { createLocalStorage } from 'utils/createLocalStorage'; +import { Route, Routes } from 'react-router-dom'; +import { listItemButtonClasses as classes } from '@mui/material/ListItemButton'; beforeEach(() => { window.localStorage.clear(); @@ -54,3 +56,16 @@ test('persist navigation mode and expansion selection in storage', async () => { expect(expanded).toEqual(['admin']); }); }); + +test('select active item', async () => { + render( + + } /> + , + { route: '/search' }, + ); + + const links = screen.getAllByRole('link'); + + expect(links[1]).toHaveClass(classes.selected); +}); diff --git a/frontend/src/component/layout/MainLayout/NavigationSidebar/NavigationSidebar.tsx b/frontend/src/component/layout/MainLayout/NavigationSidebar/NavigationSidebar.tsx index de78754d96..96d8811c00 100644 --- a/frontend/src/component/layout/MainLayout/NavigationSidebar/NavigationSidebar.tsx +++ b/frontend/src/component/layout/MainLayout/NavigationSidebar/NavigationSidebar.tsx @@ -1,16 +1,16 @@ import { Box, styled } from '@mui/material'; -import type { FC } from 'react'; +import { type FC, useState } from 'react'; import { useNavigationMode } from './useNavigationMode'; import { ShowHide } from './ShowHide'; import { useRoutes } from './useRoutes'; import { useExpanded } from './useExpanded'; import { - AdminNavigationList, - ConfigureNavigationList, OtherLinksList, PrimaryNavigationList, SecondaryNavigation, + SecondaryNavigationList, } from './NavigationList'; +import { useInitialPathname } from './useInitialPathname'; export const MobileNavigationSidebar: FC<{ onClick: () => void }> = ({ onClick, @@ -20,7 +20,7 @@ export const MobileNavigationSidebar: FC<{ onClick: () => void }> = ({ return ( <> - { const [mode, setMode] = useNavigationMode(); const [expanded, changeExpanded] = useExpanded<'configure' | 'admin'>(); + const initialPathname = useInitialPathname(); + + const [activeItem, setActiveItem] = useState(initialPathname); return ( - + { + onExpandChange={(expand) => { changeExpanded('configure', expand); }} mode={mode} - routes={routes.mainNavRoutes} + title='Configure' > - Configure + { + onExpandChange={(expand) => { changeExpanded('admin', expand); }} mode={mode} - routes={routes.adminRoutes} + title='Admin' > - Admin + { + 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', + ); +}); diff --git a/frontend/src/component/layout/MainLayout/NavigationSidebar/useInitialPathname.ts b/frontend/src/component/layout/MainLayout/NavigationSidebar/useInitialPathname.ts new file mode 100644 index 0000000000..6ba8c8711b --- /dev/null +++ b/frontend/src/component/layout/MainLayout/NavigationSidebar/useInitialPathname.ts @@ -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); +};