1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-31 00:16:47 +01:00

Insights dashboard refactor (#6404)

- reorganized dashboard components
- added share link
- health chart aggregated data
- refactored chart placeholders
This commit is contained in:
Tymoteusz Czech 2024-03-04 12:56:17 +01:00 committed by GitHub
parent 493f8e8a5b
commit 4fc0a806f1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 564 additions and 401 deletions

View File

@ -12,7 +12,9 @@ export const allOption = { label: 'ALL', id: '*' };
interface IProjectSelectProps { interface IProjectSelectProps {
selectedProjects: string[]; selectedProjects: string[];
onChange: Dispatch<SetStateAction<string[]>>; onChange:
| Dispatch<SetStateAction<string[]>>
| ((projects: string[]) => void);
dataTestId?: string; dataTestId?: string;
sx?: SxProps; sx?: SxProps;
disabled?: boolean; disabled?: boolean;

View File

@ -1,236 +1,195 @@
import { useMemo, useState, VFC } from 'react'; import { VFC } from 'react';
import { Box, styled } from '@mui/material';
import { ArrayParam, withDefault } from 'use-query-params';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { usePersistentTableState } from 'hooks/usePersistentTableState';
import { import {
Box,
styled,
Typography,
useMediaQuery,
useTheme,
} from '@mui/material';
import { UsersChart } from './UsersChart/UsersChart';
import { FlagsChart } from './FlagsChart/FlagsChart';
import { useExecutiveDashboard } from 'hooks/api/getters/useExecutiveSummary/useExecutiveSummary';
import { UserStats } from './UserStats/UserStats';
import { FlagStats } from './FlagStats/FlagStats';
import { Widget } from './Widget/Widget';
import { FlagsProjectChart } from './FlagsProjectChart/FlagsProjectChart';
import { ProjectHealthChart } from './ProjectHealthChart/ProjectHealthChart';
import { TimeToProductionChart } from './TimeToProductionChart/TimeToProductionChart';
import { TimeToProduction } from './TimeToProduction/TimeToProduction';
import {
ProjectSelect,
allOption, allOption,
} from '../common/ProjectSelect/ProjectSelect'; ProjectSelect,
import { MetricsSummaryChart } from './MetricsSummaryChart/MetricsSummaryChart'; } from 'component/common/ProjectSelect/ProjectSelect';
import { import { useExecutiveDashboard } from 'hooks/api/getters/useExecutiveSummary/useExecutiveSummary';
ExecutiveSummarySchemaMetricsSummaryTrendsItem,
ExecutiveSummarySchemaProjectFlagTrendsItem, import { useFilteredFlagsSummary } from './hooks/useFilteredFlagsSummary';
} from 'openapi'; import { useFilteredTrends } from './hooks/useFilteredTrends';
import { HealthStats } from './HealthStats/HealthStats';
import { DashboardHeader } from './DashboardHeader/DashboardHeader'; import { Widget } from './components/Widget/Widget';
import { DashboardHeader } from './components/DashboardHeader/DashboardHeader';
import { UserStats } from './componentsStat/UserStats/UserStats';
import { FlagStats } from './componentsStat/FlagStats/FlagStats';
import { HealthStats } from './componentsStat/HealthStats/HealthStats';
import { TimeToProduction } from './componentsStat/TimeToProduction/TimeToProduction';
import { UsersChart } from './componentsChart/UsersChart/UsersChart';
import { FlagsChart } from './componentsChart/FlagsChart/FlagsChart';
import { FlagsProjectChart } from './componentsChart/FlagsProjectChart/FlagsProjectChart';
import { ProjectHealthChart } from './componentsChart/ProjectHealthChart/ProjectHealthChart';
import { TimeToProductionChart } from './componentsChart/TimeToProductionChart/TimeToProductionChart';
import { MetricsSummaryChart } from './componentsChart/MetricsSummaryChart/MetricsSummaryChart';
import { UsersPerProjectChart } from './componentsChart/UsersPerProjectChart/UsersPerProjectChart';
const StyledGrid = styled(Box)(({ theme }) => ({ const StyledGrid = styled(Box)(({ theme }) => ({
display: 'grid', display: 'grid',
gridTemplateColumns: `300px 1fr`, gridTemplateColumns: `repeat(2, 1fr)`,
gridAutoRows: 'auto', gridAutoRows: 'auto',
gap: theme.spacing(2), gap: theme.spacing(2),
})); paddingBottom: theme.spacing(2),
[theme.breakpoints.up('md')]: {
const StyledBox = styled(Box)(({ theme }) => ({ gridTemplateColumns: `300px 1fr`,
marginBottom: theme.spacing(4),
marginTop: theme.spacing(4),
[theme.breakpoints.down('lg')]: {
width: '100%',
marginLeft: 0,
}, },
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
})); }));
const useDashboardGrid = () => { const ChartWidget = styled(Widget)(({ theme }) => ({
const theme = useTheme(); [theme.breakpoints.down('md')]: {
const isMediumScreen = useMediaQuery(theme.breakpoints.down('lg')); gridColumnStart: 'span 2',
const isSmallScreen = useMediaQuery(theme.breakpoints.down('sm')); order: 2,
},
if (isSmallScreen) { }));
return {
gridTemplateColumns: `1fr`,
chartSpan: 1,
userTrendsOrder: 3,
flagStatsOrder: 2,
largeChartSpan: 1,
};
}
if (isMediumScreen) {
return {
gridTemplateColumns: `1fr 1fr`,
chartSpan: 2,
userTrendsOrder: 3,
flagStatsOrder: 2,
largeChartSpan: 2,
};
}
return {
gridTemplateColumns: `300px auto`,
chartSpan: 1,
userTrendsOrder: 2,
flagStatsOrder: 3,
largeChartSpan: 2,
};
};
interface FilteredProjectData {
filteredProjectFlagTrends: ExecutiveSummarySchemaProjectFlagTrendsItem[];
filteredMetricsSummaryTrends: ExecutiveSummarySchemaMetricsSummaryTrendsItem[];
}
export const ExecutiveDashboard: VFC = () => { export const ExecutiveDashboard: VFC = () => {
const { executiveDashboardData, loading, error } = useExecutiveDashboard(); const { executiveDashboardData, loading, error } = useExecutiveDashboard();
const [projects, setProjects] = useState([allOption.id]); const stateConfig = {
projects: withDefault(ArrayParam, [allOption.id]),
};
const [state, setState] = usePersistentTableState(`insights`, stateConfig);
const setProjects = (projects: string[]) => {
setState({ projects });
};
const projects = state.projects
? (state.projects.filter(Boolean) as string[])
: [];
const showAllProjects = projects[0] === allOption.id;
const projectsData = useFilteredTrends(
executiveDashboardData.projectFlagTrends,
projects,
);
const metricsData = useFilteredTrends(
executiveDashboardData.metricsSummaryTrends,
projects,
);
const { users } = executiveDashboardData;
const flagPerUsers = useMemo(() => { const summary = useFilteredFlagsSummary(projectsData);
if ( const isOneProjectSelected = projects.length === 1;
executiveDashboardData.users.total === 0 ||
executiveDashboardData.flags.total === 0
)
return '0';
return (
executiveDashboardData.flags.total /
executiveDashboardData.users.total
).toFixed(1);
}, [executiveDashboardData]);
const { filteredProjectFlagTrends, filteredMetricsSummaryTrends } =
useMemo<FilteredProjectData>(() => {
if (projects[0] === allOption.id) {
return {
filteredProjectFlagTrends:
executiveDashboardData.projectFlagTrends,
filteredMetricsSummaryTrends:
executiveDashboardData.metricsSummaryTrends,
};
}
const filteredProjectFlagTrends =
executiveDashboardData.projectFlagTrends.filter((trend) =>
projects.includes(trend.project),
) as ExecutiveSummarySchemaProjectFlagTrendsItem[];
const filteredImpressionsSummary =
executiveDashboardData.metricsSummaryTrends.filter((summary) =>
projects.includes(summary.project),
) as ExecutiveSummarySchemaMetricsSummaryTrendsItem[];
return {
filteredProjectFlagTrends,
filteredMetricsSummaryTrends: filteredImpressionsSummary,
};
}, [executiveDashboardData, projects]);
const {
gridTemplateColumns,
chartSpan,
userTrendsOrder,
flagStatsOrder,
largeChartSpan,
} = useDashboardGrid();
return ( return (
<> <>
<Box sx={(theme) => ({ paddingBottom: theme.spacing(4) })}> <Box sx={(theme) => ({ paddingBottom: theme.spacing(4) })}>
<DashboardHeader /> <DashboardHeader
actions={
<ProjectSelect
selectedProjects={projects}
onChange={setProjects}
dataTestId={'DASHBOARD_PROJECT_SELECT'}
sx={{ flex: 1, maxWidth: '360px' }}
/>
}
/>
</Box> </Box>
<StyledGrid sx={{ gridTemplateColumns }}> <StyledGrid>
<Widget title='Total users' order={1}> <ConditionallyRender
<UserStats condition={showAllProjects}
count={executiveDashboardData.users.total} show={
active={executiveDashboardData.users.active} <Widget title='Total users'>
inactive={executiveDashboardData.users.inactive} <UserStats
/> count={users.total}
</Widget> active={users.active}
<Widget title='Users' order={userTrendsOrder} span={chartSpan}> inactive={users.inactive}
<UsersChart />
userTrends={executiveDashboardData.userTrends} </Widget>
isLoading={loading} }
/> elseShow={
</Widget> <Widget
title={
isOneProjectSelected
? 'Users in project'
: 'Users per project on average'
}
>
<UserStats count={summary.averageUsers} />
</Widget>
}
/>
<ConditionallyRender
condition={showAllProjects}
show={
<ChartWidget title='Users'>
<UsersChart
userTrends={executiveDashboardData.userTrends}
isLoading={loading}
/>
</ChartWidget>
}
elseShow={
<ChartWidget title='Users per project'>
<UsersPerProjectChart
projectFlagTrends={projectsData}
/>
</ChartWidget>
}
/>
<Widget <Widget
title='Total flags' title='Total flags'
tooltip='Total flags represent the total active flags (not archived) that currently exist across all projects of your application.' tooltip='Active flags (not archived) that currently exist across selected projects.'
order={flagStatsOrder}
> >
<FlagStats <FlagStats
count={executiveDashboardData.flags.total} count={summary.total}
flagsPerUser={flagPerUsers} flagsPerUser={
showAllProjects
? (summary.total / users.total).toFixed(2)
: ''
}
/> />
</Widget> </Widget>
<Widget title='Number of flags' order={4} span={chartSpan}> <ConditionallyRender
<FlagsChart condition={showAllProjects}
flagTrends={executiveDashboardData.flagTrends} show={
isLoading={loading} <ChartWidget title='Number of flags'>
/> <FlagsChart
</Widget> flagTrends={executiveDashboardData.flagTrends}
</StyledGrid> isLoading={loading}
<StyledBox> />
<Typography variant='h2' component='span'> </ChartWidget>
Insights per project }
</Typography> elseShow={
<ProjectSelect <ChartWidget title='Flags per project'>
selectedProjects={projects} <FlagsProjectChart
onChange={setProjects} projectFlagTrends={projectsData}
dataTestId={'DASHBOARD_PROJECT_SELECT'} />
sx={{ flex: 1, maxWidth: '360px' }} </ChartWidget>
}
/> />
</StyledBox> <Widget title='Average health'>
<StyledGrid>
<Widget
title='Number of flags per project'
order={5}
span={largeChartSpan}
>
<FlagsProjectChart
projectFlagTrends={filteredProjectFlagTrends}
/>
</Widget>
<Widget title='Average health' order={6}>
<HealthStats <HealthStats
// FIXME: data from API value={summary.averageHealth}
value={80} healthy={summary.active}
healthy={4} stale={summary.stale}
stale={1} potentiallyStale={summary.potentiallyStale}
potentiallyStale={0}
/> />
</Widget> </Widget>
<Widget title='Health per project' order={7} span={chartSpan}> <ChartWidget
<ProjectHealthChart title={
projectFlagTrends={filteredProjectFlagTrends} showAllProjects ? 'Healthy flags' : 'Health per project'
/> }
</Widget>
<Widget
title='Metrics over time per project'
order={8}
span={largeChartSpan}
> >
<MetricsSummaryChart <ProjectHealthChart
metricsSummaryTrends={filteredMetricsSummaryTrends} projectFlagTrends={projectsData}
isAggregate={showAllProjects}
/> />
</Widget> </ChartWidget>
{/* <Widget title='Average time to production'>
<Widget title='Average time to production' order={9}>
<TimeToProduction <TimeToProduction
//FIXME: data from API //FIXME: data from API
daysToProduction={5.2} daysToProduction={5.2}
/> />
</Widget> </Widget>
<Widget title='Time to production' order={10} span={chartSpan}> <ChartWidget title='Time to production'>
<TimeToProductionChart <TimeToProductionChart projectFlagTrends={projectsData} />
projectFlagTrends={filteredProjectFlagTrends} </ChartWidget> */}
/>
</Widget>
</StyledGrid> </StyledGrid>
<Widget title='Metrics'>
<MetricsSummaryChart metricsSummaryTrends={metricsData} />
</Widget>
</> </>
); );
}; };

View File

@ -1,27 +0,0 @@
import { type VFC } from 'react';
import 'chartjs-adapter-date-fns';
import { ExecutiveSummarySchema } from 'openapi';
import { LineChart } from '../LineChart/LineChart';
import { useProjectChartData } from '../useProjectChartData';
interface IFlagsProjectChartProps {
projectFlagTrends: ExecutiveSummarySchema['projectFlagTrends'];
}
export const FlagsProjectChart: VFC<IFlagsProjectChartProps> = ({
projectFlagTrends,
}) => {
const data = useProjectChartData(projectFlagTrends);
return (
<LineChart
data={data}
isLocalTooltip
overrideOptions={{
parsing: {
yAxisKey: 'total',
xAxisKey: 'date',
},
}}
/>
);
};

View File

@ -1,27 +0,0 @@
import 'chartjs-adapter-date-fns';
import { type VFC } from 'react';
import { type ExecutiveSummarySchema } from 'openapi';
import { HealthTooltip } from './HealthChartTooltip/HealthChartTooltip';
import { LineChart } from '../LineChart/LineChart';
import { useProjectChartData } from '../useProjectChartData';
interface IFlagsProjectChartProps {
projectFlagTrends: ExecutiveSummarySchema['projectFlagTrends'];
}
export const ProjectHealthChart: VFC<IFlagsProjectChartProps> = ({
projectFlagTrends,
}) => {
const data = useProjectChartData(projectFlagTrends);
return (
<LineChart
data={data}
isLocalTooltip
TooltipComponent={HealthTooltip}
overrideOptions={{
parsing: { yAxisKey: 'health', xAxisKey: 'date' },
}}
/>
);
};

View File

@ -1,11 +1,17 @@
import { VFC } from 'react'; import { ReactNode, VFC } from 'react';
import { useUiFlag } from 'hooks/useUiFlag'; import { useUiFlag } from 'hooks/useUiFlag';
import { useFeedback } from 'component/feedbackNew/useFeedback'; import { useFeedback } from 'component/feedbackNew/useFeedback';
import { ReviewsOutlined } from '@mui/icons-material'; import { ReviewsOutlined } from '@mui/icons-material';
import { Badge, Button, Typography } from '@mui/material'; import { Button, Typography } from '@mui/material';
import { PageHeader } from 'component/common/PageHeader/PageHeader'; import { PageHeader } from 'component/common/PageHeader/PageHeader';
import { Badge } from 'component/common/Badge/Badge';
import { ShareLink } from './ShareLink/ShareLink';
export const DashboardHeader: VFC = () => { type DashboardHeaderProps = {
actions?: ReactNode;
};
export const DashboardHeader: VFC<DashboardHeaderProps> = ({ actions }) => {
const showInactiveUsers = useUiFlag('showInactiveUsers'); const showInactiveUsers = useUiFlag('showInactiveUsers');
const { openFeedback } = useFeedback( const { openFeedback } = useFeedback(
@ -34,18 +40,21 @@ export const DashboardHeader: VFC = () => {
gap: theme.spacing(1), gap: theme.spacing(1),
})} })}
> >
<span>Insights</span> <Badge color='warning'>Beta</Badge> <span>Insights</span> <Badge color='success'>Beta</Badge>
</Typography> </Typography>
} }
actions={ actions={
<Button <>
startIcon={<ReviewsOutlined />} {actions}
variant='outlined' <ShareLink />
onClick={createFeedbackContext} <Button
size='small' startIcon={<ReviewsOutlined />}
> variant='outlined'
Provide feedback onClick={createFeedbackContext}
</Button> >
Provide feedback
</Button>
</>
} }
/> />
); );

View File

@ -0,0 +1,41 @@
import { VFC, useState } from 'react';
import { Share } from '@mui/icons-material';
import { Box, Button, Typography } from '@mui/material';
import { Dialogue } from 'component/common/Dialogue/Dialogue';
import { LinkField } from 'component/admin/users/LinkField/LinkField';
export const ShareLink: VFC = () => {
const [isOpen, setIsOpen] = useState(false);
const url = new URL(window.location.href);
url.searchParams.set('share', 'true');
return (
<>
<Button
startIcon={<Share />}
variant='outlined'
onClick={() => setIsOpen(true)}
>
Share
</Button>
<Dialogue
open={isOpen}
onClick={() => setIsOpen(false)}
primaryButtonText='Close'
title='Share insights'
>
<Box>
<Typography variant='body1'>
Link below will lead to insights dashboard with
currently selected filter.
</Typography>
<LinkField
inviteLink={url.toString()}
successTitle='Successfully copied the link.'
errorTitle='Could not copy the link.'
/>
</Box>
</Dialogue>
</>
);
};

View File

@ -92,7 +92,7 @@ const LineChartComponent: VFC<{
}: { tooltip: TooltipState | null }) => ReturnType<VFC>; }: { tooltip: TooltipState | null }) => ReturnType<VFC>;
}> = ({ }> = ({
data, data,
aspectRatio, aspectRatio = 2.5,
cover, cover,
isLocalTooltip, isLocalTooltip,
overrideOptions, overrideOptions,
@ -122,8 +122,8 @@ const LineChartComponent: VFC<{
options={options} options={options}
data={data} data={data}
plugins={[customHighlightPlugin]} plugins={[customHighlightPlugin]}
height={aspectRatio ? 100 : undefined} height={100}
width={aspectRatio ? 100 * aspectRatio : undefined} width={100 * aspectRatio}
/> />
<ConditionallyRender <ConditionallyRender
condition={!cover} condition={!cover}

View File

@ -62,7 +62,7 @@ export const createOptions = (
x: { x: {
type: 'time', type: 'time',
time: { time: {
unit: 'day', unit: 'week',
tooltipFormat: 'PPP', tooltipFormat: 'PPP',
}, },
grid: { grid: {
@ -72,6 +72,9 @@ export const createOptions = (
ticks: { ticks: {
color: theme.palette.text.secondary, color: theme.palette.text.secondary,
display: !isPlaceholder, display: !isPlaceholder,
source: 'data',
maxRotation: 90,
minRotation: 23.5,
}, },
min: format(subMonths(new Date(), 3), 'yyyy-MM-dd'), min: format(subMonths(new Date(), 3), 'yyyy-MM-dd'),
}, },

View File

@ -12,13 +12,14 @@ export const legendOptions = {
} = chart?.legend?.options || { } = chart?.legend?.options || {
labels: {}, labels: {},
}; };
return (chart as any)._getSortedDatasetMetas().map((meta: any) => { return (chart as any)._getSortedDatasetMetas().map((meta: any) => {
const style = meta.controller.getStyle( const style = meta.controller.getStyle(
usePointStyle ? 0 : undefined, usePointStyle ? 0 : undefined,
); );
return { return {
text: datasets[meta.index].label, text: datasets[meta.index].label,
fillStyle: style.backgroundColor, fillStyle: style.borderColor,
fontColor: color, fontColor: color,
hidden: !meta.visible, hidden: !meta.visible,
lineWidth: 0, lineWidth: 0,

View File

@ -12,17 +12,9 @@ const StyledPaper = styled(Paper)(({ theme }) => ({
export const Widget: FC<{ export const Widget: FC<{
title: ReactNode; title: ReactNode;
order?: number;
span?: number;
tooltip?: ReactNode; tooltip?: ReactNode;
}> = ({ title, order, children, span = 1, tooltip }) => ( }> = ({ title, children, tooltip, ...rest }) => (
<StyledPaper <StyledPaper elevation={0} {...rest}>
elevation={0}
sx={{
order,
gridColumn: `span ${span}`,
}}
>
<Typography <Typography
variant='h3' variant='h3'
sx={(theme) => ({ sx={(theme) => ({

View File

@ -2,7 +2,8 @@ import { useMemo, type VFC } from 'react';
import 'chartjs-adapter-date-fns'; import 'chartjs-adapter-date-fns';
import { useTheme } from '@mui/material'; import { useTheme } from '@mui/material';
import { ExecutiveSummarySchema } from 'openapi'; import { ExecutiveSummarySchema } from 'openapi';
import { LineChart, NotEnoughData } from '../LineChart/LineChart'; import { LineChart, NotEnoughData } from '../../components/LineChart/LineChart';
import { usePlaceholderData } from 'component/executiveDashboard/hooks/usePlaceholderData';
interface IFlagsChartProps { interface IFlagsChartProps {
flagTrends: ExecutiveSummarySchema['flagTrends']; flagTrends: ExecutiveSummarySchema['flagTrends'];
@ -15,32 +16,7 @@ export const FlagsChart: VFC<IFlagsChartProps> = ({
}) => { }) => {
const theme = useTheme(); const theme = useTheme();
const notEnoughData = flagTrends.length < 2; const notEnoughData = flagTrends.length < 2;
const placeholderData = useMemo( const placeholderData = usePlaceholderData({ fill: true, type: 'double' });
() => ({
labels: Array.from({ length: 15 }, (_, i) => i + 1).map(
(i) =>
new Date(Date.now() - (15 - i) * 7 * 24 * 60 * 60 * 1000),
),
datasets: [
{
label: 'Total flags',
data: [
43, 66, 55, 65, 62, 72, 75, 73, 80, 65, 62, 61, 69, 70,
77,
],
borderColor: theme.palette.primary.light,
backgroundColor: theme.palette.primary.light,
},
{
label: 'Stale',
data: [3, 5, 4, 6, 2, 7, 5, 3, 8, 3, 5, 11, 8, 4, 3],
borderColor: theme.palette.warning.border,
backgroundColor: theme.palette.warning.border,
},
],
}),
[theme],
);
const data = useMemo( const data = useMemo(
() => ({ () => ({

View File

@ -0,0 +1,38 @@
import { useMemo, type VFC } from 'react';
import 'chartjs-adapter-date-fns';
import { ExecutiveSummarySchema } from 'openapi';
import { LineChart, NotEnoughData } from '../../components/LineChart/LineChart';
import { useProjectChartData } from 'component/executiveDashboard/hooks/useProjectChartData';
import { usePlaceholderData } from 'component/executiveDashboard/hooks/usePlaceholderData';
interface IFlagsProjectChartProps {
projectFlagTrends: ExecutiveSummarySchema['projectFlagTrends'];
}
export const FlagsProjectChart: VFC<IFlagsProjectChartProps> = ({
projectFlagTrends,
}) => {
const placeholderData = usePlaceholderData({
type: 'constant',
});
const data = useProjectChartData(projectFlagTrends);
const notEnoughData = useMemo(
() => (data.datasets.some((d) => d.data.length > 1) ? false : true),
[data],
);
return (
<LineChart
data={notEnoughData ? placeholderData : data}
isLocalTooltip
overrideOptions={{
parsing: {
yAxisKey: 'total',
xAxisKey: 'date',
},
}}
cover={notEnoughData ? <NotEnoughData /> : false}
/>
);
};

View File

@ -1,7 +1,7 @@
import { type VFC } from 'react'; import { type VFC } from 'react';
import { ExecutiveSummarySchemaMetricsSummaryTrendsItem } from 'openapi'; import { ExecutiveSummarySchemaMetricsSummaryTrendsItem } from 'openapi';
import { Box, Divider, Paper, styled, Typography } from '@mui/material'; import { Box, Divider, Paper, styled, Typography } from '@mui/material';
import { TooltipState } from '../../LineChart/ChartTooltip/ChartTooltip'; import { TooltipState } from '../../../components/LineChart/ChartTooltip/ChartTooltip';
const StyledTooltipItemContainer = styled(Paper)(({ theme }) => ({ const StyledTooltipItemContainer = styled(Paper)(({ theme }) => ({
padding: theme.spacing(2), padding: theme.spacing(2),

View File

@ -1,9 +1,9 @@
import { type VFC } from 'react'; import { type VFC } from 'react';
import 'chartjs-adapter-date-fns'; import 'chartjs-adapter-date-fns';
import { ExecutiveSummarySchema } from 'openapi'; import { ExecutiveSummarySchema } from 'openapi';
import { LineChart } from '../LineChart/LineChart'; import { LineChart } from '../../components/LineChart/LineChart';
import { useMetricsSummary } from '../useMetricsSummary';
import { MetricsSummaryTooltip } from './MetricsChartTooltip/MetricsChartTooltip'; import { MetricsSummaryTooltip } from './MetricsChartTooltip/MetricsChartTooltip';
import { useMetricsSummary } from '../../hooks/useMetricsSummary';
interface IMetricsSummaryChartProps { interface IMetricsSummaryChartProps {
metricsSummaryTrends: ExecutiveSummarySchema['metricsSummaryTrends']; metricsSummaryTrends: ExecutiveSummarySchema['metricsSummaryTrends'];
@ -13,6 +13,7 @@ export const MetricsSummaryChart: VFC<IMetricsSummaryChartProps> = ({
metricsSummaryTrends, metricsSummaryTrends,
}) => { }) => {
const data = useMetricsSummary(metricsSummaryTrends); const data = useMetricsSummary(metricsSummaryTrends);
return ( return (
<LineChart <LineChart
data={data} data={data}

View File

@ -2,8 +2,8 @@ import { type VFC } from 'react';
import { type ExecutiveSummarySchemaProjectFlagTrendsItem } from 'openapi'; import { type ExecutiveSummarySchemaProjectFlagTrendsItem } from 'openapi';
import { Box, Divider, Paper, Typography, styled } from '@mui/material'; import { Box, Divider, Paper, Typography, styled } from '@mui/material';
import { Badge } from 'component/common/Badge/Badge'; import { Badge } from 'component/common/Badge/Badge';
import { TooltipState } from '../../LineChart/ChartTooltip/ChartTooltip'; import { TooltipState } from '../../../components/LineChart/ChartTooltip/ChartTooltip';
import { HorizontalDistributionChart } from '../../HorizontalDistributionChart/HorizontalDistributionChart'; import { HorizontalDistributionChart } from '../../../components/HorizontalDistributionChart/HorizontalDistributionChart';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
const StyledTooltipItemContainer = styled(Paper)(({ theme }) => ({ const StyledTooltipItemContainer = styled(Paper)(({ theme }) => ({

View File

@ -0,0 +1,103 @@
import 'chartjs-adapter-date-fns';
import { useMemo, type VFC } from 'react';
import { type ExecutiveSummarySchema } from 'openapi';
import { HealthTooltip } from './HealthChartTooltip/HealthChartTooltip';
import { useProjectChartData } from 'component/executiveDashboard/hooks/useProjectChartData';
import {
LineChart,
NotEnoughData,
} from 'component/executiveDashboard/components/LineChart/LineChart';
import { useTheme } from '@mui/material';
interface IFlagsProjectChartProps {
projectFlagTrends: ExecutiveSummarySchema['projectFlagTrends'];
isAggregate?: boolean;
}
export const ProjectHealthChart: VFC<IFlagsProjectChartProps> = ({
projectFlagTrends,
isAggregate,
}) => {
const projectsData = useProjectChartData(projectFlagTrends);
const theme = useTheme();
const aggregateHealthData = useMemo(() => {
const labels = Array.from(
new Set(
projectsData.datasets.flatMap((d) =>
d.data.map((item) => item.week),
),
),
);
const weeks = labels
.map((label) => {
return projectsData.datasets
.map((d) => d.data.find((item) => item.week === label))
.reduce(
(acc, item) => {
if (item) {
acc.total += item.total;
acc.stale += item.stale + item.potentiallyStale;
}
if (!acc.date) {
acc.date = item?.date;
}
return acc;
},
{
total: 0,
stale: 0,
week: label,
} as {
total: number;
stale: number;
week: string;
date?: string;
},
);
})
.sort((a, b) => (a.week > b.week ? 1 : -1));
return {
datasets: [
{
label: 'Health',
data: weeks.map((item) => ({
health: item.total
? ((item.total - item.stale) / item.total) * 100
: undefined,
date: item.date,
})),
borderColor: theme.palette.primary.light,
fill: false,
order: 3,
},
],
};
}, [projectsData, theme]);
const data = isAggregate ? aggregateHealthData : projectsData;
const notEnoughData = useMemo(
() =>
projectsData.datasets.some((d) => d.data.length > 1) ? false : true,
[projectsData],
);
return (
<LineChart
key={isAggregate ? 'aggregate' : 'project'}
data={data}
isLocalTooltip
TooltipComponent={isAggregate ? undefined : HealthTooltip}
overrideOptions={
notEnoughData
? {}
: {
parsing: { yAxisKey: 'health', xAxisKey: 'date' },
}
}
cover={notEnoughData ? <NotEnoughData /> : false}
/>
);
};

View File

@ -1,8 +1,8 @@
import { type VFC } from 'react'; import { type VFC } from 'react';
import 'chartjs-adapter-date-fns'; import 'chartjs-adapter-date-fns';
import { ExecutiveSummarySchema } from 'openapi'; import { ExecutiveSummarySchema } from 'openapi';
import { LineChart } from '../LineChart/LineChart'; import { LineChart } from '../../components/LineChart/LineChart';
import { useProjectChartData } from '../useProjectChartData'; import { useProjectChartData } from '../../hooks/useProjectChartData';
interface IFlagsProjectChartProps { interface IFlagsProjectChartProps {
projectFlagTrends: ExecutiveSummarySchema['projectFlagTrends']; projectFlagTrends: ExecutiveSummarySchema['projectFlagTrends'];
@ -12,7 +12,6 @@ export const TimeToProductionChart: VFC<IFlagsProjectChartProps> = ({
projectFlagTrends, projectFlagTrends,
}) => { }) => {
const data = useProjectChartData(projectFlagTrends); const data = useProjectChartData(projectFlagTrends);
return ( return (
<LineChart <LineChart
data={data} data={data}

View File

@ -6,8 +6,9 @@ import {
fillGradientPrimary, fillGradientPrimary,
LineChart, LineChart,
NotEnoughData, NotEnoughData,
} from '../LineChart/LineChart'; } from '../../components/LineChart/LineChart';
import { useUiFlag } from 'hooks/useUiFlag'; import { useUiFlag } from 'hooks/useUiFlag';
import { usePlaceholderData } from 'component/executiveDashboard/hooks/usePlaceholderData';
interface IUsersChartProps { interface IUsersChartProps {
userTrends: ExecutiveSummarySchema['userTrends']; userTrends: ExecutiveSummarySchema['userTrends'];
@ -21,28 +22,7 @@ export const UsersChart: VFC<IUsersChartProps> = ({
const showInactiveUsers = useUiFlag('showInactiveUsers'); const showInactiveUsers = useUiFlag('showInactiveUsers');
const theme = useTheme(); const theme = useTheme();
const notEnoughData = userTrends.length < 2; const notEnoughData = userTrends.length < 2;
const placeholderData = useMemo( const placeholderData = usePlaceholderData({ fill: true, type: 'rising' });
() => ({
labels: Array.from({ length: 15 }, (_, i) => i + 1).map(
(i) =>
new Date(Date.now() - (15 - i) * 7 * 24 * 60 * 60 * 1000),
),
datasets: [
{
label: 'Total users',
data: [
3, 5, 15, 17, 25, 40, 47, 48, 55, 65, 62, 72, 75, 73,
80,
],
borderColor: theme.palette.primary.light,
backgroundColor: fillGradientPrimary,
fill: true,
order: 3,
},
],
}),
[theme],
);
const data = useMemo( const data = useMemo(
() => ({ () => ({
labels: userTrends.map((item) => item.date), labels: userTrends.map((item) => item.date),

View File

@ -0,0 +1,38 @@
import { useMemo, type VFC } from 'react';
import 'chartjs-adapter-date-fns';
import { ExecutiveSummarySchema } from 'openapi';
import { LineChart, NotEnoughData } from '../../components/LineChart/LineChart';
import { useProjectChartData } from 'component/executiveDashboard/hooks/useProjectChartData';
import { usePlaceholderData } from 'component/executiveDashboard/hooks/usePlaceholderData';
interface IUsersPerProjectChartProps {
projectFlagTrends: ExecutiveSummarySchema['projectFlagTrends'];
}
export const UsersPerProjectChart: VFC<IUsersPerProjectChartProps> = ({
projectFlagTrends,
}) => {
const placeholderData = usePlaceholderData({
type: 'constant',
});
const data = useProjectChartData(projectFlagTrends);
const notEnoughData = useMemo(
() => (data.datasets.some((d) => d.data.length > 1) ? false : true),
[data],
);
return (
<LineChart
data={notEnoughData ? placeholderData : data}
isLocalTooltip
overrideOptions={{
parsing: {
yAxisKey: 'users',
xAxisKey: 'date',
},
}}
cover={notEnoughData ? <NotEnoughData /> : false}
/>
);
};

View File

@ -1,20 +1,6 @@
import { Settings } from '@mui/icons-material'; import { Settings } from '@mui/icons-material';
import { Box, Typography, styled } from '@mui/material'; import { Box, Typography, styled } from '@mui/material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
const StyledContent = styled(Box)(({ theme }) => ({
borderRadius: `${theme.shape.borderRadiusLarge}px`,
backgroundColor: theme.palette.background.paper,
maxWidth: 300,
padding: theme.spacing(3),
}));
const StyledHeader = styled(Typography)(({ theme }) => ({
marginBottom: theme.spacing(3),
fontSize: theme.fontSizes.bodySize,
fontWeight: 'bold',
display: 'flex',
alignItems: 'center',
}));
const StyledRingContainer = styled(Box)(({ theme }) => ({ const StyledRingContainer = styled(Box)(({ theme }) => ({
display: 'flex', display: 'flex',
@ -79,7 +65,7 @@ const StyledSettingsIcon = styled(Settings)(({ theme }) => ({
interface IFlagStatsProps { interface IFlagStatsProps {
count: number; count: number;
flagsPerUser: string; flagsPerUser?: string;
} }
export const FlagStats: React.FC<IFlagStatsProps> = ({ export const FlagStats: React.FC<IFlagStatsProps> = ({
@ -94,22 +80,31 @@ export const FlagStats: React.FC<IFlagStatsProps> = ({
</StyledRing> </StyledRing>
</StyledRingContainer> </StyledRingContainer>
<StyledInsightsContainer> <ConditionallyRender
<StyledTextContainer> condition={flagsPerUser !== undefined && flagsPerUser !== ''}
<StyledHeaderContainer> show={
<StyledSettingsIcon /> <StyledInsightsContainer>
<Typography <StyledTextContainer>
fontWeight='bold' <StyledHeaderContainer>
variant='body2' <StyledSettingsIcon />
color='primary' <Typography
> fontWeight='bold'
Insights variant='body2'
</Typography> color='primary'
</StyledHeaderContainer> >
<Typography variant='body2'>Flags per user</Typography> Insights
</StyledTextContainer> </Typography>
<StyledFlagCountPerUser>{flagsPerUser}</StyledFlagCountPerUser> </StyledHeaderContainer>
</StyledInsightsContainer> <Typography variant='body2'>
Flags per user
</Typography>
</StyledTextContainer>
<StyledFlagCountPerUser>
{flagsPerUser}
</StyledFlagCountPerUser>
</StyledInsightsContainer>
}
/>
</> </>
); );
}; };

View File

@ -3,7 +3,7 @@ import { useThemeMode } from 'hooks/useThemeMode';
import { useTheme } from '@mui/material'; import { useTheme } from '@mui/material';
interface IHealthStatsProps { interface IHealthStatsProps {
value: number; value?: string | number;
healthy: number; healthy: number;
stale: number; stale: number;
potentiallyStale: number; potentiallyStale: number;

View File

@ -1,6 +1,6 @@
import { VFC } from 'react'; import { VFC } from 'react';
import { Typography, styled } from '@mui/material'; import { Typography, styled } from '@mui/material';
import { Gauge } from '../Gauge/Gauge'; import { Gauge } from '../../components/Gauge/Gauge';
const StyledContainer = styled('div')(({ theme }) => ({ const StyledContainer = styled('div')(({ theme }) => ({
display: 'flex', display: 'flex',

View File

@ -4,7 +4,7 @@ import { Box, Typography, styled } from '@mui/material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { useUiFlag } from 'hooks/useUiFlag'; import { useUiFlag } from 'hooks/useUiFlag';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { HorizontalDistributionChart } from '../HorizontalDistributionChart/HorizontalDistributionChart'; import { HorizontalDistributionChart } from '../../components/HorizontalDistributionChart/HorizontalDistributionChart';
import { UserDistributionInfo } from './UserDistributionInfo'; import { UserDistributionInfo } from './UserDistributionInfo';
const StyledUserContainer = styled(Box)(({ theme }) => ({ const StyledUserContainer = styled(Box)(({ theme }) => ({
@ -81,7 +81,11 @@ export const UserStats: FC<IUserStatsProps> = ({ count, active, inactive }) => {
<> <>
<StyledUserContainer> <StyledUserContainer>
<StyledUserBox> <StyledUserBox>
<StyledUserCount variant='h2'>{count}</StyledUserCount> <StyledUserCount variant='h2'>
{parseInt(`${count}`, 10) === count
? count
: count.toFixed(2)}
</StyledUserCount>
</StyledUserBox> </StyledUserBox>
<StyledCustomShadow /> <StyledCustomShadow />
</StyledUserContainer> </StyledUserContainer>

View File

@ -53,7 +53,7 @@ describe('useFilteredFlagTrends', () => {
active: 11, active: 11,
stale: 2, stale: 2,
potentiallyStale: 1, potentiallyStale: 1,
averageUsers: '2.00', averageUsers: 2,
averageHealth: '79', averageHealth: '79',
}); });
}); });
@ -79,7 +79,7 @@ describe('useFilteredFlagTrends', () => {
active: 5, active: 5,
stale: 0, stale: 0,
potentiallyStale: 0, potentiallyStale: 0,
averageUsers: '0.00', averageUsers: 0,
averageHealth: '100', averageHealth: '100',
}); });
}); });
@ -104,7 +104,7 @@ describe('useFilteredFlagTrends', () => {
active: 5, active: 5,
stale: 0, stale: 0,
potentiallyStale: 0, potentiallyStale: 0,
users: 2, users: 3,
date: '', date: '',
}, },
]), ]),
@ -115,8 +115,34 @@ describe('useFilteredFlagTrends', () => {
active: 10, active: 10,
stale: 0, stale: 0,
potentiallyStale: 0, potentiallyStale: 0,
averageUsers: '1.00', averageUsers: 1.5,
averageHealth: '100', averageHealth: '100',
}); });
}); });
it('should set health of a project without feature toggles to undefined', () => {
const { result } = renderHook(() =>
useFilteredFlagsSummary([
{
week: '2024-01',
project: 'project1',
total: 0,
active: 0,
stale: 0,
potentiallyStale: 0,
users: 0,
date: '',
},
]),
);
expect(result.current).toEqual({
total: 0,
active: 0,
stale: 0,
potentiallyStale: 0,
averageUsers: 0,
averageHealth: undefined,
});
});
}); });

View File

@ -1,6 +1,7 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { ExecutiveSummarySchemaProjectFlagTrendsItem } from 'openapi'; import { ExecutiveSummarySchemaProjectFlagTrendsItem } from 'openapi';
// NOTE: should we move project filtering to the backend?
export const useFilteredFlagsSummary = ( export const useFilteredFlagsSummary = (
filteredProjectFlagTrends: ExecutiveSummarySchemaProjectFlagTrendsItem[], filteredProjectFlagTrends: ExecutiveSummarySchemaProjectFlagTrendsItem[],
) => ) =>
@ -14,12 +15,11 @@ export const useFilteredFlagsSummary = (
(summary) => summary.week === lastWeekId, (summary) => summary.week === lastWeekId,
); );
const averageUsers = ( const averageUsers =
lastWeekSummary.reduce( lastWeekSummary.reduce(
(acc, current) => acc + (current.users || 0), (acc, current) => acc + (current.users || 0),
0, 0,
) / lastWeekSummary.length ) / lastWeekSummary.length || 0;
).toFixed(2);
const sum = lastWeekSummary.reduce( const sum = lastWeekSummary.reduce(
(acc, current) => ({ (acc, current) => ({
@ -41,6 +41,8 @@ export const useFilteredFlagsSummary = (
return { return {
...sum, ...sum,
averageUsers, averageUsers,
averageHealth: ((sum.active / (sum.total || 1)) * 100).toFixed(0), averageHealth: sum.total
? ((sum.active / (sum.total || 1)) * 100).toFixed(0)
: undefined,
}; };
}, [filteredProjectFlagTrends]); }, [filteredProjectFlagTrends]);

View File

@ -4,7 +4,7 @@ import {
ExecutiveSummarySchema, ExecutiveSummarySchema,
ExecutiveSummarySchemaMetricsSummaryTrendsItem, ExecutiveSummarySchemaMetricsSummaryTrendsItem,
} from 'openapi'; } from 'openapi';
import { getProjectColor } from './executive-dashboard-utils'; import { getProjectColor } from '../executive-dashboard-utils';
type MetricsSummaryTrends = ExecutiveSummarySchema['metricsSummaryTrends']; type MetricsSummaryTrends = ExecutiveSummarySchema['metricsSummaryTrends'];

View File

@ -0,0 +1,64 @@
import { useTheme } from '@mui/material';
import { useMemo } from 'react';
import { fillGradientPrimary } from '../components/LineChart/LineChart';
export const usePlaceholderData = ({
fill = false,
type = 'constant',
}: {
fill?: boolean;
type?: 'rising' | 'constant' | 'double';
}) => {
const theme = useTheme();
return useMemo(
() => ({
labels: Array.from({ length: 15 }, (_, i) => i + 1).map(
(i) =>
new Date(Date.now() - (15 - i) * 7 * 24 * 60 * 60 * 1000),
),
datasets:
type === 'double'
? [
{
label: 'Total flags',
data: [
43, 66, 55, 65, 62, 72, 75, 73, 80, 65, 62,
61, 69, 70, 77,
],
borderColor: theme.palette.primary.light,
backgroundColor: theme.palette.primary.light,
},
{
label: 'Stale',
data: [
3, 5, 4, 6, 2, 7, 5, 3, 8, 3, 5, 11, 8, 4, 3,
],
borderColor: theme.palette.warning.border,
backgroundColor: theme.palette.warning.border,
},
]
: [
{
label: '',
data:
type === 'rising'
? [
3, 5, 15, 17, 25, 40, 47, 48, 55,
65, 62, 72, 75, 73, 80,
]
: [
54, 52, 53, 49, 54, 50, 47, 46,
51, 51, 50, 51, 49, 49, 51,
],
borderColor: theme.palette.primary.light,
backgroundColor: fill
? fillGradientPrimary
: theme.palette.primary.light,
fill,
},
],
}),
[theme, fill],
);
};

View File

@ -2,8 +2,8 @@ import { useMemo } from 'react';
import { import {
ExecutiveSummarySchema, ExecutiveSummarySchema,
ExecutiveSummarySchemaProjectFlagTrendsItem, ExecutiveSummarySchemaProjectFlagTrendsItem,
} from '../../openapi'; } from '../../../openapi';
import { getProjectColor } from './executive-dashboard-utils'; import { getProjectColor } from '../executive-dashboard-utils';
import { useTheme } from '@mui/material'; import { useTheme } from '@mui/material';
type ProjectFlagTrends = ExecutiveSummarySchema['projectFlagTrends']; type ProjectFlagTrends = ExecutiveSummarySchema['projectFlagTrends'];

View File

@ -1,17 +0,0 @@
/**
* Generated by Orval
* Do not edit manually.
* See `gen:api` script in package.json
*/
/**
* The name of this action.
*/
export type ApplicationOverviewIssuesSchemaType =
(typeof ApplicationOverviewIssuesSchemaType)[keyof typeof ApplicationOverviewIssuesSchemaType];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const ApplicationOverviewIssuesSchemaType = {
missingFeatures: 'missingFeatures',
missingStrategies: 'missingStrategies',
} as const;

View File

@ -575,6 +575,7 @@ export * from './getAllToggles403';
export * from './getApiTokensByName401'; export * from './getApiTokensByName401';
export * from './getApiTokensByName403'; export * from './getApiTokensByName403';
export * from './getApplication404'; export * from './getApplication404';
export * from './getApplicationEnvironmentInstances404';
export * from './getApplicationOverview404'; export * from './getApplicationOverview404';
export * from './getApplicationsParams'; export * from './getApplicationsParams';
export * from './getArchivedFeatures401'; export * from './getArchivedFeatures401';