mirror of
https://github.com/Unleash/unleash.git
synced 2025-05-31 01:16:01 +02:00
feat: improved health chart tooltip (#6359)
This commit is contained in:
parent
b82a650dab
commit
96c86b221b
@ -178,7 +178,7 @@ export const ExecutiveDashboard: VFC = () => {
|
|||||||
value={80}
|
value={80}
|
||||||
healthy={4}
|
healthy={4}
|
||||||
stale={1}
|
stale={1}
|
||||||
potenciallyStale={0}
|
potentiallyStale={0}
|
||||||
/>
|
/>
|
||||||
</Widget>
|
</Widget>
|
||||||
<Widget title='Health per project' order={7} span={chartSpan}>
|
<Widget title='Health per project' order={7} span={chartSpan}>
|
||||||
|
@ -6,14 +6,14 @@ interface IHealthStatsProps {
|
|||||||
value: number;
|
value: number;
|
||||||
healthy: number;
|
healthy: number;
|
||||||
stale: number;
|
stale: number;
|
||||||
potenciallyStale: number;
|
potentiallyStale: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HealthStats: VFC<IHealthStatsProps> = ({
|
export const HealthStats: VFC<IHealthStatsProps> = ({
|
||||||
value,
|
value,
|
||||||
healthy,
|
healthy,
|
||||||
stale,
|
stale,
|
||||||
potenciallyStale,
|
potentiallyStale,
|
||||||
}) => {
|
}) => {
|
||||||
const { themeMode } = useThemeMode();
|
const { themeMode } = useThemeMode();
|
||||||
const isDark = themeMode === 'dark';
|
const isDark = themeMode === 'dark';
|
||||||
@ -116,12 +116,12 @@ export const HealthStats: VFC<IHealthStatsProps> = ({
|
|||||||
<text
|
<text
|
||||||
x={144}
|
x={144}
|
||||||
y={216}
|
y={216}
|
||||||
fill={theme.palette.charts.health.potenciallyStale}
|
fill={theme.palette.charts.health.potentiallyStale}
|
||||||
fontWeight={700}
|
fontWeight={700}
|
||||||
fontSize={20}
|
fontSize={20}
|
||||||
textAnchor='middle'
|
textAnchor='middle'
|
||||||
>
|
>
|
||||||
{potenciallyStale || 0}
|
{potentiallyStale || 0}
|
||||||
</text>
|
</text>
|
||||||
<text
|
<text
|
||||||
x={144}
|
x={144}
|
||||||
@ -302,7 +302,7 @@ export const HealthStats: VFC<IHealthStatsProps> = ({
|
|||||||
<stop
|
<stop
|
||||||
offset='1'
|
offset='1'
|
||||||
stopColor={
|
stopColor={
|
||||||
theme.palette.charts.health.gradientPotenciallyStale
|
theme.palette.charts.health.gradientPotentiallyStale
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
|
@ -0,0 +1,41 @@
|
|||||||
|
import { VFC } from 'react';
|
||||||
|
import { Box, styled } from '@mui/material';
|
||||||
|
|
||||||
|
type DistributionLineTypes = 'default' | 'success' | 'warning' | 'error';
|
||||||
|
|
||||||
|
const StyledDistributionLine = styled(Box)<{
|
||||||
|
type: DistributionLineTypes;
|
||||||
|
size?: 'large' | 'small';
|
||||||
|
}>(({ theme, type, size = 'large' }) => {
|
||||||
|
const color: Record<DistributionLineTypes, string | undefined> = {
|
||||||
|
default: theme.palette.secondary.border,
|
||||||
|
success: theme.palette.success.border,
|
||||||
|
warning: theme.palette.warning.border,
|
||||||
|
error: theme.palette.error.border,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
borderRadius: theme.shape.borderRadius,
|
||||||
|
height: size === 'large' ? theme.spacing(2) : theme.spacing(1),
|
||||||
|
backgroundColor: color[type],
|
||||||
|
marginBottom: theme.spacing(0.5),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const HorizontalDistributionChart: VFC<{
|
||||||
|
sections: Array<{ type: DistributionLineTypes; value: number }>;
|
||||||
|
size?: 'large' | 'small';
|
||||||
|
}> = ({ sections, size }) => (
|
||||||
|
<Box sx={(theme) => ({ display: 'flex', gap: theme.spacing(0.5) })}>
|
||||||
|
{sections.map((section, index) =>
|
||||||
|
section.value ? (
|
||||||
|
<StyledDistributionLine
|
||||||
|
type={section.type}
|
||||||
|
sx={{ width: `${section.value}%` }}
|
||||||
|
key={`${section.type}-${index}`}
|
||||||
|
size={size}
|
||||||
|
/>
|
||||||
|
) : null,
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
@ -7,7 +7,7 @@ export type TooltipState = {
|
|||||||
caretX: number;
|
caretX: number;
|
||||||
caretY: number;
|
caretY: number;
|
||||||
title: string;
|
title: string;
|
||||||
align: 'left' | 'right';
|
align: 'left' | 'right' | 'center';
|
||||||
body: {
|
body: {
|
||||||
title: string;
|
title: string;
|
||||||
color: string;
|
color: string;
|
||||||
@ -40,22 +40,47 @@ const StyledLabelIcon = styled('span')(({ theme }) => ({
|
|||||||
marginRight: theme.spacing(1),
|
marginRight: theme.spacing(1),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const offset = 16;
|
||||||
|
|
||||||
|
const getAlign = (align?: 'left' | 'right' | 'center') => {
|
||||||
|
if (align === 'left') {
|
||||||
|
return 'flex-start';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (align === 'right') {
|
||||||
|
return 'flex-end';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'center';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLeftOffset = (caretX = 0, align?: 'left' | 'right' | 'center') => {
|
||||||
|
if (align === 'left') {
|
||||||
|
return caretX + offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (align === 'right') {
|
||||||
|
return caretX - offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
return caretX;
|
||||||
|
};
|
||||||
|
|
||||||
export const ChartTooltipContainer: FC<IChartTooltipProps> = ({
|
export const ChartTooltipContainer: FC<IChartTooltipProps> = ({
|
||||||
tooltip,
|
tooltip,
|
||||||
children,
|
children,
|
||||||
}) => (
|
}) => (
|
||||||
<Box
|
<Box
|
||||||
sx={(theme) => ({
|
sx={(theme) => ({
|
||||||
top: tooltip?.caretY,
|
top: (tooltip?.caretY || 0) + offset,
|
||||||
left: tooltip?.align === 'left' ? tooltip?.caretX + 20 : 0,
|
left: getLeftOffset(tooltip?.caretX, tooltip?.align),
|
||||||
right:
|
width: '1px',
|
||||||
tooltip?.align === 'right' ? tooltip?.caretX + 20 : undefined,
|
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
display: tooltip ? 'flex' : 'none',
|
display: tooltip ? 'flex' : 'none',
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
zIndex: theme.zIndex.tooltip,
|
zIndex: theme.zIndex.tooltip,
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
alignItems: tooltip?.align === 'left' ? 'flex-start' : 'flex-end',
|
alignItems: getAlign(tooltip?.align),
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
@ -10,16 +10,12 @@ import {
|
|||||||
Chart,
|
Chart,
|
||||||
Filler,
|
Filler,
|
||||||
type ChartData,
|
type ChartData,
|
||||||
TooltipModel,
|
type ChartOptions,
|
||||||
ChartOptions,
|
|
||||||
} from 'chart.js';
|
} from 'chart.js';
|
||||||
import { Line } from 'react-chartjs-2';
|
import { Line } from 'react-chartjs-2';
|
||||||
import 'chartjs-adapter-date-fns';
|
import 'chartjs-adapter-date-fns';
|
||||||
import { Theme, useTheme } from '@mui/material';
|
import { useTheme } from '@mui/material';
|
||||||
import {
|
import { useLocationSettings } from 'hooks/useLocationSettings';
|
||||||
useLocationSettings,
|
|
||||||
type ILocationSettings,
|
|
||||||
} from 'hooks/useLocationSettings';
|
|
||||||
import {
|
import {
|
||||||
ChartTooltip,
|
ChartTooltip,
|
||||||
ChartTooltipContainer,
|
ChartTooltipContainer,
|
||||||
@ -27,144 +23,7 @@ import {
|
|||||||
} from './ChartTooltip/ChartTooltip';
|
} from './ChartTooltip/ChartTooltip';
|
||||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
import { styled } from '@mui/material';
|
import { styled } from '@mui/material';
|
||||||
|
import { createOptions } from './createChartOptions';
|
||||||
const createTooltip =
|
|
||||||
(setTooltip: React.Dispatch<React.SetStateAction<TooltipState | null>>) =>
|
|
||||||
(context: {
|
|
||||||
chart: Chart;
|
|
||||||
tooltip: TooltipModel<any>;
|
|
||||||
}) => {
|
|
||||||
const tooltip = context.tooltip;
|
|
||||||
if (tooltip.opacity === 0) {
|
|
||||||
setTooltip(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setTooltip({
|
|
||||||
caretX:
|
|
||||||
tooltip?.xAlign === 'right'
|
|
||||||
? context.chart.width - tooltip?.caretX
|
|
||||||
: tooltip?.caretX,
|
|
||||||
caretY: tooltip?.caretY,
|
|
||||||
title: tooltip?.title?.join(' ') || '',
|
|
||||||
align: tooltip?.xAlign === 'right' ? 'right' : 'left',
|
|
||||||
body:
|
|
||||||
tooltip?.body?.map((item: any, index: number) => ({
|
|
||||||
title: item?.lines?.join(' '),
|
|
||||||
color: tooltip?.labelColors?.[index]?.borderColor as string,
|
|
||||||
value: '',
|
|
||||||
})) || [],
|
|
||||||
dataPoints: tooltip?.dataPoints || [],
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const createOptions = (
|
|
||||||
theme: Theme,
|
|
||||||
locationSettings: ILocationSettings,
|
|
||||||
setTooltip: React.Dispatch<React.SetStateAction<TooltipState | null>>,
|
|
||||||
isPlaceholder?: boolean,
|
|
||||||
localTooltip?: boolean,
|
|
||||||
) =>
|
|
||||||
({
|
|
||||||
responsive: true,
|
|
||||||
...(isPlaceholder
|
|
||||||
? {
|
|
||||||
animation: {
|
|
||||||
duration: 0,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
plugins: {
|
|
||||||
legend: {
|
|
||||||
display: !isPlaceholder,
|
|
||||||
position: 'bottom',
|
|
||||||
labels: {
|
|
||||||
boxWidth: 12,
|
|
||||||
padding: 30,
|
|
||||||
generateLabels: (chart: Chart) => {
|
|
||||||
const datasets = chart.data.datasets;
|
|
||||||
const {
|
|
||||||
labels: {
|
|
||||||
usePointStyle,
|
|
||||||
pointStyle,
|
|
||||||
textAlign,
|
|
||||||
color,
|
|
||||||
},
|
|
||||||
} = 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,
|
|
||||||
fontColor: color,
|
|
||||||
hidden: !meta.visible,
|
|
||||||
lineWidth: 0,
|
|
||||||
borderRadius: 6,
|
|
||||||
strokeStyle: style.borderColor,
|
|
||||||
pointStyle: pointStyle || style.pointStyle,
|
|
||||||
textAlign: textAlign || style.textAlign,
|
|
||||||
datasetIndex: meta.index,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
tooltip: {
|
|
||||||
enabled: false,
|
|
||||||
external: createTooltip(setTooltip),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
locale: locationSettings.locale,
|
|
||||||
interaction: {
|
|
||||||
intersect: localTooltip || false,
|
|
||||||
axis: 'x',
|
|
||||||
},
|
|
||||||
elements: {
|
|
||||||
point: {
|
|
||||||
radius: 0,
|
|
||||||
hitRadius: 15,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// cubicInterpolationMode: 'monotone',
|
|
||||||
tension: 0.1,
|
|
||||||
color: theme.palette.text.secondary,
|
|
||||||
scales: {
|
|
||||||
y: {
|
|
||||||
beginAtZero: true,
|
|
||||||
type: 'linear',
|
|
||||||
grid: {
|
|
||||||
color: theme.palette.divider,
|
|
||||||
borderColor: theme.palette.divider,
|
|
||||||
},
|
|
||||||
ticks: {
|
|
||||||
color: theme.palette.text.secondary,
|
|
||||||
display: !isPlaceholder,
|
|
||||||
precision: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
x: {
|
|
||||||
type: 'time',
|
|
||||||
time: {
|
|
||||||
unit: 'day',
|
|
||||||
tooltipFormat: 'PPP',
|
|
||||||
},
|
|
||||||
grid: {
|
|
||||||
color: 'transparent',
|
|
||||||
borderColor: 'transparent',
|
|
||||||
},
|
|
||||||
ticks: {
|
|
||||||
color: theme.palette.text.secondary,
|
|
||||||
display: !isPlaceholder,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}) as const;
|
|
||||||
|
|
||||||
const StyledContainer = styled('div')(({ theme }) => ({
|
const StyledContainer = styled('div')(({ theme }) => ({
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
|
@ -0,0 +1,77 @@
|
|||||||
|
import { Theme } from '@mui/material';
|
||||||
|
import { ILocationSettings } from 'hooks/useLocationSettings';
|
||||||
|
import { TooltipState } from './ChartTooltip/ChartTooltip';
|
||||||
|
import { createTooltip } from './createTooltip';
|
||||||
|
import { legendOptions } from './legendOptions';
|
||||||
|
|
||||||
|
export const createOptions = (
|
||||||
|
theme: Theme,
|
||||||
|
locationSettings: ILocationSettings,
|
||||||
|
setTooltip: React.Dispatch<React.SetStateAction<TooltipState | null>>,
|
||||||
|
isPlaceholder?: boolean,
|
||||||
|
localTooltip?: boolean,
|
||||||
|
) =>
|
||||||
|
({
|
||||||
|
responsive: true,
|
||||||
|
...(isPlaceholder
|
||||||
|
? {
|
||||||
|
animation: {
|
||||||
|
duration: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
...legendOptions,
|
||||||
|
display: !isPlaceholder,
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
enabled: false,
|
||||||
|
external: createTooltip(setTooltip),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
locale: locationSettings.locale,
|
||||||
|
interaction: {
|
||||||
|
intersect: localTooltip || false,
|
||||||
|
axis: 'x',
|
||||||
|
},
|
||||||
|
elements: {
|
||||||
|
point: {
|
||||||
|
radius: 0,
|
||||||
|
hitRadius: 15,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// cubicInterpolationMode: 'monotone',
|
||||||
|
tension: 0.1,
|
||||||
|
color: theme.palette.text.secondary,
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
type: 'linear',
|
||||||
|
grid: {
|
||||||
|
color: theme.palette.divider,
|
||||||
|
borderColor: theme.palette.divider,
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
color: theme.palette.text.secondary,
|
||||||
|
display: !isPlaceholder,
|
||||||
|
precision: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
type: 'time',
|
||||||
|
time: {
|
||||||
|
unit: 'day',
|
||||||
|
tooltipFormat: 'PPP',
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
color: 'transparent',
|
||||||
|
borderColor: 'transparent',
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
color: theme.palette.text.secondary,
|
||||||
|
display: !isPlaceholder,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}) as const;
|
@ -0,0 +1,29 @@
|
|||||||
|
import { type Chart, type TooltipModel } from 'chart.js';
|
||||||
|
import { type TooltipState } from './ChartTooltip/ChartTooltip';
|
||||||
|
|
||||||
|
export const createTooltip =
|
||||||
|
(setTooltip: React.Dispatch<React.SetStateAction<TooltipState | null>>) =>
|
||||||
|
(context: {
|
||||||
|
chart: Chart;
|
||||||
|
tooltip: TooltipModel<any>;
|
||||||
|
}) => {
|
||||||
|
const tooltip = context.tooltip;
|
||||||
|
if (tooltip.opacity === 0) {
|
||||||
|
setTooltip(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTooltip({
|
||||||
|
caretX: tooltip?.caretX,
|
||||||
|
caretY: tooltip?.caretY,
|
||||||
|
title: tooltip?.title?.join(' ') || '',
|
||||||
|
align: tooltip?.xAlign,
|
||||||
|
body:
|
||||||
|
tooltip?.body?.map((item: any, index: number) => ({
|
||||||
|
title: item?.lines?.join(' '),
|
||||||
|
color: tooltip?.labelColors?.[index]?.borderColor as string,
|
||||||
|
value: '',
|
||||||
|
})) || [],
|
||||||
|
dataPoints: tooltip?.dataPoints || [],
|
||||||
|
});
|
||||||
|
};
|
@ -0,0 +1,34 @@
|
|||||||
|
import { Chart } from 'chart.js';
|
||||||
|
|
||||||
|
export const legendOptions = {
|
||||||
|
position: 'bottom',
|
||||||
|
labels: {
|
||||||
|
boxWidth: 12,
|
||||||
|
padding: 30,
|
||||||
|
generateLabels: (chart: Chart) => {
|
||||||
|
const datasets = chart.data.datasets;
|
||||||
|
const {
|
||||||
|
labels: { usePointStyle, pointStyle, textAlign, color },
|
||||||
|
} = 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,
|
||||||
|
fontColor: color,
|
||||||
|
hidden: !meta.visible,
|
||||||
|
lineWidth: 0,
|
||||||
|
borderRadius: 6,
|
||||||
|
strokeStyle: style.borderColor,
|
||||||
|
pointStyle: pointStyle || style.pointStyle,
|
||||||
|
textAlign: textAlign || style.textAlign,
|
||||||
|
datasetIndex: meta.index,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
@ -0,0 +1,164 @@
|
|||||||
|
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 { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
|
|
||||||
|
const StyledTooltipItemContainer = styled(Paper)(({ theme }) => ({
|
||||||
|
padding: theme.spacing(2),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledItemHeader = styled(Box)(({ theme }) => ({
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: theme.spacing(2),
|
||||||
|
alignItems: 'center',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const getHealthBadgeColor = (health?: number | null) => {
|
||||||
|
if (health === undefined || health === null) {
|
||||||
|
return 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (health >= 75) {
|
||||||
|
return 'success';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (health >= 50) {
|
||||||
|
return 'warning';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'error';
|
||||||
|
};
|
||||||
|
|
||||||
|
const Distribution = ({ stale = 0, potentiallyStale = 0, total = 0 }) => (
|
||||||
|
<>
|
||||||
|
<HorizontalDistributionChart
|
||||||
|
sections={[{ type: 'default', value: 100 }]}
|
||||||
|
size='small'
|
||||||
|
/>
|
||||||
|
<HorizontalDistributionChart
|
||||||
|
sections={[
|
||||||
|
{
|
||||||
|
type: 'error',
|
||||||
|
value: (stale / total) * 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'warning',
|
||||||
|
value: (potentiallyStale / total) * 100,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
size='small'
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
variant='body2'
|
||||||
|
component='p'
|
||||||
|
sx={(theme) => ({ marginTop: theme.spacing(0.5) })}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
component='span'
|
||||||
|
sx={(theme) => ({
|
||||||
|
color: theme.palette.error.border,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{'● '}
|
||||||
|
</Typography>
|
||||||
|
Stale flags: {stale}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant='body2' component='p'>
|
||||||
|
<Typography
|
||||||
|
component='span'
|
||||||
|
sx={(theme) => ({
|
||||||
|
color: theme.palette.warning.border,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{'● '}
|
||||||
|
</Typography>
|
||||||
|
Potentially stale flags: {potentiallyStale}
|
||||||
|
</Typography>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const HealthTooltip: VFC<{ tooltip: TooltipState | null }> = ({
|
||||||
|
tooltip,
|
||||||
|
}) => {
|
||||||
|
const data = tooltip?.dataPoints.map((point) => {
|
||||||
|
return {
|
||||||
|
label: point.label,
|
||||||
|
title: point.dataset.label,
|
||||||
|
color: point.dataset.borderColor,
|
||||||
|
value: point.raw as ExecutiveSummarySchemaProjectFlagTrendsItem,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const limitedData = data?.slice(0, 5);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={(theme) => ({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: theme.spacing(2),
|
||||||
|
width: '300px',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{limitedData?.map((point, index) => (
|
||||||
|
<StyledTooltipItemContainer
|
||||||
|
elevation={3}
|
||||||
|
key={`${point.title}-${index}`}
|
||||||
|
>
|
||||||
|
<StyledItemHeader>
|
||||||
|
<Typography
|
||||||
|
variant='body2'
|
||||||
|
color='textSecondary'
|
||||||
|
component='span'
|
||||||
|
>
|
||||||
|
{point.label}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant='body2'
|
||||||
|
color='textSecondary'
|
||||||
|
component='span'
|
||||||
|
>
|
||||||
|
Project health
|
||||||
|
</Typography>
|
||||||
|
</StyledItemHeader>
|
||||||
|
<StyledItemHeader>
|
||||||
|
<Typography variant='body2' component='span'>
|
||||||
|
<Typography
|
||||||
|
sx={{ color: point.color }}
|
||||||
|
component='span'
|
||||||
|
>
|
||||||
|
{'● '}
|
||||||
|
</Typography>
|
||||||
|
<strong>{point.title}</strong>
|
||||||
|
</Typography>
|
||||||
|
<Badge color={getHealthBadgeColor(point.value.health)}>
|
||||||
|
{point.value.health}%
|
||||||
|
</Badge>
|
||||||
|
</StyledItemHeader>{' '}
|
||||||
|
<Divider
|
||||||
|
sx={(theme) => ({ margin: theme.spacing(1.5, 0) })}
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
variant='body2'
|
||||||
|
component='p'
|
||||||
|
sx={(theme) => ({
|
||||||
|
marginBottom: theme.spacing(0.5),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Total flags: {point.value.total}
|
||||||
|
</Typography>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={Boolean(
|
||||||
|
point.value.stale || point.value.potentiallyStale,
|
||||||
|
)}
|
||||||
|
show={<Distribution {...point.value} />}
|
||||||
|
/>
|
||||||
|
</StyledTooltipItemContainer>
|
||||||
|
)) || null}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
@ -1,76 +1,14 @@
|
|||||||
import { type VFC } from 'react';
|
|
||||||
import 'chartjs-adapter-date-fns';
|
import 'chartjs-adapter-date-fns';
|
||||||
import { ExecutiveSummarySchema } from 'openapi';
|
import { type VFC } from 'react';
|
||||||
|
import { type ExecutiveSummarySchema } from 'openapi';
|
||||||
|
import { HealthTooltip } from './HealthChartTooltip/HealthChartTooltip';
|
||||||
import { LineChart } from '../LineChart/LineChart';
|
import { LineChart } from '../LineChart/LineChart';
|
||||||
import { useProjectChartData } from '../useProjectChartData';
|
import { useProjectChartData } from '../useProjectChartData';
|
||||||
import { TooltipState } from '../LineChart/ChartTooltip/ChartTooltip';
|
|
||||||
import { Box, Paper, styled } from '@mui/material';
|
|
||||||
import { Badge } from 'component/common/Badge/Badge';
|
|
||||||
|
|
||||||
interface IFlagsProjectChartProps {
|
interface IFlagsProjectChartProps {
|
||||||
projectFlagTrends: ExecutiveSummarySchema['projectFlagTrends'];
|
projectFlagTrends: ExecutiveSummarySchema['projectFlagTrends'];
|
||||||
}
|
}
|
||||||
|
|
||||||
const StyledTooltipItemContainer = styled(Paper)(({ theme }) => ({
|
|
||||||
padding: theme.spacing(2),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const StyledItemHeader = styled(Box)(({ theme }) => ({
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
gap: theme.spacing(2),
|
|
||||||
alignItems: 'center',
|
|
||||||
}));
|
|
||||||
|
|
||||||
const getHealthBadgeColor = (health?: number | null) => {
|
|
||||||
if (health === undefined || health === null) {
|
|
||||||
return 'info';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (health >= 75) {
|
|
||||||
return 'success';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (health >= 50) {
|
|
||||||
return 'warning';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'error';
|
|
||||||
};
|
|
||||||
|
|
||||||
const TooltipComponent: VFC<{ tooltip: TooltipState | null }> = ({
|
|
||||||
tooltip,
|
|
||||||
}) => {
|
|
||||||
const data = tooltip?.dataPoints.map((point) => {
|
|
||||||
return {
|
|
||||||
title: point.dataset.label,
|
|
||||||
color: point.dataset.borderColor,
|
|
||||||
value: point.raw as number,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={(theme) => ({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: theme.spacing(3),
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{data?.map((point, index) => (
|
|
||||||
<StyledTooltipItemContainer elevation={3} key={point.title}>
|
|
||||||
<StyledItemHeader>
|
|
||||||
<div>{point.title}</div>{' '}
|
|
||||||
<Badge color={getHealthBadgeColor(point.value)}>
|
|
||||||
{point.value}%
|
|
||||||
</Badge>
|
|
||||||
</StyledItemHeader>
|
|
||||||
</StyledTooltipItemContainer>
|
|
||||||
)) || null}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ProjectHealthChart: VFC<IFlagsProjectChartProps> = ({
|
export const ProjectHealthChart: VFC<IFlagsProjectChartProps> = ({
|
||||||
projectFlagTrends,
|
projectFlagTrends,
|
||||||
}) => {
|
}) => {
|
||||||
@ -80,12 +18,9 @@ export const ProjectHealthChart: VFC<IFlagsProjectChartProps> = ({
|
|||||||
<LineChart
|
<LineChart
|
||||||
data={data}
|
data={data}
|
||||||
isLocalTooltip
|
isLocalTooltip
|
||||||
TooltipComponent={TooltipComponent}
|
TooltipComponent={HealthTooltip}
|
||||||
overrideOptions={{
|
overrideOptions={{
|
||||||
parsing: {
|
parsing: { yAxisKey: 'health', xAxisKey: 'date' },
|
||||||
yAxisKey: 'health',
|
|
||||||
xAxisKey: 'date',
|
|
||||||
},
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
@ -0,0 +1,78 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box, Typography, styled } from '@mui/material';
|
||||||
|
|
||||||
|
type UserType = 'active' | 'inactive';
|
||||||
|
|
||||||
|
interface StyledLinearProgressProps {
|
||||||
|
type: UserType;
|
||||||
|
}
|
||||||
|
const StyledUserDistContainer = styled(Box)(({ theme }) => ({
|
||||||
|
padding: `${theme.spacing(1.5)} ${theme.spacing(2)}`,
|
||||||
|
borderRadius: `${theme.shape.borderRadius}px`,
|
||||||
|
border: `1px solid ${theme.palette.divider}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledUserDistIndicator = styled(Box)<StyledLinearProgressProps>(
|
||||||
|
({ theme, type }) => ({
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
backgroundColor:
|
||||||
|
type === 'active'
|
||||||
|
? theme.palette.success.border
|
||||||
|
: theme.palette.warning.border,
|
||||||
|
borderRadius: `2px`,
|
||||||
|
marginRight: theme.spacing(1),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
interface IUserDistributionInfoProps {
|
||||||
|
type: UserType;
|
||||||
|
count: string;
|
||||||
|
percentage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StyledDistInfoInnerContainer = styled(Box)(() => ({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
width: '100%',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledDistInfoTextContainer = styled(Box)(() => ({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledCountTypography = styled(Typography)(() => ({
|
||||||
|
marginLeft: 'auto',
|
||||||
|
fontWeight: 'normal',
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const UserDistributionInfo: React.FC<IUserDistributionInfoProps> = ({
|
||||||
|
type,
|
||||||
|
count,
|
||||||
|
percentage,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<StyledUserDistContainer>
|
||||||
|
<StyledDistInfoInnerContainer>
|
||||||
|
<StyledDistInfoTextContainer>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StyledUserDistIndicator type={type} />
|
||||||
|
<Typography variant='body1' component='span'>
|
||||||
|
{type === 'active' ? 'Active' : 'Inactive'} users
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography variant='body2'>{percentage}%</Typography>
|
||||||
|
</StyledDistInfoTextContainer>
|
||||||
|
<StyledCountTypography variant='h2'>
|
||||||
|
{count}
|
||||||
|
</StyledCountTypography>
|
||||||
|
</StyledDistInfoInnerContainer>
|
||||||
|
</StyledUserDistContainer>
|
||||||
|
);
|
||||||
|
};
|
@ -1,9 +1,11 @@
|
|||||||
import React, { type FC } from 'react';
|
import { type FC } from 'react';
|
||||||
import { ChevronRight } from '@mui/icons-material';
|
import { ChevronRight } 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';
|
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 { UserDistributionInfo } from './UserDistributionInfo';
|
||||||
|
|
||||||
const StyledUserContainer = styled(Box)(({ theme }) => ({
|
const StyledUserContainer = styled(Box)(({ theme }) => ({
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
@ -43,12 +45,6 @@ const StyledUserCount = styled(Typography)(({ theme }) => ({
|
|||||||
padding: 0,
|
padding: 0,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const StyledHeader = styled(Typography)(({ theme }) => ({
|
|
||||||
marginBottom: theme.spacing(3),
|
|
||||||
fontSize: theme.fontSizes.bodySize,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
}));
|
|
||||||
|
|
||||||
const StyledDistInfoContainer = styled(Box)({
|
const StyledDistInfoContainer = styled(Box)({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
@ -95,8 +91,17 @@ export const UserStats: FC<IUserStatsProps> = ({ count, active, inactive }) => {
|
|||||||
show={
|
show={
|
||||||
<>
|
<>
|
||||||
<StyledUserDistributionContainer>
|
<StyledUserDistributionContainer>
|
||||||
<UserDistribution
|
<HorizontalDistributionChart
|
||||||
activeUsersPercentage={activeUsersPercentage}
|
sections={[
|
||||||
|
{
|
||||||
|
type: 'success',
|
||||||
|
value: activeUsersPercentage,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'warning',
|
||||||
|
value: 100 - activeUsersPercentage,
|
||||||
|
},
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
</StyledUserDistributionContainer>
|
</StyledUserDistributionContainer>
|
||||||
|
|
||||||
@ -124,115 +129,3 @@ export const UserStats: FC<IUserStatsProps> = ({ count, active, inactive }) => {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
type UserType = 'active' | 'inactive';
|
|
||||||
|
|
||||||
interface StyledLinearProgressProps {
|
|
||||||
type: UserType;
|
|
||||||
}
|
|
||||||
|
|
||||||
const StyledUserDistributionLine = styled(Box)<StyledLinearProgressProps>(
|
|
||||||
({ theme, type }) => ({
|
|
||||||
borderRadius: theme.shape.borderRadius,
|
|
||||||
height: 16,
|
|
||||||
backgroundColor:
|
|
||||||
type === 'active'
|
|
||||||
? theme.palette.success.border
|
|
||||||
: theme.palette.warning.border,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const UserDistribution = ({ activeUsersPercentage = 100 }) => {
|
|
||||||
const getLineWidth = () => {
|
|
||||||
return [activeUsersPercentage, 100 - activeUsersPercentage];
|
|
||||||
};
|
|
||||||
|
|
||||||
const [activeWidth, inactiveWidth] = getLineWidth();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: 'flex' }}>
|
|
||||||
<StyledUserDistributionLine
|
|
||||||
type='active'
|
|
||||||
sx={{ width: `${activeWidth}%` }}
|
|
||||||
/>
|
|
||||||
<StyledUserDistributionLine
|
|
||||||
type='inactive'
|
|
||||||
sx={(theme) => ({
|
|
||||||
width: `${inactiveWidth}%`,
|
|
||||||
marginLeft: theme.spacing(0.5),
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const StyledUserDistContainer = styled(Box)(({ theme }) => ({
|
|
||||||
padding: `${theme.spacing(1.5)} ${theme.spacing(2)}`,
|
|
||||||
borderRadius: `${theme.shape.borderRadius}px`,
|
|
||||||
border: `1px solid ${theme.palette.divider}`,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const StyledUserDistIndicator = styled(Box)<StyledLinearProgressProps>(
|
|
||||||
({ theme, type }) => ({
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
backgroundColor:
|
|
||||||
type === 'active'
|
|
||||||
? theme.palette.success.border
|
|
||||||
: theme.palette.warning.border,
|
|
||||||
borderRadius: `2px`,
|
|
||||||
marginRight: theme.spacing(1),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
interface IUserDistributionInfoProps {
|
|
||||||
type: UserType;
|
|
||||||
count: string;
|
|
||||||
percentage: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const StyledDistInfoInnerContainer = styled(Box)(({ theme }) => ({
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
width: '100%',
|
|
||||||
}));
|
|
||||||
|
|
||||||
const StyledDistInfoTextContainer = styled(Box)(({ theme }) => ({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
}));
|
|
||||||
|
|
||||||
const StyledCountTypography = styled(Typography)(({ theme }) => ({
|
|
||||||
marginLeft: 'auto',
|
|
||||||
fontWeight: 'normal',
|
|
||||||
}));
|
|
||||||
|
|
||||||
const UserDistributionInfo: React.FC<IUserDistributionInfoProps> = ({
|
|
||||||
type,
|
|
||||||
count,
|
|
||||||
percentage,
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<StyledUserDistContainer>
|
|
||||||
<StyledDistInfoInnerContainer>
|
|
||||||
<StyledDistInfoTextContainer>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<StyledUserDistIndicator type={type} />
|
|
||||||
<Typography variant='body1' component='span'>
|
|
||||||
{type === 'active' ? 'Active' : 'Inactive'} users
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Typography variant='body2'>{percentage}%</Typography>
|
|
||||||
</StyledDistInfoTextContainer>
|
|
||||||
<StyledCountTypography variant='h2'>
|
|
||||||
{count}
|
|
||||||
</StyledCountTypography>
|
|
||||||
</StyledDistInfoInnerContainer>
|
|
||||||
</StyledUserDistContainer>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
@ -298,9 +298,9 @@ const theme = {
|
|||||||
title: colors.grey[50],
|
title: colors.grey[50],
|
||||||
healthy: colors.purple[800],
|
healthy: colors.purple[800],
|
||||||
stale: colors.red[800],
|
stale: colors.red[800],
|
||||||
potenciallyStale: colors.orange[800],
|
potentiallyStale: colors.orange[800],
|
||||||
gradientStale: '#8A3E45',
|
gradientStale: '#8A3E45',
|
||||||
gradientPotenciallyStale: '#875D21',
|
gradientPotentiallyStale: '#875D21',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -283,9 +283,9 @@ export const theme = {
|
|||||||
title: colors.grey[50],
|
title: colors.grey[50],
|
||||||
healthy: colors.purple[800],
|
healthy: colors.purple[800],
|
||||||
stale: colors.red[800],
|
stale: colors.red[800],
|
||||||
potenciallyStale: colors.orange[800],
|
potentiallyStale: colors.orange[800],
|
||||||
gradientStale: colors.red[300],
|
gradientStale: colors.red[300],
|
||||||
gradientPotenciallyStale: colors.orange[500],
|
gradientPotentiallyStale: colors.orange[500],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -141,9 +141,9 @@ declare module '@mui/material/styles' {
|
|||||||
title: string;
|
title: string;
|
||||||
healthy: string;
|
healthy: string;
|
||||||
stale: string;
|
stale: string;
|
||||||
potenciallyStale: string;
|
potentiallyStale: string;
|
||||||
gradientStale: string;
|
gradientStale: string;
|
||||||
gradientPotenciallyStale: string;
|
gradientPotentiallyStale: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user