mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	chore: clean up project related tech debt (#10065)
https://linear.app/unleash/issue/2-3581/remove-project-related-legacy-code Identified some clean up opportunities during deprecated endpoint removal, mostly related to project insights.
This commit is contained in:
		
							parent
							
								
									e474abb946
								
							
						
					
					
						commit
						6d70265edd
					
				| @ -26,7 +26,6 @@ import { | ||||
| import useToast from 'hooks/useToast'; | ||||
| import useQueryParams from 'hooks/useQueryParams'; | ||||
| import { useEffect, useState, type ReactNode } from 'react'; | ||||
| import ProjectEnvironment from '../ProjectEnvironment/ProjectEnvironment.tsx'; | ||||
| import ProjectFlags from './ProjectFlags.tsx'; | ||||
| import ProjectHealth from './ProjectHealth/ProjectHealth.tsx'; | ||||
| import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; | ||||
| @ -51,7 +50,6 @@ import type { UiFlags } from 'interfaces/uiConfig'; | ||||
| import { HiddenProjectIconWithTooltip } from './HiddenProjectIconWithTooltip/HiddenProjectIconWithTooltip.tsx'; | ||||
| import { ChangeRequestPlausibleProvider } from 'component/changeRequest/ChangeRequestContext'; | ||||
| import { ProjectApplications } from '../ProjectApplications/ProjectApplications.tsx'; | ||||
| import { ProjectInsights } from './ProjectInsights/ProjectInsights.tsx'; | ||||
| import useProjectOverview from 'hooks/api/getters/useProjectOverview/useProjectOverview'; | ||||
| import { ProjectArchived } from './ArchiveProject/ProjectArchived.tsx'; | ||||
| import { usePlausibleTracker } from '../../../hooks/usePlausibleTracker.ts'; | ||||
| @ -385,8 +383,6 @@ export const Project = () => { | ||||
|                         /> | ||||
|                     } | ||||
|                 /> | ||||
|                 <Route path='environments' element={<ProjectEnvironment />} /> | ||||
|                 <Route path='insights' element={<ProjectInsights />} /> | ||||
|                 <Route path='logs' element={<ProjectLog />} /> | ||||
|                 <Route | ||||
|                     path='change-requests' | ||||
|  | ||||
| @ -1,66 +0,0 @@ | ||||
| import { screen } from '@testing-library/react'; | ||||
| import { render } from 'utils/testRenderer'; | ||||
| import { testServerRoute, testServerSetup } from 'utils/testServer'; | ||||
| import { Route, Routes } from 'react-router-dom'; | ||||
| import { ChangeRequests } from './ChangeRequests.tsx'; | ||||
| 
 | ||||
| const server = testServerSetup(); | ||||
| 
 | ||||
| const setupEnterpriseApi = () => { | ||||
|     testServerRoute(server, '/api/admin/ui-config', { | ||||
|         versionInfo: { | ||||
|             current: { enterprise: 'present' }, | ||||
|         }, | ||||
|     }); | ||||
|     testServerRoute( | ||||
|         server, | ||||
|         '/api/admin/projects/default/change-requests/count', | ||||
|         { | ||||
|             total: 14, | ||||
|             approved: 2, | ||||
|             applied: 0, | ||||
|             rejected: 0, | ||||
|             reviewRequired: 10, | ||||
|             scheduled: 2, | ||||
|         }, | ||||
|     ); | ||||
| }; | ||||
| 
 | ||||
| const setupOssApi = () => { | ||||
|     testServerRoute(server, '/api/admin/ui-config', { | ||||
|         versionInfo: { | ||||
|             current: { oss: 'present' }, | ||||
|         }, | ||||
|     }); | ||||
| }; | ||||
| 
 | ||||
| test('Show enterprise hints', async () => { | ||||
|     setupOssApi(); | ||||
|     render( | ||||
|         <Routes> | ||||
|             <Route path={'/projects/:projectId'} element={<ChangeRequests />} /> | ||||
|         </Routes>, | ||||
|         { | ||||
|             route: '/projects/default', | ||||
|         }, | ||||
|     ); | ||||
| 
 | ||||
|     await screen.findByText('Enterprise feature'); | ||||
| }); | ||||
| 
 | ||||
| test('Show change requests info', async () => { | ||||
|     setupEnterpriseApi(); | ||||
|     render( | ||||
|         <Routes> | ||||
|             <Route path={'/projects/:projectId'} element={<ChangeRequests />} /> | ||||
|         </Routes>, | ||||
|         { | ||||
|             route: '/projects/default', | ||||
|         }, | ||||
|     ); | ||||
| 
 | ||||
|     await screen.findByText('To be applied'); | ||||
|     await screen.findByText('10'); | ||||
|     await screen.findByText('4'); | ||||
|     await screen.findByText('14'); | ||||
| }); | ||||
| @ -1,144 +0,0 @@ | ||||
| import { Box, styled, Typography } from '@mui/material'; | ||||
| import KeyboardArrowRight from '@mui/icons-material/KeyboardArrowRight'; | ||||
| import { Link } from 'react-router-dom'; | ||||
| import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; | ||||
| import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; | ||||
| import { PremiumFeature } from 'component/common/PremiumFeature/PremiumFeature'; | ||||
| import { useChangeRequestsCount } from 'hooks/api/getters/useChangeRequestsCount/useChangeRequestsCount'; | ||||
| 
 | ||||
| const Container = styled(Box)(({ theme }) => ({ | ||||
|     display: 'flex', | ||||
|     flexDirection: 'column', | ||||
|     gap: theme.spacing(2.5), | ||||
| })); | ||||
| 
 | ||||
| const BoxesContainer = styled(Box)(({ theme }) => ({ | ||||
|     display: 'flex', | ||||
|     gap: theme.spacing(1), | ||||
|     justifyContent: 'space-between', | ||||
|     flexWrap: 'wrap', | ||||
| })); | ||||
| 
 | ||||
| const NumberBox = styled(Box)(({ theme }) => ({ | ||||
|     display: 'flex', | ||||
|     flexDirection: 'column', | ||||
|     flex: 1, | ||||
|     alignItems: 'center', | ||||
|     justifyContent: 'center', | ||||
|     padding: theme.spacing(1), | ||||
|     borderRadius: theme.shape.borderRadiusMedium, | ||||
|     border: `1px solid ${theme.palette.divider}`, | ||||
| })); | ||||
| 
 | ||||
| const OpenBox = styled(Box)(({ theme }) => ({ | ||||
|     display: 'flex', | ||||
|     flexDirection: 'column', | ||||
|     flex: 1, | ||||
|     borderRadius: theme.shape.borderRadiusMedium, | ||||
|     padding: theme.spacing(3), | ||||
|     border: `2px solid ${theme.palette.primary.main}`, | ||||
| })); | ||||
| 
 | ||||
| const ColorBox = styled(Box)(({ theme }) => ({ | ||||
|     borderRadius: '8px', | ||||
|     padding: theme.spacing(1, 2), | ||||
|     display: 'flex', | ||||
|     gap: theme.spacing(6), | ||||
|     justifyContent: 'space-between', | ||||
|     alignItems: 'center', | ||||
|     marginBottom: theme.spacing(1.5), | ||||
|     whiteSpace: 'nowrap', | ||||
| })); | ||||
| 
 | ||||
| const ApplyBox = styled(ColorBox)(({ theme }) => ({ | ||||
|     background: theme.palette.success.light, | ||||
|     marginTop: theme.spacing(2.5), | ||||
| })); | ||||
| 
 | ||||
| const ReviewBox = styled(ColorBox)(({ theme }) => ({ | ||||
|     background: theme.palette.secondary.light, | ||||
| })); | ||||
| 
 | ||||
| const ChangeRequestNavigation = styled(Link)(({ theme }) => ({ | ||||
|     display: 'flex', | ||||
|     justifyContent: 'space-between', | ||||
|     textDecoration: 'none', | ||||
|     color: theme.palette.text.primary, | ||||
| })); | ||||
| 
 | ||||
| const Title = styled(Typography)(({ theme }) => ({ | ||||
|     fontSize: theme.spacing(2), | ||||
|     color: theme.palette.text.secondary, | ||||
|     marginBottom: theme.spacing(1), | ||||
| })); | ||||
| 
 | ||||
| const MediumNumber = styled(Typography)(({ theme }) => ({ | ||||
|     fontSize: theme.spacing(3), | ||||
|     color: theme.palette.text.primary, | ||||
| })); | ||||
| 
 | ||||
| const BigNumber = styled(Typography)(({ theme }) => ({ | ||||
|     fontSize: theme.spacing(5.5), | ||||
|     color: theme.palette.text.primary, | ||||
| })); | ||||
| 
 | ||||
| export const ChangeRequests = () => { | ||||
|     const projectId = useRequiredPathParam('projectId'); | ||||
|     const { isOss, isPro } = useUiConfig(); | ||||
|     const { data } = useChangeRequestsCount(projectId); | ||||
| 
 | ||||
|     const { total, applied, rejected, reviewRequired, scheduled, approved } = | ||||
|         data; | ||||
|     const toBeApplied = scheduled + approved; | ||||
| 
 | ||||
|     if (isOss() || isPro()) { | ||||
|         return ( | ||||
|             <Container> | ||||
|                 <Typography variant='h3'>Change requests</Typography> | ||||
|                 <PremiumFeature feature='change-requests' /> | ||||
|             </Container> | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     return ( | ||||
|         <Container> | ||||
|             <ChangeRequestNavigation | ||||
|                 to={`/projects/${projectId}/change-requests`} | ||||
|             > | ||||
|                 <Typography variant='h3'>Change requests</Typography> | ||||
|                 <KeyboardArrowRight /> | ||||
|             </ChangeRequestNavigation> | ||||
| 
 | ||||
|             <BoxesContainer data-loading> | ||||
|                 <OpenBox> | ||||
|                     <ChangeRequestNavigation | ||||
|                         to={`/projects/${projectId}/change-requests`} | ||||
|                     > | ||||
|                         <span>Open</span> | ||||
|                         <KeyboardArrowRight /> | ||||
|                     </ChangeRequestNavigation> | ||||
|                     <ApplyBox> | ||||
|                         <span>To be applied</span> | ||||
|                         <MediumNumber>{toBeApplied}</MediumNumber> | ||||
|                     </ApplyBox> | ||||
|                     <ReviewBox> | ||||
|                         <span>To be reviewed</span> | ||||
|                         <MediumNumber>{reviewRequired}</MediumNumber> | ||||
|                     </ReviewBox> | ||||
|                 </OpenBox> | ||||
|                 <NumberBox> | ||||
|                     <Title>Total</Title> | ||||
|                     <BigNumber>{total}</BigNumber> | ||||
|                 </NumberBox> | ||||
|                 <NumberBox> | ||||
|                     <Title>Applied</Title> | ||||
|                     <BigNumber>{applied}</BigNumber> | ||||
|                 </NumberBox> | ||||
|                 <NumberBox> | ||||
|                     <Title>Rejected</Title> | ||||
|                     <BigNumber>{rejected}</BigNumber> | ||||
|                 </NumberBox> | ||||
|             </BoxesContainer> | ||||
|         </Container> | ||||
|     ); | ||||
| }; | ||||
| @ -1,46 +0,0 @@ | ||||
| import { screen } from '@testing-library/react'; | ||||
| import { render } from 'utils/testRenderer'; | ||||
| import { testServerRoute, testServerSetup } from 'utils/testServer'; | ||||
| import type { ProjectOverviewSchema } from 'openapi'; | ||||
| import { Route, Routes } from 'react-router-dom'; | ||||
| import { FlagTypesUsed } from './FlagTypesUsed.tsx'; | ||||
| 
 | ||||
| const server = testServerSetup(); | ||||
| 
 | ||||
| const setupApi = (overview: ProjectOverviewSchema) => { | ||||
|     testServerRoute(server, '/api/admin/projects/default/overview', overview); | ||||
| }; | ||||
| 
 | ||||
| test('Show outdated SDKs and apps using them', async () => { | ||||
|     setupApi({ | ||||
|         name: 'default', | ||||
|         version: 2, | ||||
|         featureTypeCounts: [ | ||||
|             { | ||||
|                 type: 'release', | ||||
|                 count: 57, | ||||
|             }, | ||||
|         ], | ||||
|         onboardingStatus: { | ||||
|             status: 'onboarded', | ||||
|         }, | ||||
|     }); | ||||
|     render( | ||||
|         <Routes> | ||||
|             <Route | ||||
|                 path={'/projects/:projectId'} | ||||
|                 element={ | ||||
|                     <FlagTypesUsed | ||||
|                         featureTypeCounts={[{ type: 'release', count: 57 }]} | ||||
|                     /> | ||||
|                 } | ||||
|             /> | ||||
|         </Routes>, | ||||
|         { | ||||
|             route: '/projects/default', | ||||
|         }, | ||||
|     ); | ||||
| 
 | ||||
|     await screen.findByText('Release'); | ||||
|     await screen.findByText('57'); | ||||
| }); | ||||
| @ -1,121 +0,0 @@ | ||||
| import { type FC, useMemo } from 'react'; | ||||
| import { styled, type SvgIconTypeMap, Typography } from '@mui/material'; | ||||
| import { getFeatureTypeIcons } from 'utils/getFeatureTypeIcons'; | ||||
| 
 | ||||
| import type { OverridableComponent } from '@mui/material/OverridableComponent'; | ||||
| import type { FeatureTypeCountSchema } from 'openapi'; | ||||
| 
 | ||||
| export const StyledProjectInfoWidgetContainer = styled('div')(({ theme }) => ({ | ||||
|     margin: '0', | ||||
|     [theme.breakpoints.down('md')]: { | ||||
|         display: 'flex', | ||||
|         flexDirection: 'column', | ||||
|         position: 'relative', | ||||
|     }, | ||||
| })); | ||||
| 
 | ||||
| export const StyledWidgetTitle = styled(Typography)(({ theme }) => ({ | ||||
|     marginBottom: theme.spacing(2.5), | ||||
| })); | ||||
| 
 | ||||
| export const StyledCount = styled('span')(({ theme }) => ({ | ||||
|     fontSize: theme.typography.h2.fontSize, | ||||
|     fontWeight: 'bold', | ||||
|     color: theme.palette.text.primary, | ||||
| })); | ||||
| 
 | ||||
| const StyledTypeCount = styled(StyledCount)(({ theme }) => ({ | ||||
|     marginLeft: 'auto', | ||||
|     fontWeight: theme.typography.fontWeightRegular, | ||||
|     color: theme.palette.text.secondary, | ||||
| })); | ||||
| 
 | ||||
| interface IFlagTypeRowProps { | ||||
|     type: string; | ||||
|     Icon: OverridableComponent<SvgIconTypeMap>; | ||||
|     count: number; | ||||
| } | ||||
| 
 | ||||
| const StyledParagraphGridRow = styled('div')(({ theme }) => ({ | ||||
|     display: 'flex', | ||||
|     gap: theme.spacing(1.5), | ||||
|     width: '100%', | ||||
|     margin: theme.spacing(1, 0), | ||||
|     fontSize: theme.fontSizes.smallBody, | ||||
|     color: theme.palette.text.secondary, | ||||
|     alignItems: 'center', | ||||
|     [theme.breakpoints.down('md')]: { | ||||
|         margin: 0, | ||||
|     }, | ||||
| })); | ||||
| 
 | ||||
| const FlagTypesRow = ({ type, Icon, count }: IFlagTypeRowProps) => { | ||||
|     const getTitleText = (str: string) => { | ||||
|         return str.charAt(0).toUpperCase() + str.slice(1).replace('-', ' '); | ||||
|     }; | ||||
| 
 | ||||
|     return ( | ||||
|         <StyledParagraphGridRow data-loading> | ||||
|             <Icon fontSize='small' data-loading /> | ||||
|             <div>{getTitleText(type)}</div> | ||||
|             <StyledTypeCount>{count}</StyledTypeCount> | ||||
|         </StyledParagraphGridRow> | ||||
|     ); | ||||
| }; | ||||
| 
 | ||||
| export const FlagTypesUsed: FC<{ | ||||
|     featureTypeCounts: FeatureTypeCountSchema[]; | ||||
| }> = ({ featureTypeCounts }) => { | ||||
|     const featureTypeStats = useMemo(() => { | ||||
|         const release = | ||||
|             featureTypeCounts.find( | ||||
|                 (featureType) => featureType.type === 'release', | ||||
|             )?.count || 0; | ||||
| 
 | ||||
|         const experiment = | ||||
|             featureTypeCounts.find( | ||||
|                 (featureType) => featureType.type === 'experiment', | ||||
|             )?.count || 0; | ||||
| 
 | ||||
|         const operational = | ||||
|             featureTypeCounts.find( | ||||
|                 (featureType) => featureType.type === 'operational', | ||||
|             )?.count || 0; | ||||
| 
 | ||||
|         const kill = | ||||
|             featureTypeCounts.find( | ||||
|                 (featureType) => featureType.type === 'kill-switch', | ||||
|             )?.count || 0; | ||||
| 
 | ||||
|         const permission = | ||||
|             featureTypeCounts.find( | ||||
|                 (featureType) => featureType.type === 'permission', | ||||
|             )?.count || 0; | ||||
| 
 | ||||
|         return { | ||||
|             release, | ||||
|             experiment, | ||||
|             operational, | ||||
|             'kill-switch': kill, | ||||
|             permission, | ||||
|         }; | ||||
|     }, [featureTypeCounts]); | ||||
| 
 | ||||
|     return ( | ||||
|         <StyledProjectInfoWidgetContainer> | ||||
|             <StyledWidgetTitle variant='h3' data-loading> | ||||
|                 Flag types used | ||||
|             </StyledWidgetTitle> | ||||
|             {Object.keys(featureTypeStats).map((type) => ( | ||||
|                 <FlagTypesRow | ||||
|                     type={type} | ||||
|                     key={type} | ||||
|                     Icon={getFeatureTypeIcons(type)} | ||||
|                     count={ | ||||
|                         featureTypeStats[type as keyof typeof featureTypeStats] | ||||
|                     } | ||||
|                 /> | ||||
|             ))} | ||||
|         </StyledProjectInfoWidgetContainer> | ||||
|     ); | ||||
| }; | ||||
| @ -1,36 +0,0 @@ | ||||
| import { screen } from '@testing-library/react'; | ||||
| import { render } from 'utils/testRenderer'; | ||||
| import type { ProjectDoraMetricsSchema } from 'openapi'; | ||||
| import { LeadTimeForChanges } from './LeadTimeForChanges.tsx'; | ||||
| import { Route, Routes } from 'react-router-dom'; | ||||
| 
 | ||||
| test('Show outdated SDKs and apps using them', async () => { | ||||
|     const leadTime: ProjectDoraMetricsSchema = { | ||||
|         features: [ | ||||
|             { | ||||
|                 name: 'ABCD', | ||||
|                 timeToProduction: 57, | ||||
|             }, | ||||
|         ], | ||||
|         projectAverage: 67, | ||||
|     }; | ||||
|     render( | ||||
|         <Routes> | ||||
|             <Route | ||||
|                 path={'/projects/:projectId'} | ||||
|                 element={ | ||||
|                     <LeadTimeForChanges leadTime={leadTime} loading={false} /> | ||||
|                 } | ||||
|             /> | ||||
|         </Routes>, | ||||
|         { | ||||
|             route: '/projects/default', | ||||
|         }, | ||||
|     ); | ||||
| 
 | ||||
|     await screen.findByText('Lead time for changes (per release flag)'); | ||||
|     await screen.findByText('ABCD'); | ||||
|     await screen.findByText('57 days'); | ||||
|     await screen.findByText('Low'); | ||||
|     await screen.findByText('10 days'); | ||||
| }); | ||||
| @ -1,267 +0,0 @@ | ||||
| import { Box, styled, Tooltip, Typography, useMediaQuery } from '@mui/material'; | ||||
| import { useMemo } from 'react'; | ||||
| import { useTable, useGlobalFilter, useSortBy } from 'react-table'; | ||||
| import { | ||||
|     Table, | ||||
|     SortableTableHeader, | ||||
|     TableBody, | ||||
|     TableCell, | ||||
|     TableRow, | ||||
|     TablePlaceholder, | ||||
| } from 'component/common/Table'; | ||||
| import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; | ||||
| import { Badge } from 'component/common/Badge/Badge'; | ||||
| import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns'; | ||||
| import theme from 'themes/theme'; | ||||
| import type { ProjectDoraMetricsSchema } from 'openapi'; | ||||
| 
 | ||||
| const Container = styled(Box)(({ theme }) => ({ | ||||
|     display: 'flex', | ||||
|     flexDirection: 'column', | ||||
|     gap: theme.spacing(2), | ||||
| })); | ||||
| 
 | ||||
| const TableContainer = styled(Box)(({ theme }) => ({ | ||||
|     overflowY: 'auto', | ||||
|     maxHeight: theme.spacing(45), | ||||
| })); | ||||
| 
 | ||||
| const resolveDoraMetrics = (input: number) => { | ||||
|     const ONE_MONTH = 30; | ||||
|     const ONE_WEEK = 7; | ||||
| 
 | ||||
|     if (input >= ONE_MONTH) { | ||||
|         return <Badge color='error'>Low</Badge>; | ||||
|     } | ||||
| 
 | ||||
|     if (input <= ONE_MONTH && input >= ONE_WEEK + 1) { | ||||
|         return <Badge>Medium</Badge>; | ||||
|     } | ||||
| 
 | ||||
|     if (input <= ONE_WEEK) { | ||||
|         return <Badge color='success'>High</Badge>; | ||||
|     } | ||||
| }; | ||||
| 
 | ||||
| interface ILeadTimeForChangesProps { | ||||
|     leadTime: ProjectDoraMetricsSchema; | ||||
|     loading: boolean; | ||||
| } | ||||
| 
 | ||||
| const loadingLeadTimeFeatures = [ | ||||
|     { name: 'feature1', timeToProduction: 0 }, | ||||
|     { name: 'feature2', timeToProduction: 0 }, | ||||
|     { name: 'feature3', timeToProduction: 0 }, | ||||
|     { name: 'feature4', timeToProduction: 0 }, | ||||
|     { name: 'feature5', timeToProduction: 2 }, | ||||
| ]; | ||||
| 
 | ||||
| export const LeadTimeForChanges = ({ | ||||
|     leadTime, | ||||
|     loading, | ||||
| }: ILeadTimeForChangesProps) => { | ||||
|     const columns = useMemo( | ||||
|         () => [ | ||||
|             { | ||||
|                 Header: 'Name', | ||||
|                 accessor: 'name', | ||||
|                 width: '40%', | ||||
|                 Cell: ({ | ||||
|                     row: { | ||||
|                         original: { name }, | ||||
|                     }, | ||||
|                 }: any) => { | ||||
|                     return ( | ||||
|                         <Box | ||||
|                             data-loading | ||||
|                             sx={{ | ||||
|                                 pl: 2, | ||||
|                                 pr: 1, | ||||
|                                 paddingTop: 2, | ||||
|                                 paddingBottom: 2, | ||||
|                                 display: 'flex', | ||||
|                                 alignItems: 'center', | ||||
|                             }} | ||||
|                         > | ||||
|                             {name} | ||||
|                         </Box> | ||||
|                     ); | ||||
|                 }, | ||||
|                 sortType: 'alphanumeric', | ||||
|             }, | ||||
|             { | ||||
|                 Header: 'Time to production', | ||||
|                 id: 'timetoproduction', | ||||
|                 align: 'center', | ||||
|                 Cell: ({ row: { original } }: any) => ( | ||||
|                     <Tooltip | ||||
|                         title='The time from the feature flag of type release was created until it was turned on in a production environment' | ||||
|                         arrow | ||||
|                     > | ||||
|                         <Box | ||||
|                             sx={{ | ||||
|                                 display: 'flex', | ||||
|                                 justifyContent: 'center', | ||||
|                             }} | ||||
|                             data-loading | ||||
|                         > | ||||
|                             {original.timeToProduction} days | ||||
|                         </Box> | ||||
|                     </Tooltip> | ||||
|                 ), | ||||
|                 width: 220, | ||||
|                 disableGlobalFilter: true, | ||||
|                 disableSortBy: true, | ||||
|             }, | ||||
|             { | ||||
|                 Header: `Deviation`, | ||||
|                 id: 'deviation', | ||||
|                 align: 'center', | ||||
|                 Cell: ({ row: { original } }: any) => ( | ||||
|                     <Tooltip | ||||
|                         title={`Deviation from project average. Average for this project is: ${ | ||||
|                             leadTime.projectAverage || 0 | ||||
|                         } days`}
 | ||||
|                         arrow | ||||
|                     > | ||||
|                         <Box | ||||
|                             sx={{ | ||||
|                                 display: 'flex', | ||||
|                                 justifyContent: 'center', | ||||
|                             }} | ||||
|                             data-loading | ||||
|                         > | ||||
|                             {Math.round( | ||||
|                                 (leadTime.projectAverage | ||||
|                                     ? leadTime.projectAverage | ||||
|                                     : 0) - original.timeToProduction, | ||||
|                             )}{' '} | ||||
|                             days | ||||
|                         </Box> | ||||
|                     </Tooltip> | ||||
|                 ), | ||||
|                 width: 300, | ||||
|                 disableGlobalFilter: true, | ||||
|                 disableSortBy: true, | ||||
|             }, | ||||
|             { | ||||
|                 Header: 'DORA', | ||||
|                 id: 'dora', | ||||
|                 align: 'center', | ||||
|                 Cell: ({ row: { original } }: any) => ( | ||||
|                     <Tooltip | ||||
|                         title='Dora score. High = less than a week to production. Medium = less than a month to production. Low = Less than 6 months to production' | ||||
|                         arrow | ||||
|                     > | ||||
|                         <Box | ||||
|                             sx={{ | ||||
|                                 display: 'flex', | ||||
|                                 justifyContent: 'center', | ||||
|                             }} | ||||
|                             data-loading | ||||
|                         > | ||||
|                             {resolveDoraMetrics(original.timeToProduction)} | ||||
|                         </Box> | ||||
|                     </Tooltip> | ||||
|                 ), | ||||
|                 width: 200, | ||||
|                 disableGlobalFilter: true, | ||||
|                 disableSortBy: true, | ||||
|             }, | ||||
|         ], | ||||
|         [JSON.stringify(leadTime.features), loading], | ||||
|     ); | ||||
| 
 | ||||
|     const initialState = useMemo( | ||||
|         () => ({ | ||||
|             sortBy: [ | ||||
|                 { | ||||
|                     id: 'name', | ||||
|                     desc: false, | ||||
|                 }, | ||||
|             ], | ||||
|         }), | ||||
|         [], | ||||
|     ); | ||||
| 
 | ||||
|     const isExtraSmallScreen = useMediaQuery(theme.breakpoints.down('sm')); | ||||
| 
 | ||||
|     const { | ||||
|         getTableProps, | ||||
|         getTableBodyProps, | ||||
|         headerGroups, | ||||
|         rows, | ||||
|         prepareRow, | ||||
|         state: { globalFilter }, | ||||
|         setHiddenColumns, | ||||
|     } = useTable( | ||||
|         { | ||||
|             columns: columns as any[], | ||||
|             data: loading ? loadingLeadTimeFeatures : leadTime.features, | ||||
|             initialState, | ||||
|             autoResetGlobalFilter: false, | ||||
|             autoResetSortBy: false, | ||||
|             disableSortRemove: true, | ||||
|         }, | ||||
|         useGlobalFilter, | ||||
|         useSortBy, | ||||
|     ); | ||||
| 
 | ||||
|     useConditionallyHiddenColumns( | ||||
|         [ | ||||
|             { | ||||
|                 condition: isExtraSmallScreen, | ||||
|                 columns: ['deviation'], | ||||
|             }, | ||||
|         ], | ||||
|         setHiddenColumns, | ||||
|         columns, | ||||
|     ); | ||||
| 
 | ||||
|     return ( | ||||
|         <Container> | ||||
|             <Typography variant='h3'> | ||||
|                 Lead time for changes (per release flag) | ||||
|             </Typography> | ||||
|             <TableContainer> | ||||
|                 <Table {...getTableProps()}> | ||||
|                     <SortableTableHeader headerGroups={headerGroups} /> | ||||
|                     <TableBody {...getTableBodyProps()}> | ||||
|                         {rows.map((row) => { | ||||
|                             prepareRow(row); | ||||
|                             const { key, ...rowProps } = row.getRowProps(); | ||||
|                             return ( | ||||
|                                 <TableRow hover key={key} {...rowProps}> | ||||
|                                     {row.cells.map((cell) => { | ||||
|                                         const { key, ...cellProps } = | ||||
|                                             cell.getCellProps(); | ||||
|                                         return ( | ||||
|                                             <TableCell key={key} {...cellProps}> | ||||
|                                                 {cell.render('Cell')} | ||||
|                                             </TableCell> | ||||
|                                         ); | ||||
|                                     })} | ||||
|                                 </TableRow> | ||||
|                             ); | ||||
|                         })} | ||||
|                     </TableBody> | ||||
|                 </Table> | ||||
|             </TableContainer> | ||||
|             <ConditionallyRender | ||||
|                 condition={rows.length === 0} | ||||
|                 show={ | ||||
|                     <ConditionallyRender | ||||
|                         condition={globalFilter?.length > 0} | ||||
|                         show={ | ||||
|                             <TablePlaceholder> | ||||
|                                 No features with data found “ | ||||
|                                 {globalFilter} | ||||
|                                 ” | ||||
|                             </TablePlaceholder> | ||||
|                         } | ||||
|                     /> | ||||
|                 } | ||||
|             /> | ||||
|         </Container> | ||||
|     ); | ||||
| }; | ||||
| @ -1,80 +0,0 @@ | ||||
| import { Box, styled, useTheme } from '@mui/material'; | ||||
| import { Link } from 'react-router-dom'; | ||||
| import type { FC } from 'react'; | ||||
| 
 | ||||
| const Dot = styled('span', { | ||||
|     shouldForwardProp: (prop) => prop !== 'color', | ||||
| })<{ color?: string }>(({ theme, color }) => ({ | ||||
|     height: '15px', | ||||
|     width: '15px', | ||||
|     borderRadius: '50%', | ||||
|     display: 'inline-block', | ||||
|     backgroundColor: color, | ||||
| })); | ||||
| 
 | ||||
| const FlagCountsWrapper = styled(Box)(({ theme }) => ({ | ||||
|     display: 'flex', | ||||
|     flexDirection: 'column', | ||||
|     gap: theme.spacing(2), | ||||
| })); | ||||
| 
 | ||||
| const FlagsCount = styled('p')(({ theme }) => ({ | ||||
|     color: theme.palette.text.secondary, | ||||
|     marginLeft: theme.spacing(3), | ||||
| })); | ||||
| 
 | ||||
| const StatusWithDot = styled(Box)(({ theme }) => ({ | ||||
|     display: 'flex', | ||||
|     alignItems: 'center', | ||||
|     gap: theme.spacing(1), | ||||
| })); | ||||
| 
 | ||||
| export const FlagCounts: FC<{ | ||||
|     projectId: string; | ||||
|     activeCount: number; | ||||
|     potentiallyStaleCount: number; | ||||
|     staleCount: number; | ||||
|     hideLinks?: boolean; | ||||
| }> = ({ | ||||
|     projectId, | ||||
|     activeCount, | ||||
|     potentiallyStaleCount, | ||||
|     staleCount, | ||||
|     hideLinks = false, | ||||
| }) => { | ||||
|     const theme = useTheme(); | ||||
| 
 | ||||
|     return ( | ||||
|         <FlagCountsWrapper> | ||||
|             <Box> | ||||
|                 <StatusWithDot> | ||||
|                     <Dot color={theme.palette.success.border} /> | ||||
|                     <Box sx={{ fontWeight: 'bold' }}>Active</Box> | ||||
|                 </StatusWithDot> | ||||
|                 <FlagsCount>{activeCount} feature flags</FlagsCount> | ||||
|             </Box> | ||||
|             <Box> | ||||
|                 <StatusWithDot> | ||||
|                     <Dot color={theme.palette.warning.border} /> | ||||
|                     <Box sx={{ fontWeight: 'bold' }}>Potentially stale</Box> | ||||
|                     {hideLinks ? null : ( | ||||
|                         <Link to='/feature-toggle-type'>(configure)</Link> | ||||
|                     )} | ||||
|                 </StatusWithDot> | ||||
|                 <FlagsCount>{potentiallyStaleCount} feature flags</FlagsCount> | ||||
|             </Box> | ||||
|             <Box> | ||||
|                 <StatusWithDot> | ||||
|                     <Dot color={theme.palette.error.border} /> | ||||
|                     <Box sx={{ fontWeight: 'bold' }}>Stale</Box> | ||||
|                     {hideLinks ? null : ( | ||||
|                         <Link to={`/projects/${projectId}?state=IS%3Astale`}> | ||||
|                             (view flags) | ||||
|                         </Link> | ||||
|                     )} | ||||
|                 </StatusWithDot> | ||||
|                 <FlagsCount>{staleCount} feature flags</FlagsCount> | ||||
|             </Box> | ||||
|         </FlagCountsWrapper> | ||||
|     ); | ||||
| }; | ||||
| @ -1,58 +0,0 @@ | ||||
| import { ProjectHealthChart } from './ProjectHealthChart.tsx'; | ||||
| import { Alert, Box, styled, Typography } from '@mui/material'; | ||||
| import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; | ||||
| import type { ProjectInsightsSchemaHealth } from 'openapi'; | ||||
| import type { FC } from 'react'; | ||||
| import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; | ||||
| import { FlagCounts } from './FlagCounts.tsx'; | ||||
| 
 | ||||
| const Container = styled(Box)(({ theme }) => ({ | ||||
|     display: 'flex', | ||||
|     flexDirection: 'column', | ||||
|     gap: theme.spacing(2), | ||||
| })); | ||||
| 
 | ||||
| export const ProjectHealth: FC<{ health: ProjectInsightsSchemaHealth }> = ({ | ||||
|     health, | ||||
| }) => { | ||||
|     const projectId = useRequiredPathParam('projectId'); | ||||
|     const { staleCount, potentiallyStaleCount, activeCount, rating } = health; | ||||
| 
 | ||||
|     return ( | ||||
|         <Container> | ||||
|             <Typography variant='h3'>Project Health</Typography> | ||||
|             <ConditionallyRender | ||||
|                 condition={staleCount > 0} | ||||
|                 show={ | ||||
|                     <Alert severity='warning'> | ||||
|                         <b>Health alert!</b> Review your flags and delete the | ||||
|                         stale flags | ||||
|                     </Alert> | ||||
|                 } | ||||
|             /> | ||||
| 
 | ||||
|             <Box | ||||
|                 data-loading | ||||
|                 sx={(theme) => ({ | ||||
|                     display: 'flex', | ||||
|                     gap: theme.spacing(4), | ||||
|                     marginTop: theme.spacing(3), | ||||
|                 })} | ||||
|             > | ||||
|                 <ProjectHealthChart | ||||
|                     active={activeCount} | ||||
|                     stale={staleCount} | ||||
|                     potentiallyStale={potentiallyStaleCount} | ||||
|                     health={rating} | ||||
|                 /> | ||||
| 
 | ||||
|                 <FlagCounts | ||||
|                     projectId={projectId} | ||||
|                     activeCount={activeCount} | ||||
|                     potentiallyStaleCount={potentiallyStaleCount} | ||||
|                     staleCount={staleCount} | ||||
|                 /> | ||||
|             </Box> | ||||
|         </Container> | ||||
|     ); | ||||
| }; | ||||
| @ -1,143 +0,0 @@ | ||||
| import '@testing-library/jest-dom'; | ||||
| import { ProjectHealthChart } from './ProjectHealthChart.tsx'; | ||||
| import { render } from 'utils/testRenderer'; | ||||
| import { screen } from '@testing-library/react'; | ||||
| 
 | ||||
| describe('ProjectHealthChart', () => { | ||||
|     test('renders correctly with no flags', () => { | ||||
|         const { container } = render( | ||||
|             <ProjectHealthChart | ||||
|                 active={0} | ||||
|                 stale={0} | ||||
|                 potentiallyStale={0} | ||||
|                 health={0} | ||||
|             />, | ||||
|         ); | ||||
| 
 | ||||
|         const activeCircle = container.querySelector( | ||||
|             'circle[data-testid="active-circle"]', | ||||
|         ); | ||||
|         const staleCircle = container.querySelector( | ||||
|             'circle[data-testid="stale-circle"]', | ||||
|         ); | ||||
|         const potentiallyStaleCircle = container.querySelector( | ||||
|             'circle[data-testid="potentially-stale-circle"]', | ||||
|         ); | ||||
| 
 | ||||
|         expect(activeCircle).toBeInTheDocument(); | ||||
|         expect(staleCircle).not.toBeInTheDocument(); | ||||
|         expect(potentiallyStaleCircle).not.toBeInTheDocument(); | ||||
|     }); | ||||
| 
 | ||||
|     test('renders correctly with 1 active and 0 stale', () => { | ||||
|         const { container } = render( | ||||
|             <ProjectHealthChart | ||||
|                 active={1} | ||||
|                 stale={0} | ||||
|                 potentiallyStale={0} | ||||
|                 health={100} | ||||
|             />, | ||||
|         ); | ||||
| 
 | ||||
|         const activeCircle = container.querySelector( | ||||
|             'circle[data-testid="active-circle"]', | ||||
|         ); | ||||
|         const staleCircle = container.querySelector( | ||||
|             'circle[data-testid="stale-circle"]', | ||||
|         ); | ||||
|         const potentiallyStaleCircle = container.querySelector( | ||||
|             'circle[data-testid="potentially-stale-circle"]', | ||||
|         ); | ||||
| 
 | ||||
|         expect(activeCircle).toBeInTheDocument(); | ||||
|         expect(staleCircle).not.toBeInTheDocument(); | ||||
|         expect(potentiallyStaleCircle).not.toBeInTheDocument(); | ||||
|     }); | ||||
| 
 | ||||
|     test('renders correctly with 0 active and 1 stale', () => { | ||||
|         const { container } = render( | ||||
|             <ProjectHealthChart | ||||
|                 active={0} | ||||
|                 stale={1} | ||||
|                 potentiallyStale={0} | ||||
|                 health={0} | ||||
|             />, | ||||
|         ); | ||||
| 
 | ||||
|         const staleCircle = container.querySelector( | ||||
|             'circle[data-testid="stale-circle"]', | ||||
|         ); | ||||
| 
 | ||||
|         expect(staleCircle).toBeInTheDocument(); | ||||
|     }); | ||||
| 
 | ||||
|     test('renders correctly with active, stale and potentially stale', () => { | ||||
|         const { container } = render( | ||||
|             <ProjectHealthChart | ||||
|                 active={2} | ||||
|                 stale={1} | ||||
|                 potentiallyStale={1} | ||||
|                 health={50} | ||||
|             />, | ||||
|         ); | ||||
| 
 | ||||
|         const activeCircle = container.querySelector( | ||||
|             'circle[data-testid="active-circle"]', | ||||
|         ); | ||||
|         const staleCircle = container.querySelector( | ||||
|             'circle[data-testid="stale-circle"]', | ||||
|         ); | ||||
|         const potentiallyStaleCircle = container.querySelector( | ||||
|             'circle[data-testid="potentially-stale-circle"]', | ||||
|         ); | ||||
| 
 | ||||
|         expect(activeCircle).toBeInTheDocument(); | ||||
|         expect(staleCircle).toBeInTheDocument(); | ||||
|         expect(potentiallyStaleCircle).toBeInTheDocument(); | ||||
|     }); | ||||
| 
 | ||||
|     test('renders flags count and health', () => { | ||||
|         const { container } = render( | ||||
|             <ProjectHealthChart | ||||
|                 active={2} | ||||
|                 stale={1} | ||||
|                 potentiallyStale={1} | ||||
|                 health={50} | ||||
|             />, | ||||
|         ); | ||||
| 
 | ||||
|         expect(screen.queryByText('3 flags')).toBeInTheDocument(); | ||||
|         expect(screen.queryByText('50%')).toBeInTheDocument(); | ||||
|     }); | ||||
| 
 | ||||
|     test('renders small values without negative stroke dasharray', () => { | ||||
|         const { container } = render( | ||||
|             <ProjectHealthChart | ||||
|                 active={1000} | ||||
|                 stale={1} | ||||
|                 potentiallyStale={1} | ||||
|                 health={50} | ||||
|             />, | ||||
|         ); | ||||
| 
 | ||||
|         const activeCircle = container.querySelector( | ||||
|             'circle[data-testid="active-circle"]', | ||||
|         ); | ||||
|         const staleCircle = container.querySelector( | ||||
|             'circle[data-testid="stale-circle"]', | ||||
|         ); | ||||
|         const potentiallyStaleCircle = container.querySelector( | ||||
|             'circle[data-testid="potentially-stale-circle"]', | ||||
|         ); | ||||
| 
 | ||||
|         expect( | ||||
|             activeCircle?.getAttribute('stroke-dasharray')?.charAt(0), | ||||
|         ).not.toBe('-'); | ||||
|         expect( | ||||
|             staleCircle?.getAttribute('stroke-dasharray')?.charAt(0), | ||||
|         ).not.toBe('-'); | ||||
|         expect( | ||||
|             potentiallyStaleCircle?.getAttribute('stroke-dasharray')?.charAt(0), | ||||
|         ).not.toBe('-'); | ||||
|     }); | ||||
| }); | ||||
| @ -1,142 +0,0 @@ | ||||
| import type React from 'react'; | ||||
| import { useTheme } from '@mui/material'; | ||||
| import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; | ||||
| 
 | ||||
| interface ProgressComponentProps { | ||||
|     active: number; | ||||
|     stale: number; | ||||
|     potentiallyStale: number; | ||||
|     health: number; | ||||
| } | ||||
| 
 | ||||
| export const ProjectHealthChart: React.FC<ProgressComponentProps> = ({ | ||||
|     active, | ||||
|     stale, | ||||
|     potentiallyStale, | ||||
|     health, | ||||
| }) => { | ||||
|     const theme = useTheme(); | ||||
|     const gap = | ||||
|         active === 0 || | ||||
|         stale === 0 || | ||||
|         active / stale > 30 || | ||||
|         stale / active > 30 | ||||
|             ? 0 | ||||
|             : 10; | ||||
|     const strokeWidth = 6; | ||||
|     const radius = 50 - strokeWidth / 2; | ||||
|     const circumference = 2 * Math.PI * radius; | ||||
|     const gapAngle = (gap / circumference) * 360; | ||||
| 
 | ||||
|     const totalCount = active + stale; | ||||
|     const activePercentage = | ||||
|         totalCount === 0 ? 100 : (active / totalCount) * 100; | ||||
|     const stalePercentage = totalCount === 0 ? 0 : (stale / totalCount) * 100; | ||||
|     const potentiallyStalePercentage = | ||||
|         active === 0 ? 0 : (potentiallyStale / totalCount) * 100; | ||||
| 
 | ||||
|     const activeLength = Math.max( | ||||
|         (activePercentage / 100) * circumference - gap, | ||||
|         1, | ||||
|     ); | ||||
|     const staleLength = Math.max( | ||||
|         (stalePercentage / 100) * circumference - gap, | ||||
|         1, | ||||
|     ); | ||||
|     const potentiallyStaleLength = Math.max( | ||||
|         (potentiallyStalePercentage / 100) * circumference - gap, | ||||
|         1, | ||||
|     ); | ||||
| 
 | ||||
|     const activeRotation = -90 + gapAngle / 2; | ||||
|     const potentiallyStaleRotation = | ||||
|         activeRotation + | ||||
|         ((activeLength - potentiallyStaleLength) / circumference) * 360; | ||||
|     const staleRotation = | ||||
|         activeRotation + (activeLength / circumference) * 360 + gapAngle; | ||||
| 
 | ||||
|     const innerRadius = radius / 1.2; | ||||
| 
 | ||||
|     return ( | ||||
|         <svg width='170' height='170' viewBox='0 0 100 100'> | ||||
|             <title>Project Health Chart</title> | ||||
|             <circle | ||||
|                 data-testid='active-circle' | ||||
|                 cx='50' | ||||
|                 cy='50' | ||||
|                 r={radius} | ||||
|                 fill='none' | ||||
|                 stroke={theme.palette.success.border} | ||||
|                 strokeWidth={strokeWidth} | ||||
|                 strokeLinecap='round' | ||||
|                 strokeDasharray={`${activeLength} ${circumference}`} | ||||
|                 transform={`rotate(${activeRotation} 50 50)`} | ||||
|             /> | ||||
| 
 | ||||
|             <ConditionallyRender | ||||
|                 condition={potentiallyStale > 0} | ||||
|                 show={ | ||||
|                     <circle | ||||
|                         data-testid='potentially-stale-circle' | ||||
|                         cx='50' | ||||
|                         cy='50' | ||||
|                         r={radius} | ||||
|                         fill='none' | ||||
|                         stroke={theme.palette.warning.border} | ||||
|                         strokeWidth={strokeWidth} | ||||
|                         strokeLinecap='round' | ||||
|                         strokeDasharray={`${potentiallyStaleLength} ${circumference}`} | ||||
|                         transform={`rotate(${potentiallyStaleRotation} 50 50)`} | ||||
|                     /> | ||||
|                 } | ||||
|             /> | ||||
| 
 | ||||
|             <ConditionallyRender | ||||
|                 condition={stale > 0} | ||||
|                 show={ | ||||
|                     <circle | ||||
|                         data-testid='stale-circle' | ||||
|                         cx='50' | ||||
|                         cy='50' | ||||
|                         r={radius} | ||||
|                         fill='none' | ||||
|                         stroke={theme.palette.error.border} | ||||
|                         strokeWidth={strokeWidth} | ||||
|                         strokeLinecap='round' | ||||
|                         strokeDasharray={`${staleLength} ${circumference}`} | ||||
|                         transform={`rotate(${staleRotation} 50 50)`} | ||||
|                     /> | ||||
|                 } | ||||
|             /> | ||||
| 
 | ||||
|             <circle | ||||
|                 cx='50' | ||||
|                 cy='50' | ||||
|                 r={innerRadius} | ||||
|                 fill={theme.palette.warning.light} | ||||
|             /> | ||||
| 
 | ||||
|             <text | ||||
|                 x='50%' | ||||
|                 y='50%' | ||||
|                 fill={theme.palette.text.primary} | ||||
|                 fontSize={theme.spacing(2.25)} | ||||
|                 textAnchor='middle' | ||||
|                 fontWeight='bold' | ||||
|             > | ||||
|                 {health}% | ||||
|             </text> | ||||
|             <text | ||||
|                 x='50%' | ||||
|                 y='50%' | ||||
|                 dy='1.5em' | ||||
|                 fill={theme.palette.text.secondary} | ||||
|                 fontSize={theme.spacing(1.25)} | ||||
|                 textAnchor='middle' | ||||
|                 fontWeight='normal' | ||||
|             > | ||||
|                 {active + stale} flags | ||||
|             </text> | ||||
|         </svg> | ||||
|     ); | ||||
| }; | ||||
| @ -1,71 +0,0 @@ | ||||
| import { Box, styled } from '@mui/material'; | ||||
| import { ChangeRequests } from './ChangeRequests/ChangeRequests.tsx'; | ||||
| import { LeadTimeForChanges } from './LeadTimeForChanges/LeadTimeForChanges.tsx'; | ||||
| import { ProjectHealth } from './ProjectHealth/ProjectHealth.tsx'; | ||||
| import { FlagTypesUsed } from './FlagTypesUsed/FlagTypesUsed.tsx'; | ||||
| import { ProjectInsightsStats } from './ProjectInsightsStats/ProjectInsightsStats.tsx'; | ||||
| import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; | ||||
| import { useProjectInsights } from 'hooks/api/getters/useProjectInsights/useProjectInsights'; | ||||
| import useLoading from 'hooks/useLoading'; | ||||
| import { ProjectMembers } from './ProjectMembers/ProjectMembers.tsx'; | ||||
| 
 | ||||
| const Container = styled(Box)(({ theme }) => ({ | ||||
|     backgroundColor: theme.palette.background.paper, | ||||
|     padding: theme.spacing(3), | ||||
|     borderRadius: theme.shape.borderRadiusLarge, | ||||
| })); | ||||
| 
 | ||||
| const Grid = styled(Box)(({ theme }) => ({ | ||||
|     display: 'grid', | ||||
|     gap: theme.spacing(2), | ||||
|     gridTemplateColumns: 'repeat(10, 1fr)', | ||||
| })); | ||||
| 
 | ||||
| const FullWidthContainer = styled(Box)(() => ({ | ||||
|     gridColumn: '1 / -1', | ||||
| })); | ||||
| 
 | ||||
| const WideContainer = styled(Container)(() => ({ | ||||
|     gridColumn: 'span 6', | ||||
| })); | ||||
| 
 | ||||
| const MediumWideContainer = styled(Container)(() => ({ | ||||
|     gridColumn: 'span 4', | ||||
| })); | ||||
| 
 | ||||
| const NarrowContainer = styled(Container)(() => ({ | ||||
|     gridColumn: 'span 2', | ||||
| })); | ||||
| 
 | ||||
| export const ProjectInsights = () => { | ||||
|     const projectId = useRequiredPathParam('projectId'); | ||||
|     const { data, loading } = useProjectInsights(projectId); | ||||
| 
 | ||||
|     const ref = useLoading(loading); | ||||
| 
 | ||||
|     return ( | ||||
|         <Grid ref={ref}> | ||||
|             <FullWidthContainer> | ||||
|                 <ProjectInsightsStats stats={data.stats} /> | ||||
|             </FullWidthContainer> | ||||
|             <MediumWideContainer> | ||||
|                 <ProjectHealth health={data.health} /> | ||||
|             </MediumWideContainer> | ||||
|             <WideContainer> | ||||
|                 <LeadTimeForChanges | ||||
|                     leadTime={data.leadTime} | ||||
|                     loading={loading} | ||||
|                 /> | ||||
|             </WideContainer> | ||||
|             <NarrowContainer> | ||||
|                 <FlagTypesUsed featureTypeCounts={data.featureTypeCounts} /> | ||||
|             </NarrowContainer> | ||||
|             <NarrowContainer sx={{ padding: 0 }}> | ||||
|                 <ProjectMembers projectId={projectId} members={data.members} /> | ||||
|             </NarrowContainer> | ||||
|             <WideContainer> | ||||
|                 <ChangeRequests /> | ||||
|             </WideContainer> | ||||
|         </Grid> | ||||
|     ); | ||||
| }; | ||||
| @ -1,78 +0,0 @@ | ||||
| import type React from 'react'; | ||||
| 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; | ||||
|     children?: React.ReactNode; | ||||
| } | ||||
| 
 | ||||
| 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> | ||||
|             <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> | ||||
|     ); | ||||
| }; | ||||
| @ -1,119 +0,0 @@ | ||||
| import { Box, styled, Typography } from '@mui/material'; | ||||
| import type { ProjectStatsSchema } from 'openapi/models'; | ||||
| import { HelpPopper } from './HelpPopper.tsx'; | ||||
| import { StatusBox } from './StatusBox.tsx'; | ||||
| import KeyboardArrowRight from '@mui/icons-material/KeyboardArrowRight'; | ||||
| import { Link } from 'react-router-dom'; | ||||
| import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; | ||||
| 
 | ||||
| const StyledBox = styled(Box)(({ theme }) => ({ | ||||
|     display: 'grid', | ||||
|     gap: theme.spacing(2), | ||||
|     gridTemplateColumns: 'repeat(4, 1fr)', | ||||
|     flexWrap: 'wrap', | ||||
|     [theme.breakpoints.down('lg')]: { | ||||
|         gridTemplateColumns: 'repeat(2, 1fr)', | ||||
|     }, | ||||
|     [theme.breakpoints.down('sm')]: { | ||||
|         flexDirection: 'column', | ||||
|     }, | ||||
| })); | ||||
| 
 | ||||
| const StyledTimeToProductionDescription = styled(Typography)(({ theme }) => ({ | ||||
|     color: theme.palette.text.secondary, | ||||
|     fontSize: theme.typography.body2.fontSize, | ||||
|     lineHeight: theme.typography.body2.lineHeight, | ||||
| })); | ||||
| 
 | ||||
| const NavigationBar = styled(Link)(({ theme }) => ({ | ||||
|     marginLeft: 'auto', | ||||
|     display: 'flex', | ||||
|     justifyContent: 'space-between', | ||||
|     textDecoration: 'none', | ||||
|     color: theme.palette.text.primary, | ||||
| })); | ||||
| 
 | ||||
| interface IProjectStatsProps { | ||||
|     stats: ProjectStatsSchema; | ||||
| } | ||||
| 
 | ||||
| export const ProjectInsightsStats = ({ stats }: IProjectStatsProps) => { | ||||
|     const projectId = useRequiredPathParam('projectId'); | ||||
|     if (Object.keys(stats).length === 0) { | ||||
|         return null; | ||||
|     } | ||||
| 
 | ||||
|     const { | ||||
|         avgTimeToProdCurrentWindow, | ||||
|         projectActivityCurrentWindow, | ||||
|         projectActivityPastWindow, | ||||
|         createdCurrentWindow, | ||||
|         createdPastWindow, | ||||
|         archivedCurrentWindow, | ||||
|         archivedPastWindow, | ||||
|     } = stats; | ||||
| 
 | ||||
|     return ( | ||||
|         <StyledBox> | ||||
|             <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> | ||||
|             <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 flag was | ||||
|                     created until it was enabled in an environment of type | ||||
|                     production. This is calculated only from feature flags with | ||||
|                     the type of "release". | ||||
|                 </HelpPopper> | ||||
|             </StatusBox> | ||||
|             <StatusBox | ||||
|                 title='Features created' | ||||
|                 boxText={String(createdCurrentWindow)} | ||||
|                 change={createdCurrentWindow - createdPastWindow} | ||||
|             > | ||||
|                 <NavigationBar to={`/projects/${projectId}`}> | ||||
|                     <KeyboardArrowRight /> | ||||
|                 </NavigationBar> | ||||
|             </StatusBox> | ||||
| 
 | ||||
|             <StatusBox | ||||
|                 title='Features archived' | ||||
|                 boxText={String(archivedCurrentWindow)} | ||||
|                 change={archivedCurrentWindow - archivedPastWindow} | ||||
|             > | ||||
|                 <NavigationBar to={`/projects/${projectId}/archive`}> | ||||
|                     <KeyboardArrowRight /> | ||||
|                 </NavigationBar> | ||||
|             </StatusBox> | ||||
|         </StyledBox> | ||||
|     ); | ||||
| }; | ||||
| @ -1,149 +0,0 @@ | ||||
| import type React from 'react'; | ||||
| 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 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, | ||||
| })); | ||||
| 
 | ||||
| const RowContainer = styled(Box)(({ theme }) => ({ | ||||
|     ...flexRow, | ||||
| })); | ||||
| 
 | ||||
| const StyledWidget = styled(Box)(({ theme }) => ({ | ||||
|     padding: theme.spacing(3), | ||||
|     backgroundColor: theme.palette.background.paper, | ||||
|     flex: 1, | ||||
|     display: 'flex', | ||||
|     flexDirection: 'column', | ||||
|     gap: theme.spacing(2.5), | ||||
|     borderRadius: `${theme.shape.borderRadiusLarge}px`, | ||||
|     [theme.breakpoints.down('lg')]: { | ||||
|         padding: theme.spacing(2), | ||||
|     }, | ||||
| })); | ||||
| 
 | ||||
| interface IStatusBoxProps { | ||||
|     title: string; | ||||
|     boxText: ReactNode; | ||||
|     change?: number; | ||||
|     percentage?: boolean; | ||||
|     customChangeElement?: ReactNode; | ||||
|     children?: React.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, | ||||
| }) => ( | ||||
|     <StyledWidget> | ||||
|         <RowContainer> | ||||
|             <Typography variant='h3' data-loading> | ||||
|                 {title} | ||||
|             </Typography> | ||||
|             {children} | ||||
|         </RowContainer> | ||||
|         <RowContainer> | ||||
|             <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> | ||||
|                         } | ||||
|                     /> | ||||
|                 } | ||||
|             /> | ||||
|         </RowContainer> | ||||
|     </StyledWidget> | ||||
| ); | ||||
| @ -1,15 +0,0 @@ | ||||
| import { screen } from '@testing-library/react'; | ||||
| import { render } from 'utils/testRenderer'; | ||||
| import { ProjectMembers } from './ProjectMembers.tsx'; | ||||
| 
 | ||||
| test('Show outdated project members', async () => { | ||||
|     const members = { | ||||
|         currentMembers: 10, | ||||
|         change: 2, | ||||
|     }; | ||||
| 
 | ||||
|     render(<ProjectMembers projectId={'default'} members={members} />); | ||||
| 
 | ||||
|     await screen.findByText('10'); | ||||
|     await screen.findByText('+2'); | ||||
| }); | ||||
| @ -1,43 +0,0 @@ | ||||
| import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; | ||||
| import { styled } from '@mui/material'; | ||||
| import { StatusBox } from '../ProjectInsightsStats/StatusBox.tsx'; | ||||
| import KeyboardArrowRight from '@mui/icons-material/KeyboardArrowRight'; | ||||
| import { Link } from 'react-router-dom'; | ||||
| import type { ProjectInsightsSchemaMembers } from 'openapi'; | ||||
| 
 | ||||
| interface IProjectMembersProps { | ||||
|     members: ProjectInsightsSchemaMembers; | ||||
|     projectId: string; | ||||
| } | ||||
| 
 | ||||
| const NavigationBar = styled(Link)(({ theme }) => ({ | ||||
|     marginLeft: 'auto', | ||||
|     display: 'flex', | ||||
|     justifyContent: 'space-between', | ||||
|     textDecoration: 'none', | ||||
|     color: theme.palette.text.primary, | ||||
| })); | ||||
| 
 | ||||
| export const ProjectMembers = ({ | ||||
|     members, | ||||
|     projectId, | ||||
| }: IProjectMembersProps) => { | ||||
|     const { uiConfig } = useUiConfig(); | ||||
| 
 | ||||
|     const link = uiConfig?.versionInfo?.current?.enterprise | ||||
|         ? `/projects/${projectId}/settings/access` | ||||
|         : `/admin/users`; | ||||
| 
 | ||||
|     const { currentMembers, change } = members; | ||||
|     return ( | ||||
|         <StatusBox | ||||
|             title={'Project members'} | ||||
|             boxText={`${currentMembers}`} | ||||
|             change={change} | ||||
|         > | ||||
|             <NavigationBar to={link}> | ||||
|                 <KeyboardArrowRight /> | ||||
|             </NavigationBar> | ||||
|         </StatusBox> | ||||
|     ); | ||||
| }; | ||||
| @ -1,56 +0,0 @@ | ||||
| import { fetcher, useApiGetter } from '../useApiGetter/useApiGetter.js'; | ||||
| import type { ProjectInsightsSchema } from 'openapi'; | ||||
| import { formatApiPath } from 'utils/formatPath'; | ||||
| 
 | ||||
| const path = (projectId: string) => `api/admin/projects/${projectId}/insights`; | ||||
| 
 | ||||
| const placeholderData: ProjectInsightsSchema = { | ||||
|     stats: { | ||||
|         avgTimeToProdCurrentWindow: 0, | ||||
|         createdCurrentWindow: 0, | ||||
|         createdPastWindow: 0, | ||||
|         archivedCurrentWindow: 0, | ||||
|         archivedPastWindow: 0, | ||||
|         projectActivityCurrentWindow: 0, | ||||
|         projectActivityPastWindow: 0, | ||||
|         projectMembersAddedCurrentWindow: 0, | ||||
|     }, | ||||
|     featureTypeCounts: [ | ||||
|         { | ||||
|             type: 'experiment', | ||||
|             count: 0, | ||||
|         }, | ||||
|         { | ||||
|             type: 'permission', | ||||
|             count: 0, | ||||
|         }, | ||||
|         { | ||||
|             type: 'release', | ||||
|             count: 0, | ||||
|         }, | ||||
|     ], | ||||
|     leadTime: { | ||||
|         projectAverage: 0, | ||||
|         features: [], | ||||
|     }, | ||||
|     health: { | ||||
|         rating: 0, | ||||
|         activeCount: 0, | ||||
|         potentiallyStaleCount: 0, | ||||
|         staleCount: 0, | ||||
|     }, | ||||
|     members: { | ||||
|         currentMembers: 0, | ||||
|         change: 0, | ||||
|     }, | ||||
| }; | ||||
| 
 | ||||
| export const useProjectInsights = (projectId: string) => { | ||||
|     const projectPath = formatApiPath(path(projectId)); | ||||
|     const { data, refetch, loading, error } = | ||||
|         useApiGetter<ProjectInsightsSchema>(projectPath, () => | ||||
|             fetcher(projectPath, 'Project Insights'), | ||||
|         ); | ||||
| 
 | ||||
|     return { data: data || placeholderData, refetch, loading, error }; | ||||
| }; | ||||
| @ -30,6 +30,7 @@ export default class ProjectInsightsController extends Controller { | ||||
|         this.openApiService = services.openApiService; | ||||
|         this.flagResolver = config.flagResolver; | ||||
| 
 | ||||
|         // TODO: Remove in v8. This endpoint is deprecated and no longer used by the UI.
 | ||||
|         this.route({ | ||||
|             method: 'get', | ||||
|             path: '/:projectId/insights', | ||||
| @ -37,6 +38,7 @@ export default class ProjectInsightsController extends Controller { | ||||
|             permission: NONE, | ||||
|             middleware: [ | ||||
|                 this.openApiService.validPath({ | ||||
|                     deprecated: true, | ||||
|                     tags: ['Projects'], | ||||
|                     operationId: 'getProjectInsights', | ||||
|                     summary: 'Get an overview of a project insights.', | ||||
|  | ||||
| @ -146,6 +146,7 @@ export default class ProjectController extends Controller { | ||||
|             permission: NONE, | ||||
|             middleware: [ | ||||
|                 this.openApiService.validPath({ | ||||
|                     deprecated: true, | ||||
|                     tags: ['Projects'], | ||||
|                     operationId: 'getProjectDora', | ||||
|                     summary: 'Get an overview project dora metrics.', | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user