mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	chore: move project stats (#6602)
Add stats to project insights. Will follow up with UI enhancements in a later iteration. <img width="1408" alt="Skjermbilde 2024-03-19 kl 13 19 18" src="https://github.com/Unleash/unleash/assets/16081982/f4726635-99eb-4f27-8c31-5c6d402f2ceb">
This commit is contained in:
		
							parent
							
								
									aeb6291863
								
							
						
					
					
						commit
						bb847e2935
					
				| @ -3,6 +3,7 @@ import { ChangeRequests } from './ChangeRequests/ChangeRequests'; | ||||
| import { LeadTimeForChanges } from './LeadTimeForChanges/LeadTimeForChanges'; | ||||
| import { ProjectHealth } from './ProjectHealth/ProjectHealth'; | ||||
| import { FlagTypesUsed } from './FlagTypesUsed/FlagTypesUsed'; | ||||
| import { ProjectInsightsStats } from './ProjectInsightsStats/ProjectInsightsStats'; | ||||
| 
 | ||||
| const Container = styled(Box)(({ theme }) => ({ | ||||
|     backgroundColor: theme.palette.background.paper, | ||||
| @ -16,7 +17,7 @@ const Grid = styled(Box)(({ theme }) => ({ | ||||
|     gridTemplateColumns: 'repeat(10, 1fr)', | ||||
| })); | ||||
| 
 | ||||
| const FullWidthContainer = styled(Container)(() => ({ | ||||
| const FullWidthContainer = styled(Box)(() => ({ | ||||
|     gridColumn: '1 / -1', | ||||
| })); | ||||
| 
 | ||||
| @ -32,12 +33,24 @@ const NarrowContainer = styled(Container)(() => ({ | ||||
|     gridColumn: 'span 2', | ||||
| })); | ||||
| 
 | ||||
| const statsData = { | ||||
|     stats: { | ||||
|         archivedCurrentWindow: 5, | ||||
|         archivedPastWindow: 3, | ||||
|         avgTimeToProdCurrentWindow: 2.5, | ||||
|         createdCurrentWindow: 7, | ||||
|         createdPastWindow: 4, | ||||
|         projectActivityCurrentWindow: 10, | ||||
|         projectActivityPastWindow: 8, | ||||
|         projectMembersAddedCurrentWindow: 2, | ||||
|     }, | ||||
| }; | ||||
| 
 | ||||
| export const ProjectInsights = () => { | ||||
|     return ( | ||||
|         <Grid> | ||||
|             <FullWidthContainer> | ||||
|                 Total changes / avg time to production / feature flags /stale | ||||
|                 flags | ||||
|                 <ProjectInsightsStats {...statsData} /> | ||||
|             </FullWidthContainer> | ||||
|             <MediumWideContainer> | ||||
|                 <ProjectHealth /> | ||||
|  | ||||
| @ -0,0 +1,82 @@ | ||||
| import { type FC, useState } from 'react'; | ||||
| import Close from '@mui/icons-material/Close'; | ||||
| import HelpOutline from '@mui/icons-material/HelpOutline'; | ||||
| import { | ||||
|     Box, | ||||
|     IconButton, | ||||
|     Popper, | ||||
|     Paper, | ||||
|     ClickAwayListener, | ||||
|     styled, | ||||
| } from '@mui/material'; | ||||
| import { Feedback } from 'component/common/Feedback/Feedback'; | ||||
| 
 | ||||
| interface IHelpPopperProps { | ||||
|     id: string; | ||||
| } | ||||
| 
 | ||||
| const StyledPaper = styled(Paper)(({ theme }) => ({ | ||||
|     padding: theme.spacing(3, 3), | ||||
|     maxWidth: '350px', | ||||
|     borderRadius: `${theme.shape.borderRadiusMedium}px`, | ||||
|     border: `1px solid ${theme.palette.neutral.border}`, | ||||
|     fontSize: theme.typography.body2.fontSize, | ||||
| })); | ||||
| 
 | ||||
| export const HelpPopper: FC<IHelpPopperProps> = ({ children, id }) => { | ||||
|     const [anchor, setAnchorEl] = useState<null | Element>(null); | ||||
| 
 | ||||
|     const onOpen = (event: React.FormEvent<HTMLButtonElement>) => | ||||
|         setAnchorEl(event.currentTarget); | ||||
| 
 | ||||
|     const onClose = () => setAnchorEl(null); | ||||
| 
 | ||||
|     const open = Boolean(anchor); | ||||
| 
 | ||||
|     return ( | ||||
|         <Box | ||||
|             sx={{ | ||||
|                 position: 'absolute', | ||||
|                 top: (theme) => theme.spacing(0.5), | ||||
|                 right: (theme) => theme.spacing(0.5), | ||||
|             }} | ||||
|         > | ||||
|             <IconButton onClick={onOpen} aria-describedby={id} size='small'> | ||||
|                 <HelpOutline | ||||
|                     sx={{ | ||||
|                         fontSize: (theme) => theme.typography.body1.fontSize, | ||||
|                     }} | ||||
|                 /> | ||||
|             </IconButton> | ||||
| 
 | ||||
|             <Popper | ||||
|                 id={id} | ||||
|                 open={open} | ||||
|                 anchorEl={anchor} | ||||
|                 sx={(theme) => ({ zIndex: theme.zIndex.tooltip })} | ||||
|             > | ||||
|                 <ClickAwayListener onClickAway={onClose}> | ||||
|                     <StyledPaper elevation={3}> | ||||
|                         <IconButton | ||||
|                             onClick={onClose} | ||||
|                             sx={{ position: 'absolute', right: 4, top: 4 }} | ||||
|                         > | ||||
|                             <Close | ||||
|                                 sx={{ | ||||
|                                     fontSize: (theme) => | ||||
|                                         theme.typography.body1.fontSize, | ||||
|                                 }} | ||||
|                             /> | ||||
|                         </IconButton> | ||||
|                         {children} | ||||
|                         <Feedback | ||||
|                             id={id} | ||||
|                             eventName='project_overview' | ||||
|                             localStorageKey='ProjectOverviewFeedback' | ||||
|                         /> | ||||
|                     </StyledPaper> | ||||
|                 </ClickAwayListener> | ||||
|             </Popper> | ||||
|         </Box> | ||||
|     ); | ||||
| }; | ||||
| @ -0,0 +1,136 @@ | ||||
| import { Box, styled, Typography } from '@mui/material'; | ||||
| import type { ProjectStatsSchema } from 'openapi/models'; | ||||
| import { HelpPopper } from './HelpPopper'; | ||||
| import { StatusBox } from './StatusBox'; | ||||
| 
 | ||||
| const StyledBox = styled(Box)(({ theme }) => ({ | ||||
|     display: 'grid', | ||||
|     gap: theme.spacing(2), | ||||
|     gridTemplateColumns: 'repeat(5, 1fr)', | ||||
|     flexWrap: 'wrap', | ||||
|     [theme.breakpoints.down('lg')]: { | ||||
|         gridTemplateColumns: 'repeat(2, 1fr)', | ||||
|     }, | ||||
|     [theme.breakpoints.down('sm')]: { | ||||
|         flexDirection: 'column', | ||||
|     }, | ||||
| })); | ||||
| 
 | ||||
| const StyledWidget = styled(Box)(({ theme }) => ({ | ||||
|     position: 'relative', | ||||
|     padding: theme.spacing(3), | ||||
|     backgroundColor: theme.palette.background.paper, | ||||
|     flex: 1, | ||||
|     display: 'flex', | ||||
|     flexDirection: 'column', | ||||
|     alignItems: 'center', | ||||
|     justifyContent: 'center', | ||||
|     borderRadius: `${theme.shape.borderRadiusLarge}px`, | ||||
|     [theme.breakpoints.down('lg')]: { | ||||
|         padding: theme.spacing(2), | ||||
|     }, | ||||
| })); | ||||
| 
 | ||||
| const StyledTimeToProductionDescription = styled(Typography)(({ theme }) => ({ | ||||
|     color: theme.palette.text.secondary, | ||||
|     fontSize: theme.typography.body2.fontSize, | ||||
|     lineHeight: theme.typography.body2.lineHeight, | ||||
| })); | ||||
| 
 | ||||
| interface IProjectStatsProps { | ||||
|     stats: ProjectStatsSchema; | ||||
| } | ||||
| 
 | ||||
| export const ProjectInsightsStats = ({ stats }: IProjectStatsProps) => { | ||||
|     if (Object.keys(stats).length === 0) { | ||||
|         return null; | ||||
|     } | ||||
| 
 | ||||
|     const { | ||||
|         avgTimeToProdCurrentWindow, | ||||
|         projectActivityCurrentWindow, | ||||
|         projectActivityPastWindow, | ||||
|         createdCurrentWindow, | ||||
|         createdPastWindow, | ||||
|         archivedCurrentWindow, | ||||
|         archivedPastWindow, | ||||
|     } = stats; | ||||
| 
 | ||||
|     return ( | ||||
|         <StyledBox> | ||||
|             <StyledWidget> | ||||
|                 <StatusBox | ||||
|                     title='Total changes' | ||||
|                     boxText={String(projectActivityCurrentWindow)} | ||||
|                     change={ | ||||
|                         projectActivityCurrentWindow - projectActivityPastWindow | ||||
|                     } | ||||
|                 > | ||||
|                     <HelpPopper id='total-changes'> | ||||
|                         Sum of all configuration and state modifications in the | ||||
|                         project. | ||||
|                     </HelpPopper> | ||||
|                 </StatusBox> | ||||
|             </StyledWidget> | ||||
|             <StyledWidget> | ||||
|                 <StatusBox | ||||
|                     title='Avg. time to production' | ||||
|                     boxText={ | ||||
|                         <Box | ||||
|                             sx={{ | ||||
|                                 display: 'flex', | ||||
|                                 alignItems: 'center', | ||||
|                                 gap: (theme) => theme.spacing(1), | ||||
|                             }} | ||||
|                         > | ||||
|                             {avgTimeToProdCurrentWindow}{' '} | ||||
|                             <Typography component='span'>days</Typography> | ||||
|                         </Box> | ||||
|                     } | ||||
|                     customChangeElement={ | ||||
|                         <StyledTimeToProductionDescription> | ||||
|                             In project life | ||||
|                         </StyledTimeToProductionDescription> | ||||
|                     } | ||||
|                     percentage | ||||
|                 > | ||||
|                     <HelpPopper id='avg-time-to-prod'> | ||||
|                         How long did it take on average from a feature toggle | ||||
|                         was created until it was enabled in an environment of | ||||
|                         type production. This is calculated only from feature | ||||
|                         toggles with the type of "release". | ||||
|                     </HelpPopper> | ||||
|                 </StatusBox> | ||||
|             </StyledWidget> | ||||
|             <StyledWidget> | ||||
|                 <StatusBox | ||||
|                     title='Features created' | ||||
|                     boxText={String(createdCurrentWindow)} | ||||
|                     change={createdCurrentWindow - createdPastWindow} | ||||
|                 /> | ||||
|             </StyledWidget> | ||||
| 
 | ||||
|             <StyledWidget> | ||||
|                 <StatusBox | ||||
|                     title='Stale toggles' | ||||
|                     boxText={String(projectActivityCurrentWindow)} | ||||
|                     change={ | ||||
|                         projectActivityCurrentWindow - projectActivityPastWindow | ||||
|                     } | ||||
|                 > | ||||
|                     <HelpPopper id='stale-toggles'> | ||||
|                         Sum of all stale toggles in the project | ||||
|                     </HelpPopper> | ||||
|                 </StatusBox> | ||||
|             </StyledWidget> | ||||
| 
 | ||||
|             <StyledWidget> | ||||
|                 <StatusBox | ||||
|                     title='Features archived' | ||||
|                     boxText={String(archivedCurrentWindow)} | ||||
|                     change={archivedCurrentWindow - archivedPastWindow} | ||||
|                 /> | ||||
|             </StyledWidget> | ||||
|         </StyledBox> | ||||
|     ); | ||||
| }; | ||||
| @ -0,0 +1,129 @@ | ||||
| import type { FC, ReactNode } from 'react'; | ||||
| import CallMade from '@mui/icons-material/CallMade'; | ||||
| import SouthEast from '@mui/icons-material/SouthEast'; | ||||
| import { Box, Typography, styled } from '@mui/material'; | ||||
| import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; | ||||
| import { flexRow } from 'themes/themeStyles'; | ||||
| 
 | ||||
| const StyledTypographyHeader = styled(Typography)(({ theme }) => ({ | ||||
|     marginBottom: theme.spacing(2.5), | ||||
| })); | ||||
| 
 | ||||
| const StyledTypographyCount = styled(Box)(({ theme }) => ({ | ||||
|     fontSize: theme.fontSizes.largeHeader, | ||||
| })); | ||||
| 
 | ||||
| const StyledBoxChangeContainer = styled(Box)(({ theme }) => ({ | ||||
|     ...flexRow, | ||||
|     flexDirection: 'column', | ||||
|     alignItems: 'center', | ||||
|     marginLeft: theme.spacing(2.5), | ||||
| })); | ||||
| 
 | ||||
| const StyledTypographySubtext = styled(Typography)(({ theme }) => ({ | ||||
|     color: theme.palette.text.secondary, | ||||
|     fontSize: theme.typography.body2.fontSize, | ||||
| })); | ||||
| 
 | ||||
| const StyledTypographyChange = styled(Typography)(({ theme }) => ({ | ||||
|     marginLeft: theme.spacing(1), | ||||
|     fontSize: theme.typography.body1.fontSize, | ||||
|     fontWeight: theme.typography.fontWeightBold, | ||||
| })); | ||||
| 
 | ||||
| interface IStatusBoxProps { | ||||
|     title?: string; | ||||
|     boxText: ReactNode; | ||||
|     change?: number; | ||||
|     percentage?: boolean; | ||||
|     customChangeElement?: ReactNode; | ||||
| } | ||||
| 
 | ||||
| const resolveIcon = (change: number) => { | ||||
|     if (change > 0) { | ||||
|         return ( | ||||
|             <CallMade sx={{ color: 'success.dark', height: 20, width: 20 }} /> | ||||
|         ); | ||||
|     } | ||||
|     return <SouthEast sx={{ color: 'warning.dark', height: 20, width: 20 }} />; | ||||
| }; | ||||
| 
 | ||||
| const resolveColor = (change: number) => { | ||||
|     if (change > 0) { | ||||
|         return 'success.dark'; | ||||
|     } | ||||
|     return 'warning.dark'; | ||||
| }; | ||||
| 
 | ||||
| export const StatusBox: FC<IStatusBoxProps> = ({ | ||||
|     title, | ||||
|     boxText, | ||||
|     change, | ||||
|     percentage, | ||||
|     children, | ||||
|     customChangeElement, | ||||
| }) => ( | ||||
|     <> | ||||
|         <ConditionallyRender | ||||
|             condition={Boolean(title)} | ||||
|             show={ | ||||
|                 <StyledTypographyHeader data-loading> | ||||
|                     {title} | ||||
|                 </StyledTypographyHeader> | ||||
|             } | ||||
|         /> | ||||
|         {children} | ||||
|         <Box | ||||
|             sx={{ | ||||
|                 ...flexRow, | ||||
|                 justifyContent: 'center', | ||||
|                 width: 'auto', | ||||
|             }} | ||||
|         > | ||||
|             <StyledTypographyCount data-loading> | ||||
|                 {boxText} | ||||
|             </StyledTypographyCount> | ||||
|             <ConditionallyRender | ||||
|                 condition={Boolean(customChangeElement)} | ||||
|                 show={ | ||||
|                     <StyledBoxChangeContainer data-loading> | ||||
|                         {customChangeElement} | ||||
|                     </StyledBoxChangeContainer> | ||||
|                 } | ||||
|                 elseShow={ | ||||
|                     <ConditionallyRender | ||||
|                         condition={change !== undefined && change !== 0} | ||||
|                         show={ | ||||
|                             <StyledBoxChangeContainer data-loading> | ||||
|                                 <Box | ||||
|                                     sx={{ | ||||
|                                         ...flexRow, | ||||
|                                     }} | ||||
|                                 > | ||||
|                                     {resolveIcon(change as number)} | ||||
|                                     <StyledTypographyChange | ||||
|                                         color={resolveColor(change as number)} | ||||
|                                     > | ||||
|                                         {(change as number) > 0 ? '+' : ''} | ||||
|                                         {change} | ||||
|                                         {percentage ? '%' : ''} | ||||
|                                     </StyledTypographyChange> | ||||
|                                 </Box> | ||||
|                                 <StyledTypographySubtext> | ||||
|                                     this month | ||||
|                                 </StyledTypographySubtext> | ||||
|                             </StyledBoxChangeContainer> | ||||
|                         } | ||||
|                         elseShow={ | ||||
|                             <StyledBoxChangeContainer> | ||||
|                                 <StyledTypographySubtext data-loading> | ||||
|                                     No change | ||||
|                                 </StyledTypographySubtext> | ||||
|                             </StyledBoxChangeContainer> | ||||
|                         } | ||||
|                     /> | ||||
|                 } | ||||
|             /> | ||||
|         </Box> | ||||
|     </> | ||||
| ); | ||||
| @ -1,3 +1,4 @@ | ||||
| // biome-ignore lint: we need this to correctly extend the MUI theme
 | ||||
| import { FormHelperTextOwnProps } from '@mui/material/FormHelperText'; | ||||
| 
 | ||||
| declare module '@mui/material/styles' { | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user