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