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 { styled } from '@mui/material';
// TODO: move to routes
const icons: Record<string, typeof SvgIcon> = {
'/applications': ApplicationsIcon,
'/context': ContextFieldsIcon,

View File

@ -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 (
<ListItem disablePadding onClick={onClick}>
<ListItemButton
@ -32,6 +33,7 @@ export const FullListItem: FC<{
component={Link}
to={href}
sx={listItemButtonStyle}
selected={selected}
>
<ListItemIcon sx={(theme) => ({ 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 (
<ListItem disablePadding>
<ListItem disablePadding onClick={onClick}>
<ListItemButton
dense={true}
component={Link}
to={href}
sx={listItemButtonStyle}
selected={selected}
>
<Tooltip title={text} placement='right'>
<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 { NavigationMode } from './NavigationMode';
import {
@ -48,34 +48,12 @@ const useShowBadge = () => {
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 (
<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 }) => {
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) => (
<DynamicListItem
key={route.title}
onClick={onClick}
onClick={() => onClick(route.path)}
href={route.path}
text={route.title}
selected={activeItem === route.path}
badge={
showBadge(route?.menu?.mode) ? (
<EnterprisePlanBadge />
@ -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 (
<List>
<DynamicListItem href='/projects' text='Projects' onClick={onClick}>
<DynamicListItem
href='/projects'
text='Projects'
onClick={() => onClick('/projects')}
selected={activeItem === '/projects'}
>
<StyledProjectIcon />
</DynamicListItem>
<DynamicListItem href='/search' text='Search' onClick={onClick}>
<DynamicListItem
href='/search'
text='Search'
onClick={() => onClick('/search')}
selected={activeItem === '/search'}
>
<SearchIcon />
</DynamicListItem>
<DynamicListItem
href='/playground'
text='Playground'
onClick={onClick}
onClick={() => onClick('/playground')}
selected={activeItem === '/playground'}
>
<PlaygroundIcon />
</DynamicListItem>
<DynamicListItem href='/insights' text='Insights' onClick={onClick}>
<DynamicListItem
href='/insights'
text='Insights'
onClick={() => onClick('/insights')}
selected={activeItem === '/insights'}
>
<InsightsIcon />
</DynamicListItem>
</List>
@ -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 (
<Accordion
disableGutters={true}
sx={{ boxShadow: 'none' }}
expanded={expanded}
onChange={(_, expand) => {
onChange(expand);
onExpandChange(expand);
}}
>
{mode === 'full' && <AccordionHeader>{children}</AccordionHeader>}
<AccordionDetails sx={{ p: 0 }}>
<ConfigureNavigationList routes={routes} mode={mode} />
</AccordionDetails>
{mode === 'full' && <AccordionHeader>{title}</AccordionHeader>}
<AccordionDetails sx={{ p: 0 }}>{children}</AccordionDetails>
</Accordion>
);
};

View File

@ -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(
<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 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 (
<>
<PrimaryNavigationList mode='full' onClick={onClick} />
<ConfigureNavigationList
<SecondaryNavigationList
routes={routes.mainNavRoutes}
mode='full'
onClick={onClick}
@ -45,29 +45,46 @@ export const NavigationSidebar = () => {
const [mode, setMode] = useNavigationMode();
const [expanded, changeExpanded] = useExpanded<'configure' | 'admin'>();
const initialPathname = useInitialPathname();
const [activeItem, setActiveItem] = useState(initialPathname);
return (
<StyledBox>
<PrimaryNavigationList mode={mode} />
<PrimaryNavigationList
mode={mode}
onClick={setActiveItem}
activeItem={activeItem}
/>
<SecondaryNavigation
expanded={expanded.includes('configure')}
onChange={(expand) => {
onExpandChange={(expand) => {
changeExpanded('configure', expand);
}}
mode={mode}
routes={routes.mainNavRoutes}
title='Configure'
>
Configure
<SecondaryNavigationList
routes={routes.mainNavRoutes}
mode={mode}
onClick={setActiveItem}
activeItem={activeItem}
/>
</SecondaryNavigation>
<SecondaryNavigation
expanded={expanded.includes('admin')}
onChange={(expand) => {
onExpandChange={(expand) => {
changeExpanded('admin', expand);
}}
mode={mode}
routes={routes.adminRoutes}
title='Admin'
>
Admin
<SecondaryNavigationList
routes={routes.adminRoutes}
mode={mode}
onClick={setActiveItem}
activeItem={activeItem}
/>
</SecondaryNavigation>
<ShowHide
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);
};