Skip to content
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

Merged
merged 5 commits into from
Sep 24, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import GeneralSelect, {
type IGeneralSelectProps,
} from 'component/common/GeneralSelect/GeneralSelect';
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
import { useEffect } from 'react';
import { type ReactNode, useEffect } from 'react';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';

const StyledTitle = styled('h2')(({ theme }) => ({
Expand All @@ -17,13 +17,15 @@ const StyledTitle = styled('h2')(({ theme }) => ({
interface IFeatureMetricsHoursProps {
hoursBack: number;
setHoursBack: (value: number) => void;
label?: ReactNode;
}

export const FEATURE_METRIC_HOURS_BACK_DEFAULT = 48;

export const FeatureMetricsHours = ({
hoursBack,
setHoursBack,
label = <StyledTitle>Period</StyledTitle>,
Copy link
Contributor Author

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.

Copy link
Contributor

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.

Copy link
Contributor

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 💁🏼

}: IFeatureMetricsHoursProps) => {
const { trackEvent } = usePlausibleTracker();

Expand Down Expand Up @@ -55,7 +57,7 @@ export const FeatureMetricsHours = ({

return (
<div>
<StyledTitle>Period</StyledTitle>
{label}
<GeneralSelect
name='feature-metrics-period'
id='feature-metrics-period'
Expand Down
201 changes: 140 additions & 61 deletions frontend/src/component/personalDashboard/FlagMetricsChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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: [
{
Expand All @@ -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 = (
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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

View workflow job for this annotation

GitHub Actions / build

src/hooks/useRecentlyVisited.test.tsx

ReferenceError: styled is not defined ❯ src/component/personalDashboard/FlagMetricsChart.tsx:155:26 ❯ src/component/personalDashboard/PersonalDashboard.tsx:10:31

Check failure on line 155 in frontend/src/component/personalDashboard/FlagMetricsChart.tsx

View workflow job for this annotation

GitHub Actions / build

src/component/changeRequest/ChangeRequest.test.tsx

ReferenceError: styled is not defined ❯ src/component/personalDashboard/FlagMetricsChart.tsx:155:26 ❯ src/component/personalDashboard/PersonalDashboard.tsx:10:31

Check failure on line 155 in frontend/src/component/personalDashboard/FlagMetricsChart.tsx

View workflow job for this annotation

GitHub Actions / build

src/component/menu/__tests__/routes.test.tsx

ReferenceError: styled is not defined ❯ src/component/personalDashboard/FlagMetricsChart.tsx:155:26 ❯ src/component/personalDashboard/PersonalDashboard.tsx:10:31

Check failure on line 155 in frontend/src/component/personalDashboard/FlagMetricsChart.tsx

View workflow job for this annotation

GitHub Actions / build

src/component/layout/MainLayout/NavigationSidebar/NavigationSidebar.test.tsx

ReferenceError: styled is not defined ❯ src/component/personalDashboard/FlagMetricsChart.tsx:155:26 ❯ src/component/personalDashboard/PersonalDashboard.tsx:10:31
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,
Expand Down
23 changes: 16 additions & 7 deletions frontend/src/component/personalDashboard/PersonalDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,17 @@ import { useProfile } from 'hooks/api/getters/useProfile/useProfile';
import LinkIcon from '@mui/icons-material/Link';
import { Badge } from '../common/Badge/Badge';
import { ConnectSDK, CreateFlag } from './ConnectSDK';
import { PlaceholderFlagMetricsChart } from './FlagMetricsChart';
import {
FlagMetricsChart,
PlaceholderFlagMetricsChart,
} from './FlagMetricsChart';
import { WelcomeDialog } from './WelcomeDialog';
import { useLocalStorageState } from 'hooks/useLocalStorageState';
import useProjectOverview from 'hooks/api/getters/useProjectOverview/useProjectOverview';
import { ProjectSetupComplete } from './ProjectSetupComplete';
import { usePersonalDashboard } from 'hooks/api/getters/usePersonalDashboard/usePersonalDashboard';
import { getFeatureTypeIcons } from 'utils/getFeatureTypeIcons';
import type { PersonalDashboardSchema } from '../../openapi';

const ScreenExplanation = styled(Typography)(({ theme }) => ({
marginTop: theme.spacing(1),
Expand Down Expand Up @@ -178,10 +182,12 @@ export const PersonalDashboard = () => {
const { projects, activeProject, setActiveProject } = useProjects();

const { personalDashboard } = usePersonalDashboard();
const [activeFlag, setActiveFlag] = useState<string | null>(null);
const [activeFlag, setActiveFlag] = useState<
PersonalDashboardSchema['flags'][0] | null
>(null);
useEffect(() => {
if (personalDashboard?.flags.length) {
setActiveFlag(personalDashboard.flags[0].name);
setActiveFlag(personalDashboard.flags[0]);
}
}, [JSON.stringify(personalDashboard)]);

Expand Down Expand Up @@ -307,8 +313,8 @@ export const PersonalDashboard = () => {
<FlagListItem
key={flag.name}
flag={flag}
selected={flag.name === activeFlag}
onClick={() => setActiveFlag(flag.name)}
selected={flag.name === activeFlag?.name}
onClick={() => setActiveFlag(flag)}
/>
))}
</List>
Expand All @@ -321,8 +327,11 @@ export const PersonalDashboard = () => {
</SpacedGridItem>

<SpacedGridItem item lg={8} md={1}>
<Typography sx={{ mb: 4 }}>Feature flag metrics</Typography>
<PlaceholderFlagMetricsChart />
{activeFlag ? (
<FlagMetricsChart flag={activeFlag} />
) : (
<PlaceholderFlagMetricsChart />
)}
</SpacedGridItem>
</ContentGrid>
<WelcomeDialog
Expand Down
42 changes: 42 additions & 0 deletions frontend/src/component/personalDashboard/createChartData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { IFeatureMetricsRaw } from 'interfaces/featureToggle';
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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 || {},
}));
};
Loading
Loading