-
-
Notifications
You must be signed in to change notification settings - Fork 730
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: personal flag metrics display #8232
Changes from 1 commit
ac7bd89
2415421
0ce84f2
0a34043
8bd0fe6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,18 +2,27 @@ | |
BarElement, | ||
CategoryScale, | ||
Chart as ChartJS, | ||
type ChartOptions, | ||
Legend, | ||
LinearScale, | ||
Title, | ||
Tooltip, | ||
} from 'chart.js'; | ||
import annotationPlugin from 'chartjs-plugin-annotation'; | ||
import { Bar } from 'react-chartjs-2'; | ||
import type { Theme } from '@mui/material/styles/createTheme'; | ||
import useTheme from '@mui/material/styles/useTheme'; | ||
import { useMemo } from 'react'; | ||
import { formatTickValue } from 'component/common/Chart/formatTickValue'; | ||
import { type FC, useEffect, useMemo, useState } from 'react'; | ||
import { Box, Typography } from '@mui/material'; | ||
import { FeatureMetricsHours } from '../feature/FeatureView/FeatureMetrics/FeatureMetricsHours/FeatureMetricsHours'; | ||
import GeneralSelect from '../common/GeneralSelect/GeneralSelect'; | ||
import { useProjectEnvironments } from 'hooks/api/getters/useProjectEnvironments/useProjectEnvironments'; | ||
import { useFeatureMetricsRaw } from 'hooks/api/getters/useFeatureMetricsRaw/useFeatureMetricsRaw'; | ||
import { useLocationSettings } from 'hooks/useLocationSettings'; | ||
import { createChartData } from './createChartData'; | ||
import { aggregateFeatureMetrics } from '../feature/FeatureView/FeatureMetrics/aggregateFeatureMetrics'; | ||
import { | ||
createBarChartOptions, | ||
createPlaceholderBarChartOptions, | ||
} from './createChartOptions'; | ||
|
||
const defaultYes = [ | ||
45_000_000, 28_000_000, 28_000_000, 25_000_000, 50_000_000, 27_000_000, | ||
|
@@ -30,7 +39,7 @@ | |
3_000_000, 8_000_000, 2_000_000, | ||
]; | ||
|
||
const data = { | ||
const placeholderData = { | ||
labels: Array.from({ length: 30 }, (_, i) => i + 1), | ||
datasets: [ | ||
{ | ||
|
@@ -48,73 +57,143 @@ | |
], | ||
}; | ||
|
||
const createBarChartOptions = (theme: Theme): ChartOptions<'bar'> => ({ | ||
plugins: { | ||
legend: { | ||
position: 'bottom', | ||
labels: { | ||
color: theme.palette.text.primary, | ||
pointStyle: 'circle', | ||
usePointStyle: true, | ||
boxHeight: 6, | ||
padding: 15, | ||
boxPadding: 5, | ||
}, | ||
}, | ||
tooltip: { | ||
enabled: false, | ||
}, | ||
}, | ||
responsive: true, | ||
scales: { | ||
x: { | ||
stacked: true, | ||
ticks: { | ||
color: theme.palette.text.secondary, | ||
}, | ||
grid: { | ||
display: false, | ||
}, | ||
}, | ||
y: { | ||
stacked: true, | ||
ticks: { | ||
color: theme.palette.text.secondary, | ||
maxTicksLimit: 5, | ||
callback: formatTickValue, | ||
}, | ||
grid: { | ||
drawBorder: false, | ||
}, | ||
}, | ||
}, | ||
elements: { | ||
bar: { | ||
borderRadius: 5, | ||
}, | ||
}, | ||
interaction: { | ||
mode: 'index', | ||
intersect: false, | ||
}, | ||
}); | ||
|
||
export const PlaceholderFlagMetricsChart = () => { | ||
const theme = useTheme(); | ||
|
||
const options = useMemo(() => { | ||
return createBarChartOptions(theme); | ||
return createPlaceholderBarChartOptions(theme); | ||
}, [theme]); | ||
|
||
return ( | ||
<Bar | ||
data={data} | ||
options={options} | ||
aria-label='A bar chart with a single feature flag exposure metrics' | ||
<> | ||
<Typography sx={{ mb: 4 }}>Feature flag metrics</Typography> | ||
<Bar | ||
data={placeholderData} | ||
options={options} | ||
aria-label='A placeholder bar chart with a single feature flag exposure metrics' | ||
/> | ||
</> | ||
); | ||
}; | ||
|
||
const useMetricsEnvironments = (project: string) => { | ||
const [environment, setEnvironment] = useState<string | null>(null); | ||
const { environments } = useProjectEnvironments(project); | ||
const activeEnvironments = environments.filter((env) => env.enabled); | ||
const firstProductionEnvironment = activeEnvironments.find( | ||
(env) => env.type === 'production', | ||
); | ||
|
||
useEffect(() => { | ||
if (firstProductionEnvironment) { | ||
setEnvironment(firstProductionEnvironment.name); | ||
} else if (activeEnvironments.length > 0) { | ||
setEnvironment(activeEnvironments[0].name); | ||
} | ||
}, [JSON.stringify(activeEnvironments)]); | ||
|
||
return { environment, setEnvironment, activeEnvironments }; | ||
}; | ||
|
||
const useFlagMetrics = ( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. copied all this code from the feature metrics. One difference is that now we have a hook that prepares chart options + chart data in one function instead of going through multiple components |
||
flagName: string, | ||
environment: string, | ||
hoursBack: number, | ||
) => { | ||
const { featureMetrics: metrics = [] } = useFeatureMetricsRaw( | ||
flagName, | ||
hoursBack, | ||
); | ||
const sortedMetrics = useMemo(() => { | ||
return [...metrics].sort((metricA, metricB) => { | ||
return metricA.timestamp.localeCompare(metricB.timestamp); | ||
}); | ||
}, [metrics]); | ||
const filteredMetrics = useMemo(() => { | ||
return aggregateFeatureMetrics( | ||
sortedMetrics?.filter( | ||
(metric) => environment === metric.environment, | ||
), | ||
).map((metric) => ({ | ||
...metric, | ||
appName: 'all selected', | ||
})); | ||
}, [sortedMetrics, environment]); | ||
|
||
const data = useMemo(() => { | ||
return createChartData(filteredMetrics); | ||
}, [filteredMetrics]); | ||
|
||
const theme = useTheme(); | ||
const { locationSettings } = useLocationSettings(); | ||
const options = useMemo(() => { | ||
return createBarChartOptions(theme, hoursBack, locationSettings); | ||
}, [theme, hoursBack, locationSettings]); | ||
|
||
return { data, options }; | ||
}; | ||
|
||
const EnvironmentSelect: FC<{ | ||
activeEnvironments: { name: string }[]; | ||
environment: string; | ||
setEnvironment: () => void; | ||
}> = ({ activeEnvironments, environment, setEnvironment }) => { | ||
return ( | ||
<GeneralSelect | ||
name='feature-environments' | ||
id='feature-environments' | ||
options={activeEnvironments.map((env) => ({ | ||
key: env.name, | ||
label: env.name, | ||
}))} | ||
value={String(environment)} | ||
onChange={setEnvironment} | ||
/> | ||
); | ||
}; | ||
|
||
const MetricsSelectors = styled(Box)(({ theme }) => ({ | ||
Check failure on line 155 in frontend/src/component/personalDashboard/FlagMetricsChart.tsx GitHub Actions / buildsrc/hooks/useRecentlyVisited.test.tsx
Check failure on line 155 in frontend/src/component/personalDashboard/FlagMetricsChart.tsx GitHub Actions / buildsrc/component/changeRequest/ChangeRequest.test.tsx
Check failure on line 155 in frontend/src/component/personalDashboard/FlagMetricsChart.tsx GitHub Actions / buildsrc/component/menu/__tests__/routes.test.tsx
Check failure on line 155 in frontend/src/component/personalDashboard/FlagMetricsChart.tsx GitHub Actions / buildsrc/component/layout/MainLayout/NavigationSidebar/NavigationSidebar.test.tsx
|
||
display: 'flex', | ||
justifyContent: 'flex-end', | ||
gap: theme.spacing(2), | ||
mb: theme.spacing(6), | ||
})); | ||
|
||
export const FlagMetricsChart: FC<{ | ||
flag: { name: string; project: string }; | ||
}> = ({ flag }) => { | ||
const [hoursBack, setHoursBack] = useState(48); | ||
|
||
const { environment, setEnvironment, activeEnvironments } = | ||
useMetricsEnvironments(flag.project); | ||
|
||
const { data, options } = useFlagMetrics(flag.name, environment, hoursBack); | ||
|
||
return ( | ||
<> | ||
<MetricsSelectors> | ||
<EnvironmentSelect | ||
project={flag.project} | ||
environment={environment} | ||
setEnvironment={setEnvironment} | ||
activeEnvironments={activeEnvironments} | ||
/> | ||
<FeatureMetricsHours | ||
hoursBack={hoursBack} | ||
setHoursBack={setHoursBack} | ||
label={null} | ||
/> | ||
</MetricsSelectors> | ||
|
||
<Bar | ||
data={data} | ||
options={options} | ||
aria-label='A bar chart with a single feature flag exposure metrics' | ||
/> | ||
</> | ||
); | ||
}; | ||
|
||
ChartJS.register( | ||
annotationPlugin, | ||
CategoryScale, | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,42 @@ | ||
import type { IFeatureMetricsRaw } from 'interfaces/featureToggle'; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this is a copy paste of the feature metrics with colors swapped and total series removed |
||
import type { ChartData } from 'chart.js'; | ||
import 'chartjs-adapter-date-fns'; | ||
|
||
export interface IPoint { | ||
x: string; | ||
y: number; | ||
variants: Record<string, number>; | ||
} | ||
|
||
export const createChartData = ( | ||
metrics: IFeatureMetricsRaw[], | ||
): ChartData<'bar', IPoint[], string> => { | ||
const yesSeries = { | ||
label: 'Exposed', | ||
hoverBackgroundColor: '#A39EFF', | ||
backgroundColor: '#A39EFF', | ||
data: createChartPoints(metrics, (m) => m.yes), | ||
}; | ||
|
||
const noSeries = { | ||
label: 'Not exposed', | ||
hoverBackgroundColor: '#D8D6FF', | ||
backgroundColor: '#D8D6FF', | ||
data: createChartPoints(metrics, (m) => m.no), | ||
}; | ||
|
||
return { | ||
datasets: [yesSeries, noSeries], | ||
}; | ||
}; | ||
|
||
const createChartPoints = ( | ||
metrics: IFeatureMetricsRaw[], | ||
y: (m: IFeatureMetricsRaw) => number, | ||
): IPoint[] => { | ||
return metrics.map((metric) => ({ | ||
x: metric.timestamp, | ||
y: y(metric), | ||
variants: metric.variants || {}, | ||
})); | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
allowing to disable the label by passing undefined. In the feature metrics the label is needed but in personal dashboard it's not.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As long as this doesn't cause any a11y issues, I have no problems. But I notice that the general select component doesn't have a label associated with it. Input elements should always be properly labeled (with label elements tied to the component). You can hide the label visually if you want, but it should def be there for assistive tech.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Of course, the input's been around for a while, probably, but we can still fix it up now that we're here 💁🏼