mirror of
https://github.com/Unleash/unleash.git
synced 2025-02-23 00:22:19 +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