1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-07-26 13:48:33 +02:00

feat: update configuration menu (#10041)

Updated "Configure" navigation, with all interactions including expanding/collapsing size of the menu.
This commit is contained in:
Tymoteusz Czech 2025-05-29 13:26:26 +02:00 committed by GitHub
parent e67e60a363
commit 016d82a797
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 338 additions and 170 deletions

View File

@ -0,0 +1,77 @@
import { type FC, useEffect, useState } from 'react';
import { MenuListAccordion } from './ListItems.tsx';
import { useExpanded } from './useExpanded.ts';
import type { NavigationMode } from './NavigationMode.tsx';
import { IconRenderer } from './IconRenderer.tsx';
import { ConfigurationNavigationList } from './ConfigurationNavigationList.tsx';
import { useRoutes } from './useRoutes.ts';
type ConfigurationAccordionProps = {
mode: NavigationMode;
setMode;
activeItem?: string;
onClick?: () => void;
};
export const ConfigurationAccordion: FC<ConfigurationAccordionProps> = ({
mode,
setMode,
activeItem,
onClick,
}) => {
const [expanded, changeExpanded] = useExpanded<'configure'>();
const [temporarilyExpanded, setTemporarilyExpanded] = useState(false);
const { routes } = useRoutes();
const subRoutes = routes.mainNavRoutes;
const hasActiveItem = Boolean(
activeItem && subRoutes.some((route) => route.path === activeItem),
);
useEffect(() => {
if (mode === 'mini') {
setTemporarilyExpanded(false);
}
}, [mode]);
const onExpandChange = () => {
changeExpanded('configure', !expanded.includes('configure'));
if (temporarilyExpanded) {
setTemporarilyExpanded(false);
setMode('mini');
}
if (mode === 'mini') {
setTemporarilyExpanded(true);
setMode('full');
}
};
const onItemClick = () => {
if (temporarilyExpanded) {
setTemporarilyExpanded(false);
setMode('mini');
}
onClick?.();
};
return (
<MenuListAccordion
title='Configure'
expanded={
(expanded.includes('configure') || temporarilyExpanded) &&
mode === 'full'
}
onExpandChange={onExpandChange}
mode={mode}
icon={<IconRenderer path='Configure' />}
active={hasActiveItem}
>
<ConfigurationNavigationList
routes={subRoutes}
mode={mode}
onClick={onItemClick}
activeItem={activeItem}
/>
</MenuListAccordion>
);
};

View File

@ -1,6 +1,6 @@
import type React from 'react';
import type { FC } from 'react';
import type { NavigationMode } from './NavigationMode.tsx';
import type { NavigationMode } from './NavigationMode.ts';
import { Typography } from '@mui/material';
import Accordion from '@mui/material/Accordion';
import AccordionDetails from '@mui/material/AccordionDetails';
@ -21,7 +21,7 @@ const AccordionHeader: FC<{ children?: React.ReactNode }> = ({ children }) => {
);
};
export const SecondaryNavigation: FC<{
export const ConfigurationNavigation: FC<{
expanded: boolean;
onExpandChange: (expanded: boolean) => void;
mode: NavigationMode;

View File

@ -1,7 +1,7 @@
import type { FC } from 'react';
import type { INavigationMenuItem } from 'interfaces/route';
import type { NavigationMode } from './NavigationMode.tsx';
import { FullListItem, MiniListItem } from './ListItems.tsx';
import type { NavigationMode } from './NavigationMode.ts';
import { MenuListItem } from './ListItems.tsx';
import { List } from '@mui/material';
import { IconRenderer } from './IconRenderer.tsx';
import { useUiFlag } from 'hooks/useUiFlag.ts';
@ -9,20 +9,19 @@ import StopRoundedIcon from '@mui/icons-material/StopRounded';
import { useShowBadge } from 'component/layout/components/EnterprisePlanBadge/useShowBadge';
import { EnterprisePlanBadge } from 'component/layout/components/EnterprisePlanBadge/EnterprisePlanBadge';
export const SecondaryNavigationList: FC<{
export const ConfigurationNavigationList: FC<{
routes: INavigationMenuItem[];
mode: NavigationMode;
onClick: (activeItem: string) => void;
activeItem?: string;
}> = ({ routes, mode, onClick, activeItem }) => {
const showBadge = useShowBadge();
const DynamicListItem = mode === 'mini' ? MiniListItem : FullListItem;
const sideMenuCleanup = useUiFlag('sideMenuCleanup');
return (
<List>
{routes.map((route) => (
<DynamicListItem
<MenuListItem
key={route.title}
onClick={() => onClick(route.path)}
href={route.path}
@ -33,13 +32,16 @@ export const SecondaryNavigationList: FC<{
<EnterprisePlanBadge />
) : null
}
>
{sideMenuCleanup ? (
<StopRoundedIcon fontSize='small' color='primary' />
) : (
<IconRenderer path={route.path} />
)}
</DynamicListItem>
mode={mode}
icon={
sideMenuCleanup ? (
<StopRoundedIcon fontSize='small' color='primary' />
) : (
<IconRenderer path={route.path} />
)
}
secondary={sideMenuCleanup}
/>
))}
</List>
);

View File

@ -1,6 +1,8 @@
import type React from 'react';
import type { FC, ReactNode } from 'react';
import {
Accordion,
AccordionDetails,
AccordionSummary,
ListItem,
ListItemButton,
ListItemIcon,
@ -13,6 +15,8 @@ import { Link } from 'react-router-dom';
import { basePath } from 'utils/formatPath';
import SignOutIcon from '@mui/icons-material/ExitToApp';
import type { Theme } from '@mui/material/styles/createTheme';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import type { NavigationMode } from './NavigationMode.tsx';
const listItemButtonStyle = (theme: Theme) => ({
borderRadius: theme.spacing(0.5),
@ -22,12 +26,17 @@ const listItemButtonStyle = (theme: Theme) => ({
},
});
const CappedText = styled(Typography)({
const CappedText = styled(Typography)<{
bold?: boolean;
}>(({ theme, bold }) => ({
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
width: '100%',
});
fontWeight: bold
? theme.typography.fontWeightBold
: theme.typography.fontWeightRegular,
}));
const StyledListItemIcon = styled(ListItemIcon)(({ theme }) => ({
minWidth: theme.spacing(4),
@ -38,37 +47,10 @@ const StyledListItemText = styled(ListItemText)(({ theme }) => ({
margin: 0,
}));
export const FullListItem: FC<{
href: string;
text: string;
badge?: ReactNode;
onClick: () => void;
selected?: boolean;
children?: React.ReactNode;
}> = ({ href, text, badge, onClick, selected, children }) => {
return (
<ListItem disablePadding onClick={onClick}>
<ListItemButton
dense={true}
component={Link}
to={href}
sx={listItemButtonStyle}
selected={selected}
>
<StyledListItemIcon>{children}</StyledListItemIcon>
<StyledListItemText>
<CappedText>{text}</CappedText>
</StyledListItemText>
{badge}
</ListItemButton>
</ListItem>
);
};
export const ExternalFullListItem: FC<{
href: string;
text: string;
children?: React.ReactNode;
children?: ReactNode;
}> = ({ href, text, children }) => {
return (
<ListItem disablePadding>
@ -88,6 +70,7 @@ export const ExternalFullListItem: FC<{
</ListItem>
);
};
export const SignOutItem = () => {
return (
<form method='POST' action={`${basePath}/logout`}>
@ -110,26 +93,139 @@ export const SignOutItem = () => {
);
};
export const MiniListItem: FC<{
export const MenuListItem: FC<{
href: string;
text: string;
selected?: boolean;
badge?: ReactNode;
onClick: () => void;
children?: React.ReactNode;
}> = ({ href, text, selected, onClick, children }) => {
icon?: ReactNode;
children?: ReactNode;
mode?: NavigationMode;
secondary?: boolean;
}> = ({
href,
text,
selected,
onClick,
icon,
mode = 'full',
badge,
children,
secondary,
}) => {
return (
<ListItem disablePadding onClick={onClick}>
<ListItemButton
dense={true}
dense
component={Link}
to={href}
sx={listItemButtonStyle}
sx={(theme) => ({
...listItemButtonStyle(theme),
...(mode === 'full' &&
secondary && {
paddingLeft: theme.spacing(4),
}),
})}
selected={selected}
>
<Tooltip title={text} placement='right'>
<StyledListItemIcon>{children}</StyledListItemIcon>
</Tooltip>
{mode === 'mini' ? (
<Tooltip title={text} placement='right'>
<StyledListItemIcon>{icon}</StyledListItemIcon>
</Tooltip>
) : (
<>
<StyledListItemIcon>{icon}</StyledListItemIcon>
<StyledListItemText>
<CappedText>{text}</CappedText>
</StyledListItemText>
{badge}
</>
)}
</ListItemButton>
{children}
</ListItem>
);
};
const StyledAccordion = styled(Accordion)(({ theme }) => ({
flexGrow: 1,
'.MuiAccordionSummary-root': {
minHeight: 'auto',
borderRadius: theme.spacing(1),
borderLeft: `${theme.spacing(0.5)} solid transparent`,
margin: 0,
paddingTop: theme.spacing(0.5),
paddingBottom: theme.spacing(0.5),
'.MuiAccordionSummary-content': { margin: 0 },
'&>.MuiAccordionSummary-content.MuiAccordionSummary-content': {
margin: '0',
alignItems: 'center',
},
},
'.MuiAccordionSummary-content': {
margin: 0,
display: 'flex',
alignItems: 'center',
},
'.MuiAccordionSummary-expandIconWrapper': {
position: 'absolute',
right: theme.spacing(1),
},
}));
export const MenuListAccordion: FC<{
title: string;
expanded: boolean;
onExpandChange: (expanded: boolean) => void;
children?: ReactNode;
mode?: NavigationMode;
icon?: ReactNode;
active?: boolean;
}> = ({ title, expanded, mode, icon, onExpandChange, children, active }) => {
return (
<ListItem disablePadding sx={{ display: 'flex' }}>
<StyledAccordion
disableGutters={true}
sx={{
boxShadow: 'none',
'&:before': {
display: 'none',
},
}}
expanded={expanded}
onChange={(_, expand) => {
onExpandChange(expand);
}}
>
<AccordionSummary
sx={{ padding: 0 }}
expandIcon={mode === 'full' ? <ExpandMoreIcon /> : null}
>
<ListItemButton
dense
sx={listItemButtonStyle}
selected={active && mode === 'mini'}
disableRipple
>
{mode === 'mini' ? (
<Tooltip title={title} placement='right'>
<StyledListItemIcon>{icon}</StyledListItemIcon>
</Tooltip>
) : (
<>
<StyledListItemIcon>{icon}</StyledListItemIcon>
<StyledListItemText>
<CappedText bold={active}>
{title}
</CappedText>
</StyledListItemText>
</>
)}
</ListItemButton>
</AccordionSummary>
<AccordionDetails sx={{ p: 0 }}>{children}</AccordionDetails>
</StyledAccordion>
</ListItem>
);
};

View File

@ -5,24 +5,32 @@ import {
OtherLinksList,
} from './NavigationList.tsx';
import type { NewInUnleash } from './NewInUnleash/NewInUnleash.tsx';
import { SecondaryNavigationList } from './SecondaryNavigationList.tsx';
import { ConfigurationNavigationList } from './ConfigurationNavigationList.tsx';
import { useRoutes } from './useRoutes.ts';
import { useUiFlag } from 'hooks/useUiFlag.ts';
export const MobileNavigationSidebar: FC<{
onClick: () => void;
NewInUnleash?: typeof NewInUnleash;
}> = ({ onClick, NewInUnleash }) => {
const { routes } = useRoutes();
const sideMenuCleanup = useUiFlag('sideMenuCleanup');
return (
<>
{NewInUnleash ? <NewInUnleash /> : null}
<PrimaryNavigationList mode='full' onClick={onClick} />
<SecondaryNavigationList
routes={routes.mainNavRoutes}
<PrimaryNavigationList
mode='full'
onClick={onClick}
setMode={() => {}}
/>
{!sideMenuCleanup ? (
<ConfigurationNavigationList
routes={routes.mainNavRoutes}
mode='full'
onClick={onClick}
/>
) : null}
<AdminSettingsLink mode={'full'} onClick={onClick} />
<OtherLinksList />
</>

View File

@ -1,10 +1,9 @@
import type { FC } from 'react';
import type { ComponentProps, FC } from 'react';
import type { INavigationMenuItem } from 'interfaces/route';
import type { NavigationMode } from './NavigationMode.tsx';
import {
ExternalFullListItem,
FullListItem,
MiniListItem,
MenuListItem,
SignOutItem,
} from './ListItems.tsx';
import { Box, List, Typography } from '@mui/material';
@ -16,6 +15,7 @@ import useProjectOverview from 'hooks/api/getters/useProjectOverview/useProjectO
import { useNewAdminMenu } from 'hooks/useNewAdminMenu';
import { AdminMenuNavigation } from '../AdminMenu/AdminNavigationItems.tsx';
import { useUiFlag } from 'hooks/useUiFlag.ts';
import { ConfigurationAccordion } from './ConfigurationAccordion.tsx';
export const OtherLinksList = () => {
const { uiConfig } = useUiConfig();
@ -36,105 +36,92 @@ export const OtherLinksList = () => {
);
};
/**
* @deprecated remove with `sideMenuCleanup` flag
*/
export const RecentProjectsList: FC<{
projectId: string;
projectName: string;
mode: NavigationMode;
onClick: () => void;
}> = ({ projectId, projectName, mode, onClick }) => {
const DynamicListItem = mode === 'mini' ? MiniListItem : FullListItem;
return (
<List>
<DynamicListItem
href={`/projects/${projectId}`}
text={projectName}
onClick={onClick}
selected={false}
>
<ProjectIcon />
</DynamicListItem>
</List>
);
};
}> = ({ projectId, projectName, mode, onClick }) => (
<List>
<MenuListItem
href={`/projects/${projectId}`}
text={projectName}
onClick={onClick}
selected={false}
mode={mode}
icon={<ProjectIcon />}
/>
</List>
);
/**
* @deprecated remove with `sideMenuCleanup` flag
*/
export const RecentFlagsList: FC<{
flags: { featureId: string; projectId: string }[];
mode: NavigationMode;
onClick: () => void;
}> = ({ flags, mode, onClick }) => {
const DynamicListItem = mode === 'mini' ? MiniListItem : FullListItem;
return (
<List>
{flags.map((flag) => (
<DynamicListItem
href={`/projects/${flag.projectId}/features/${flag.featureId}`}
text={flag.featureId}
onClick={onClick}
selected={false}
key={flag.featureId}
>
<FlagIcon />
</DynamicListItem>
))}
</List>
);
};
}> = ({ flags, mode, onClick }) => (
<List>
{flags.map((flag) => (
<MenuListItem
href={`/projects/${flag.projectId}/features/${flag.featureId}`}
text={flag.featureId}
onClick={onClick}
selected={false}
key={flag.featureId}
mode={mode}
icon={<FlagIcon />}
/>
))}
</List>
);
export const PrimaryNavigationList: FC<{
mode: NavigationMode;
setMode: (mode: NavigationMode) => void;
onClick: (activeItem: string) => void;
activeItem?: string;
}> = ({ mode, onClick, activeItem }) => {
const DynamicListItem = mode === 'mini' ? MiniListItem : FullListItem;
}> = ({ mode, setMode, onClick, activeItem }) => {
const PrimaryListItem = ({
href,
text,
}: Pick<ComponentProps<typeof MenuListItem>, 'href' | 'text'>) => (
<MenuListItem
href={href}
text={text}
icon={<IconRenderer path={href} />}
onClick={() => onClick(href)}
selected={activeItem === href}
mode={mode}
/>
);
const { isOss } = useUiConfig();
const sideMenuCleanup = useUiFlag('sideMenuCleanup');
return (
<List>
<DynamicListItem
href='/personal'
text='Dashboard'
onClick={() => onClick('/personal')}
selected={activeItem === '/personal'}
>
<IconRenderer path='/personal' />
</DynamicListItem>
<DynamicListItem
href='/projects'
text='Projects'
onClick={() => onClick('/projects')}
selected={activeItem === '/projects'}
>
<IconRenderer path='/projects' />
</DynamicListItem>
<DynamicListItem
href='/search'
text='Flags overview'
onClick={() => onClick('/search')}
selected={activeItem === '/search'}
>
<IconRenderer path='/search' />
</DynamicListItem>
<DynamicListItem
href='/playground'
text='Playground'
onClick={() => onClick('/playground')}
selected={activeItem === '/playground'}
>
<IconRenderer path='/playground' />
</DynamicListItem>
<PrimaryListItem href='/personal' text='Dashboard' />
<PrimaryListItem href='/projects' text='Projects' />
<PrimaryListItem href='/search' text='Flags overview' />
<PrimaryListItem href='/playground' text='Playground' />
{!isOss() ? (
<DynamicListItem
<PrimaryListItem
href='/insights'
text={sideMenuCleanup ? 'Analytics' : 'Insights'}
onClick={() => onClick('/insights')}
selected={activeItem === '/insights'}
>
<IconRenderer path='/insights' />
</DynamicListItem>
/>
) : null}
{sideMenuCleanup ? (
<ConfigurationAccordion
mode={mode}
setMode={setMode}
activeItem={activeItem}
onClick={() => onClick('configure')}
/>
) : null}
</List>
);
@ -173,22 +160,19 @@ export const AdminSettingsNavigation: FC<{
export const AdminSettingsLink: FC<{
mode: NavigationMode;
onClick: (activeItem: string) => void;
}> = ({ mode, onClick }) => {
const DynamicListItem = mode === 'mini' ? MiniListItem : FullListItem;
return (
<Box>
<List>
<DynamicListItem
href='/admin'
text='Admin settings'
onClick={() => onClick('/admin')}
>
<IconRenderer path='/admin' />
</DynamicListItem>
</List>
</Box>
);
};
}> = ({ mode, onClick }) => (
<Box>
<List>
<MenuListItem
href='/admin'
text='Admin settings'
onClick={() => onClick('/admin')}
mode={mode}
icon={<IconRenderer path='/admin' />}
/>
</List>
</Box>
);
export const RecentProjectsNavigation: FC<{
mode: NavigationMode;

View File

@ -10,9 +10,8 @@ import {
RecentProjectsNavigation,
AdminSettingsNavigation,
} from './NavigationList.tsx';
import { SecondaryNavigationList } from './SecondaryNavigationList.tsx';
import { SecondaryNavigation } from './SecondaryNavigation.tsx';
import { FullListItem, MiniListItem } from './ListItems.tsx';
import { ConfigurationNavigationList } from './ConfigurationNavigationList.tsx';
import { ConfigurationNavigation } from './ConfigurationNavigation.tsx';
import { useInitialPathname } from './useInitialPathname.ts';
import { useLastViewedProject } from 'hooks/useLastViewedProject';
import { useLastViewedFlags } from 'hooks/useLastViewedFlags';
@ -105,7 +104,6 @@ export const NavigationSidebar: FC<{ NewInUnleash?: typeof NewInUnleash }> = ({
const { lastViewed: lastViewedFlags } = useLastViewedFlags();
const showRecentFlags =
!sideMenuCleanup && mode === 'full' && lastViewedFlags.length > 0;
const DynamicListItem = mode === 'mini' ? MiniListItem : FullListItem;
useEffect(() => {
setActiveItem(initialPathname);
@ -155,24 +153,27 @@ export const NavigationSidebar: FC<{ NewInUnleash?: typeof NewInUnleash }> = ({
<>
<PrimaryNavigationList
mode={mode}
setMode={setMode}
onClick={setActiveItem}
activeItem={activeItem}
/>
<SecondaryNavigation
expanded={expanded.includes('configure')}
onExpandChange={(expand) => {
changeExpanded('configure', expand);
}}
mode={mode}
title='Configure'
>
<SecondaryNavigationList
routes={routes.mainNavRoutes}
{!sideMenuCleanup ? (
<ConfigurationNavigation
expanded={expanded.includes('configure')}
onExpandChange={(expand) => {
changeExpanded('configure', expand);
}}
mode={mode}
onClick={setActiveItem}
activeItem={activeItem}
/>
</SecondaryNavigation>
title='Configure'
>
<ConfigurationNavigationList
routes={routes.mainNavRoutes}
mode={mode}
onClick={setActiveItem}
activeItem={activeItem}
/>
</ConfigurationNavigation>
) : null}
<AdminSettingsNavigation
onClick={setActiveItem}