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:
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