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 {
selectedProjects: string[];
onChange: Dispatch<SetStateAction<string[]>>;
onChange:
| Dispatch<SetStateAction<string[]>>
| ((projects: string[]) => void);
dataTestId?: string;
sx?: SxProps;
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 {
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,
} from '../common/ProjectSelect/ProjectSelect';
import { MetricsSummaryChart } from './MetricsSummaryChart/MetricsSummaryChart';
import {
ExecutiveSummarySchemaMetricsSummaryTrendsItem,
ExecutiveSummarySchemaProjectFlagTrendsItem,
} from 'openapi';
import { HealthStats } from './HealthStats/HealthStats';
import { DashboardHeader } from './DashboardHeader/DashboardHeader';
ProjectSelect,
} from 'component/common/ProjectSelect/ProjectSelect';
import { useExecutiveDashboard } from 'hooks/api/getters/useExecutiveSummary/useExecutiveSummary';
import { useFilteredFlagsSummary } from './hooks/useFilteredFlagsSummary';
import { useFilteredTrends } from './hooks/useFilteredTrends';
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 }) => ({
display: 'grid',
gridTemplateColumns: `300px 1fr`,
gridTemplateColumns: `repeat(2, 1fr)`,
gridAutoRows: 'auto',
gap: theme.spacing(2),
}));
const StyledBox = styled(Box)(({ theme }) => ({
marginBottom: theme.spacing(4),
marginTop: theme.spacing(4),
[theme.breakpoints.down('lg')]: {
width: '100%',
marginLeft: 0,
paddingBottom: theme.spacing(2),
[theme.breakpoints.up('md')]: {
gridTemplateColumns: `300px 1fr`,
},
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}));
const useDashboardGrid = () => {
const theme = useTheme();
const isMediumScreen = useMediaQuery(theme.breakpoints.down('lg'));
const isSmallScreen = useMediaQuery(theme.breakpoints.down('sm'));
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[];
}
const ChartWidget = styled(Widget)(({ theme }) => ({
[theme.breakpoints.down('md')]: {
gridColumnStart: 'span 2',
order: 2,
},
}));
export const ExecutiveDashboard: VFC = () => {
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(() => {
if (
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();
const summary = useFilteredFlagsSummary(projectsData);
const isOneProjectSelected = projects.length === 1;
return (
<>
<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>
<StyledGrid sx={{ gridTemplateColumns }}>
<Widget title='Total users' order={1}>
<UserStats
count={executiveDashboardData.users.total}
active={executiveDashboardData.users.active}
inactive={executiveDashboardData.users.inactive}
/>
</Widget>
<Widget title='Users' order={userTrendsOrder} span={chartSpan}>
<UsersChart
userTrends={executiveDashboardData.userTrends}
isLoading={loading}
/>
</Widget>
<StyledGrid>
<ConditionallyRender
condition={showAllProjects}
show={
<Widget title='Total users'>
<UserStats
count={users.total}
active={users.active}
inactive={users.inactive}
/>
</Widget>
}
elseShow={
<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
title='Total flags'
tooltip='Total flags represent the total active flags (not archived) that currently exist across all projects of your application.'
order={flagStatsOrder}
tooltip='Active flags (not archived) that currently exist across selected projects.'
>
<FlagStats
count={executiveDashboardData.flags.total}
flagsPerUser={flagPerUsers}
count={summary.total}
flagsPerUser={
showAllProjects
? (summary.total / users.total).toFixed(2)
: ''
}
/>
</Widget>
<Widget title='Number of flags' order={4} span={chartSpan}>
<FlagsChart
flagTrends={executiveDashboardData.flagTrends}
isLoading={loading}
/>
</Widget>
</StyledGrid>
<StyledBox>
<Typography variant='h2' component='span'>
Insights per project
</Typography>
<ProjectSelect
selectedProjects={projects}
onChange={setProjects}
dataTestId={'DASHBOARD_PROJECT_SELECT'}
sx={{ flex: 1, maxWidth: '360px' }}
<ConditionallyRender
condition={showAllProjects}
show={
<ChartWidget title='Number of flags'>
<FlagsChart
flagTrends={executiveDashboardData.flagTrends}
isLoading={loading}
/>
</ChartWidget>
}
elseShow={
<ChartWidget title='Flags per project'>
<FlagsProjectChart
projectFlagTrends={projectsData}
/>
</ChartWidget>
}
/>
</StyledBox>
<StyledGrid>
<Widget
title='Number of flags per project'
order={5}
span={largeChartSpan}
>
<FlagsProjectChart
projectFlagTrends={filteredProjectFlagTrends}
/>
</Widget>
<Widget title='Average health' order={6}>
<Widget title='Average health'>
<HealthStats
// FIXME: data from API
value={80}
healthy={4}
stale={1}
potentiallyStale={0}
value={summary.averageHealth}
healthy={summary.active}
stale={summary.stale}
potentiallyStale={summary.potentiallyStale}
/>
</Widget>
<Widget title='Health per project' order={7} span={chartSpan}>
<ProjectHealthChart
projectFlagTrends={filteredProjectFlagTrends}
/>
</Widget>
<Widget
title='Metrics over time per project'
order={8}
span={largeChartSpan}
<ChartWidget
title={
showAllProjects ? 'Healthy flags' : 'Health per project'
}
>
<MetricsSummaryChart
metricsSummaryTrends={filteredMetricsSummaryTrends}
<ProjectHealthChart
projectFlagTrends={projectsData}
isAggregate={showAllProjects}
/>
</Widget>
<Widget title='Average time to production' order={9}>
</ChartWidget>
{/* <Widget title='Average time to production'>
<TimeToProduction
//FIXME: data from API
//FIXME: data from API
daysToProduction={5.2}
/>
</Widget>
<Widget title='Time to production' order={10} span={chartSpan}>
<TimeToProductionChart
projectFlagTrends={filteredProjectFlagTrends}
/>
</Widget>
<ChartWidget title='Time to production'>
<TimeToProductionChart projectFlagTrends={projectsData} />
</ChartWidget> */}
</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 { useFeedback } from 'component/feedbackNew/useFeedback';
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 { 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 { openFeedback } = useFeedback(
@ -34,18 +40,21 @@ export const DashboardHeader: VFC = () => {
gap: theme.spacing(1),
})}
>
<span>Insights</span> <Badge color='warning'>Beta</Badge>
<span>Insights</span> <Badge color='success'>Beta</Badge>
</Typography>
}
actions={
<Button
startIcon={<ReviewsOutlined />}
variant='outlined'
onClick={createFeedbackContext}
size='small'
>
Provide feedback
</Button>
<>
{actions}
<ShareLink />
<Button
startIcon={<ReviewsOutlined />}
variant='outlined'
onClick={createFeedbackContext}
>
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>;
}> = ({
data,
aspectRatio,
aspectRatio = 2.5,
cover,
isLocalTooltip,
overrideOptions,
@ -122,8 +122,8 @@ const LineChartComponent: VFC<{
options={options}
data={data}
plugins={[customHighlightPlugin]}
height={aspectRatio ? 100 : undefined}
width={aspectRatio ? 100 * aspectRatio : undefined}
height={100}
width={100 * aspectRatio}
/>
<ConditionallyRender
condition={!cover}

View File

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

View File

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

View File

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

View File

@ -2,7 +2,8 @@ import { useMemo, type VFC } from 'react';
import 'chartjs-adapter-date-fns';
import { useTheme } from '@mui/material';
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 {
flagTrends: ExecutiveSummarySchema['flagTrends'];
@ -15,32 +16,7 @@ export const FlagsChart: VFC<IFlagsChartProps> = ({
}) => {
const theme = useTheme();
const notEnoughData = flagTrends.length < 2;
const placeholderData = useMemo(
() => ({
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 placeholderData = usePlaceholderData({ fill: true, type: 'double' });
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 { ExecutiveSummarySchemaMetricsSummaryTrendsItem } from 'openapi';
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 }) => ({
padding: theme.spacing(2),

View File

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

View File

@ -2,8 +2,8 @@ import { type VFC } from 'react';
import { type ExecutiveSummarySchemaProjectFlagTrendsItem } from 'openapi';
import { Box, Divider, Paper, Typography, styled } from '@mui/material';
import { Badge } from 'component/common/Badge/Badge';
import { TooltipState } from '../../LineChart/ChartTooltip/ChartTooltip';
import { HorizontalDistributionChart } from '../../HorizontalDistributionChart/HorizontalDistributionChart';
import { TooltipState } from '../../../components/LineChart/ChartTooltip/ChartTooltip';
import { HorizontalDistributionChart } from '../../../components/HorizontalDistributionChart/HorizontalDistributionChart';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
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 'chartjs-adapter-date-fns';
import { ExecutiveSummarySchema } from 'openapi';
import { LineChart } from '../LineChart/LineChart';
import { useProjectChartData } from '../useProjectChartData';
import { LineChart } from '../../components/LineChart/LineChart';
import { useProjectChartData } from '../../hooks/useProjectChartData';
interface IFlagsProjectChartProps {
projectFlagTrends: ExecutiveSummarySchema['projectFlagTrends'];
@ -12,7 +12,6 @@ export const TimeToProductionChart: VFC<IFlagsProjectChartProps> = ({
projectFlagTrends,
}) => {
const data = useProjectChartData(projectFlagTrends);
return (
<LineChart
data={data}

View File

@ -6,8 +6,9 @@ import {
fillGradientPrimary,
LineChart,
NotEnoughData,
} from '../LineChart/LineChart';
} from '../../components/LineChart/LineChart';
import { useUiFlag } from 'hooks/useUiFlag';
import { usePlaceholderData } from 'component/executiveDashboard/hooks/usePlaceholderData';
interface IUsersChartProps {
userTrends: ExecutiveSummarySchema['userTrends'];
@ -21,28 +22,7 @@ export const UsersChart: VFC<IUsersChartProps> = ({
const showInactiveUsers = useUiFlag('showInactiveUsers');
const theme = useTheme();
const notEnoughData = userTrends.length < 2;
const placeholderData = useMemo(
() => ({
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 placeholderData = usePlaceholderData({ fill: true, type: 'rising' });
const data = useMemo(
() => ({
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 { Box, Typography, styled } from '@mui/material';
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',
}));
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
const StyledRingContainer = styled(Box)(({ theme }) => ({
display: 'flex',
@ -79,7 +65,7 @@ const StyledSettingsIcon = styled(Settings)(({ theme }) => ({
interface IFlagStatsProps {
count: number;
flagsPerUser: string;
flagsPerUser?: string;
}
export const FlagStats: React.FC<IFlagStatsProps> = ({
@ -94,22 +80,31 @@ export const FlagStats: React.FC<IFlagStatsProps> = ({
</StyledRing>
</StyledRingContainer>
<StyledInsightsContainer>
<StyledTextContainer>
<StyledHeaderContainer>
<StyledSettingsIcon />
<Typography
fontWeight='bold'
variant='body2'
color='primary'
>
Insights
</Typography>
</StyledHeaderContainer>
<Typography variant='body2'>Flags per user</Typography>
</StyledTextContainer>
<StyledFlagCountPerUser>{flagsPerUser}</StyledFlagCountPerUser>
</StyledInsightsContainer>
<ConditionallyRender
condition={flagsPerUser !== undefined && flagsPerUser !== ''}
show={
<StyledInsightsContainer>
<StyledTextContainer>
<StyledHeaderContainer>
<StyledSettingsIcon />
<Typography
fontWeight='bold'
variant='body2'
color='primary'
>
Insights
</Typography>
</StyledHeaderContainer>
<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';
interface IHealthStatsProps {
value: number;
value?: string | number;
healthy: number;
stale: number;
potentiallyStale: number;

View File

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

View File

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

View File

@ -53,7 +53,7 @@ describe('useFilteredFlagTrends', () => {
active: 11,
stale: 2,
potentiallyStale: 1,
averageUsers: '2.00',
averageUsers: 2,
averageHealth: '79',
});
});
@ -79,7 +79,7 @@ describe('useFilteredFlagTrends', () => {
active: 5,
stale: 0,
potentiallyStale: 0,
averageUsers: '0.00',
averageUsers: 0,
averageHealth: '100',
});
});
@ -104,7 +104,7 @@ describe('useFilteredFlagTrends', () => {
active: 5,
stale: 0,
potentiallyStale: 0,
users: 2,
users: 3,
date: '',
},
]),
@ -115,8 +115,34 @@ describe('useFilteredFlagTrends', () => {
active: 10,
stale: 0,
potentiallyStale: 0,
averageUsers: '1.00',
averageUsers: 1.5,
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 { ExecutiveSummarySchemaProjectFlagTrendsItem } from 'openapi';
// NOTE: should we move project filtering to the backend?
export const useFilteredFlagsSummary = (
filteredProjectFlagTrends: ExecutiveSummarySchemaProjectFlagTrendsItem[],
) =>
@ -14,12 +15,11 @@ export const useFilteredFlagsSummary = (
(summary) => summary.week === lastWeekId,
);
const averageUsers = (
const averageUsers =
lastWeekSummary.reduce(
(acc, current) => acc + (current.users || 0),
0,
) / lastWeekSummary.length
).toFixed(2);
) / lastWeekSummary.length || 0;
const sum = lastWeekSummary.reduce(
(acc, current) => ({
@ -41,6 +41,8 @@ export const useFilteredFlagsSummary = (
return {
...sum,
averageUsers,
averageHealth: ((sum.active / (sum.total || 1)) * 100).toFixed(0),
averageHealth: sum.total
? ((sum.active / (sum.total || 1)) * 100).toFixed(0)
: undefined,
};
}, [filteredProjectFlagTrends]);

View File

@ -4,7 +4,7 @@ import {
ExecutiveSummarySchema,
ExecutiveSummarySchemaMetricsSummaryTrendsItem,
} from 'openapi';
import { getProjectColor } from './executive-dashboard-utils';
import { getProjectColor } from '../executive-dashboard-utils';
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 {
ExecutiveSummarySchema,
ExecutiveSummarySchemaProjectFlagTrendsItem,
} from '../../openapi';
import { getProjectColor } from './executive-dashboard-utils';
} from '../../../openapi';
import { getProjectColor } from '../executive-dashboard-utils';
import { useTheme } from '@mui/material';
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 './getApiTokensByName403';
export * from './getApplication404';
export * from './getApplicationEnvironmentInstances404';
export * from './getApplicationOverview404';
export * from './getApplicationsParams';
export * from './getArchivedFeatures401';