mirror of
https://github.com/Unleash/unleash.git
synced 2025-06-27 01:19:00 +02:00
feat: add lifecycle trend graphs (#10077)
Adds lifecycle trend graphs to the insights page. The graphs are each placed within their own boxes. The boxes do not have any more information in them yet. Also, because the data returned from the API is still all zeroes, I've used mock data that matches the sketches. Finally, the chart configuration and how it's split into a LifecycleChart that lazy loads a LifecycleChartComponent is based on the LineChart and LineChartComponent that we use elsewhere on the insights page. Light mode: <img width="1562" alt="image" src="https://github.com/user-attachments/assets/6dd11168-be24-42d4-aa97-a7a55651fa0e" /> We might want to tweak some colors in dark mode, but maybe not? 🤷🏼 
This commit is contained in:
parent
ae47771290
commit
16df33b078
@ -159,6 +159,7 @@
|
||||
},
|
||||
"packageManager": "yarn@4.9.1",
|
||||
"dependencies": {
|
||||
"chartjs-plugin-datalabels": "^2.2.0",
|
||||
"json-2-csv": "^5.5.5"
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,5 @@
|
||||
import { lazy } from 'react';
|
||||
|
||||
export const LifecycleChart = lazy(
|
||||
() => import('./LifecycleChartComponent.tsx'),
|
||||
);
|
@ -0,0 +1,121 @@
|
||||
import { type FC, useMemo } from 'react';
|
||||
import {
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
Tooltip,
|
||||
Legend,
|
||||
TimeScale,
|
||||
Chart,
|
||||
Filler,
|
||||
type ChartData,
|
||||
type ChartOptions,
|
||||
BarElement,
|
||||
} from 'chart.js';
|
||||
import { Bar } from 'react-chartjs-2';
|
||||
import 'chartjs-adapter-date-fns';
|
||||
import { type Theme, useTheme } from '@mui/material';
|
||||
import { useLocationSettings } from 'hooks/useLocationSettings';
|
||||
import merge from 'deepmerge';
|
||||
import ChartDataLabels from 'chartjs-plugin-datalabels';
|
||||
|
||||
export const createOptions = (theme: Theme): ChartOptions<'bar'> => {
|
||||
const fontSize = 10;
|
||||
return {
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'right',
|
||||
maxWidth: 150,
|
||||
align: 'start',
|
||||
labels: {
|
||||
color: theme.palette.text.secondary,
|
||||
usePointStyle: true,
|
||||
padding: 21,
|
||||
boxHeight: 8,
|
||||
font: {
|
||||
size: fontSize,
|
||||
},
|
||||
},
|
||||
},
|
||||
datalabels: {
|
||||
color: theme.palette.text.primary,
|
||||
font: {
|
||||
weight: 'bold',
|
||||
size: fontSize,
|
||||
},
|
||||
anchor: 'end',
|
||||
align: 'top',
|
||||
offset: -6,
|
||||
},
|
||||
},
|
||||
aspectRatio: 2 / 1,
|
||||
responsive: true,
|
||||
color: theme.palette.text.secondary,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: theme.palette.divider,
|
||||
borderColor: theme.palette.divider,
|
||||
drawBorder: false,
|
||||
},
|
||||
ticks: {
|
||||
stepSize: 1,
|
||||
color: theme.palette.text.disabled,
|
||||
font: {
|
||||
size: fontSize,
|
||||
},
|
||||
},
|
||||
},
|
||||
x: {
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
ticks: {
|
||||
color: theme.palette.text.primary,
|
||||
font: {
|
||||
size: fontSize,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
};
|
||||
|
||||
function mergeAll<T>(objects: Partial<T>[]): T {
|
||||
return merge.all<T>(objects.filter((i) => i));
|
||||
}
|
||||
|
||||
const LifecycleChartComponent: FC<{
|
||||
data: ChartData<'bar', unknown>;
|
||||
overrideOptions?: ChartOptions<'bar'>;
|
||||
}> = ({ data, overrideOptions }) => {
|
||||
const theme = useTheme();
|
||||
const { locationSettings } = useLocationSettings();
|
||||
|
||||
const options = useMemo(
|
||||
() => mergeAll([createOptions(theme)]),
|
||||
[theme, locationSettings, overrideOptions],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Bar options={options} data={data} plugins={[ChartDataLabels]} />
|
||||
{/* todo: implement fallback for screen readers */}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Chart.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
BarElement,
|
||||
TimeScale,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler,
|
||||
);
|
||||
|
||||
// for lazy-loading
|
||||
export default LifecycleChartComponent;
|
@ -3,13 +3,73 @@ import type { FC } from 'react';
|
||||
import { FilterItemParam } from 'utils/serializeQueryParams';
|
||||
import { InsightsSection } from 'component/insights/sections/InsightsSection';
|
||||
import { InsightsFilters } from 'component/insights/InsightsFilters';
|
||||
import { allOption } from 'component/common/ProjectSelect/ProjectSelect';
|
||||
import { useInsights } from 'hooks/api/getters/useInsights/useInsights';
|
||||
import { LifecycleChart } from '../components/LifecycleChart/LifecycleChart.tsx';
|
||||
import { styled, useTheme } from '@mui/material';
|
||||
|
||||
type LifecycleTrend = {
|
||||
totalFlags: number;
|
||||
averageTimeInStageDays: number;
|
||||
categories: {
|
||||
experimental: {
|
||||
flagsOlderThanWeek: number;
|
||||
newFlagsThisWeek: number;
|
||||
};
|
||||
release: {
|
||||
flagsOlderThanWeek: number;
|
||||
newFlagsThisWeek: number;
|
||||
};
|
||||
permanent: {
|
||||
flagsOlderThanWeek: number;
|
||||
newFlagsThisWeek: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type LifecycleInsights = {
|
||||
develop: LifecycleTrend;
|
||||
production: LifecycleTrend;
|
||||
cleanup: LifecycleTrend;
|
||||
};
|
||||
|
||||
const useChartColors = () => {
|
||||
const theme = useTheme();
|
||||
return {
|
||||
olderThanWeek: theme.palette.primary.light,
|
||||
newThisWeek: theme.palette.success.border,
|
||||
};
|
||||
};
|
||||
|
||||
const ChartRow = styled('div')(({ theme }) => ({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||
gap: theme.spacing(2),
|
||||
}));
|
||||
|
||||
const ChartContainer = styled('article')(({ theme }) => ({
|
||||
background: theme.palette.background.default,
|
||||
borderRadius: theme.shape.borderRadiusLarge,
|
||||
padding: theme.spacing(2),
|
||||
minWidth: 0,
|
||||
}));
|
||||
|
||||
export const LifecycleInsights: FC = () => {
|
||||
const statePrefix = 'lifecycle-';
|
||||
const stateConfig = {
|
||||
[`${statePrefix}project`]: FilterItemParam,
|
||||
};
|
||||
const [state, setState] = usePersistentTableState('insights', stateConfig);
|
||||
const [state, setState] = usePersistentTableState(
|
||||
'insights-lifecycle',
|
||||
stateConfig,
|
||||
);
|
||||
|
||||
// todo: use data from the actual endpoint when we have something useful to return
|
||||
const projects = state[`${statePrefix}project`]?.values ?? [allOption.id];
|
||||
const { insights, loading } = useInsights();
|
||||
|
||||
// @ts-expect-error (lifecycleMetrics): The schema hasn't been updated yet.
|
||||
const { lifecycleTrends } = insights;
|
||||
|
||||
return (
|
||||
<InsightsSection
|
||||
@ -21,6 +81,144 @@ export const LifecycleInsights: FC = () => {
|
||||
filterNamePrefix={statePrefix}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ChartRow>
|
||||
{Object.entries(mockData).map(([stage, data]) => {
|
||||
return (
|
||||
<ChartContainer key={stage}>
|
||||
<Chart data={data} />
|
||||
</ChartContainer>
|
||||
);
|
||||
})}
|
||||
</ChartRow>
|
||||
</InsightsSection>
|
||||
);
|
||||
};
|
||||
|
||||
const Chart: React.FC<{ data: LifecycleTrend }> = ({ data }) => {
|
||||
const chartColors = useChartColors();
|
||||
const oldData = [
|
||||
data.categories.experimental.flagsOlderThanWeek,
|
||||
data.categories.release.flagsOlderThanWeek,
|
||||
data.categories.permanent.flagsOlderThanWeek,
|
||||
];
|
||||
return (
|
||||
<LifecycleChart
|
||||
data={{
|
||||
labels: [`Experimental`, `Release`, `Other flags`],
|
||||
datasets: [
|
||||
{
|
||||
label: 'Flags > 1 week old',
|
||||
data: oldData,
|
||||
stack: '1',
|
||||
backgroundColor: chartColors.olderThanWeek,
|
||||
borderRadius: 4,
|
||||
datalabels: {
|
||||
labels: {
|
||||
value: {
|
||||
formatter: (value, context) => {
|
||||
// todo (lifecycleMetrics): use a nice
|
||||
// formatter here, so that 1,000,000
|
||||
// flags are instead formatted as 1M
|
||||
if (
|
||||
context.chart.legend
|
||||
?.legendItems?.[1].hidden
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
return '';
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'New flags this week',
|
||||
data: [
|
||||
data.categories.experimental.newFlagsThisWeek,
|
||||
data.categories.release.newFlagsThisWeek,
|
||||
data.categories.permanent.newFlagsThisWeek,
|
||||
],
|
||||
stack: '1',
|
||||
backgroundColor: chartColors.newThisWeek,
|
||||
borderRadius: 4,
|
||||
datalabels: {
|
||||
labels: {
|
||||
value: {
|
||||
formatter: (value, context) => {
|
||||
if (
|
||||
context.chart.legend
|
||||
?.legendItems?.[0].hidden
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
return (
|
||||
value + oldData[context.dataIndex]
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const mockData: LifecycleInsights = {
|
||||
develop: {
|
||||
totalFlags: 35,
|
||||
averageTimeInStageDays: 28,
|
||||
categories: {
|
||||
experimental: {
|
||||
flagsOlderThanWeek: 11,
|
||||
newFlagsThisWeek: 4,
|
||||
},
|
||||
release: {
|
||||
flagsOlderThanWeek: 12,
|
||||
newFlagsThisWeek: 1,
|
||||
},
|
||||
permanent: {
|
||||
flagsOlderThanWeek: 7,
|
||||
newFlagsThisWeek: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
production: {
|
||||
totalFlags: 10,
|
||||
averageTimeInStageDays: 14,
|
||||
categories: {
|
||||
experimental: {
|
||||
flagsOlderThanWeek: 2,
|
||||
newFlagsThisWeek: 3,
|
||||
},
|
||||
release: {
|
||||
flagsOlderThanWeek: 1,
|
||||
newFlagsThisWeek: 1,
|
||||
},
|
||||
permanent: {
|
||||
flagsOlderThanWeek: 3,
|
||||
newFlagsThisWeek: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
cleanup: {
|
||||
totalFlags: 5,
|
||||
averageTimeInStageDays: 16,
|
||||
categories: {
|
||||
experimental: {
|
||||
flagsOlderThanWeek: 0,
|
||||
newFlagsThisWeek: 3,
|
||||
},
|
||||
release: {
|
||||
flagsOlderThanWeek: 0,
|
||||
newFlagsThisWeek: 1,
|
||||
},
|
||||
permanent: {
|
||||
flagsOlderThanWeek: 1,
|
||||
newFlagsThisWeek: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -4106,6 +4106,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"chartjs-plugin-datalabels@npm:^2.2.0":
|
||||
version: 2.2.0
|
||||
resolution: "chartjs-plugin-datalabels@npm:2.2.0"
|
||||
peerDependencies:
|
||||
chart.js: ">=3.0.0"
|
||||
checksum: 10c0/de4855a795e4eef34869a16db1a8a0f905b6dfed0258c733338f472625361eb56fb899214b18651c1c1064cd343a78285ba576576693a40ec51285a84f022ea0
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"check-error@npm:^2.1.1":
|
||||
version: 2.1.1
|
||||
resolution: "check-error@npm:2.1.1"
|
||||
@ -10138,6 +10147,7 @@ __metadata:
|
||||
chart.js: "npm:3.9.1"
|
||||
chartjs-adapter-date-fns: "npm:3.0.0"
|
||||
chartjs-plugin-annotation: "npm:2.2.1"
|
||||
chartjs-plugin-datalabels: "npm:^2.2.0"
|
||||
classnames: "npm:2.5.1"
|
||||
copy-to-clipboard: "npm:3.3.3"
|
||||
countries-and-timezones: "npm:^3.4.0"
|
||||
|
Loading…
Reference in New Issue
Block a user