1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-25 00:07:47 +01:00

feat: currently selected nav item (#7182)

This commit is contained in:
Mateusz Kwasniewski 2024-05-28 12:03:52 +02:00 committed by GitHub
parent c8fa7e477a
commit 5a9b015022
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 135 additions and 62 deletions

View File

@ -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,

View File

@ -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

View File

@ -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>
); );
}; };

View File

@ -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);
});

View File

@ -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}

View File

@ -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',
);
});

View File

@ -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);
};