1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-07-21 13:47:39 +02: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:
Nuno Góis 2025-06-03 09:21:55 +01:00 committed by GitHub
parent e474abb946
commit 6d70265edd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 3 additions and 1638 deletions

View File

@ -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'

View File

@ -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');
});

View File

@ -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>
);
};

View File

@ -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');
});

View File

@ -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>
);
};

View File

@ -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');
});

View File

@ -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 &ldquo;
{globalFilter}
&rdquo;
</TablePlaceholder>
}
/>
}
/>
</Container>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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('-');
});
});

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);

View File

@ -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');
});

View File

@ -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>
);
};

View File

@ -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 };
};

View File

@ -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.',

View File

@ -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.',