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