1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-09-01 13:47:27 +02:00

refactor: apply previous pr comments

This commit is contained in:
Tymoteusz Czech 2025-07-01 16:52:29 +02:00
parent 75d3a5d8d6
commit 41e0c3a653
No known key found for this signature in database
GPG Key ID: 133555230D88D75F
11 changed files with 297 additions and 598 deletions

View File

@ -1,5 +1,5 @@
import type { FC } from 'react'; import type { FC } from 'react';
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect } from 'react';
import { import {
Dialog, Dialog,
DialogTitle, DialogTitle,
@ -8,21 +8,11 @@ import {
Button, Button,
TextField, TextField,
Box, Box,
Typography,
Alert,
styled, styled,
} from '@mui/material'; } from '@mui/material';
import { ImpactMetricsControls } from './ImpactMetricsControls/ImpactMetricsControls.tsx'; import { ImpactMetricsControls } from './ImpactMetricsControls/ImpactMetricsControls.tsx';
import { import { ImpactMetricsChartPreview } from './ImpactMetricsChartPreview.tsx';
LineChart,
NotEnoughData,
} from '../insights/components/LineChart/LineChart.tsx';
import { StyledChartContainer } from 'component/insights/InsightsCharts.styles';
import { useImpactMetricsData } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData'; import { useImpactMetricsData } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData';
import { usePlaceholderData } from '../insights/hooks/usePlaceholderData.js';
import { getDisplayFormat, getTimeUnit, formatLargeNumbers } from './utils.ts';
import { fromUnixTime } from 'date-fns';
import { useChartData } from './hooks/useChartData.ts';
import type { ChartConfig } from './types.ts'; import type { ChartConfig } from './types.ts';
import type { ImpactMetricsSeries } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata'; import type { ImpactMetricsSeries } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata';
@ -82,25 +72,6 @@ export const ChartConfigModal: FC<ChartConfigModalProps> = ({
Record<string, string[]> Record<string, string[]>
>(initialConfig?.selectedLabels || {}); >(initialConfig?.selectedLabels || {});
// Data for preview
const {
data: { start, end, series: timeSeriesData },
loading: dataLoading,
error: dataError,
} = useImpactMetricsData(
selectedSeries
? {
series: selectedSeries,
range: selectedRange,
labels:
Object.keys(selectedLabels).length > 0
? selectedLabels
: undefined,
}
: undefined,
);
// Fetch available labels for the currently selected series
const { const {
data: { labels: currentAvailableLabels }, data: { labels: currentAvailableLabels },
} = useImpactMetricsData( } = useImpactMetricsData(
@ -112,40 +83,6 @@ export const ChartConfigModal: FC<ChartConfigModalProps> = ({
: undefined, : undefined,
); );
const placeholderData = usePlaceholderData({
fill: true,
type: 'constant',
});
const data = useChartData(timeSeriesData);
const hasError = !!dataError;
const isLoading = dataLoading;
const shouldShowPlaceholder = !selectedSeries || isLoading || hasError;
const notEnoughData = useMemo(
() =>
!isLoading &&
(!timeSeriesData ||
timeSeriesData.length === 0 ||
!data.datasets.some((d) => d.data.length > 1)),
[data, isLoading, timeSeriesData],
);
const minTime = start
? fromUnixTime(Number.parseInt(start, 10))
: undefined;
const maxTime = end ? fromUnixTime(Number.parseInt(end, 10)) : undefined;
const placeholder = selectedSeries ? (
<NotEnoughData description='Send impact metrics using Unleash SDK and select data series to view the chart.' />
) : (
<NotEnoughData
title='Select a metric series to view the chart.'
description=''
/>
);
const cover = notEnoughData ? placeholder : isLoading;
useEffect(() => { useEffect(() => {
if (open && initialConfig) { if (open && initialConfig) {
setTitle(initialConfig.title || ''); setTitle(initialConfig.title || '');
@ -232,100 +169,13 @@ export const ChartConfigModal: FC<ChartConfigModalProps> = ({
availableLabels={currentAvailableLabels} availableLabels={currentAvailableLabels}
/> />
</StyledConfigPanel> </StyledConfigPanel>
{/* Preview Panel */}
<StyledPreviewPanel> <StyledPreviewPanel>
<Typography variant='h6' color='text.secondary'> <ImpactMetricsChartPreview
Preview selectedSeries={selectedSeries}
</Typography> selectedRange={selectedRange}
selectedLabels={selectedLabels}
{!selectedSeries && !isLoading ? ( beginAtZero={beginAtZero}
<Typography variant='body2' color='text.secondary'>
Select a metric series to view the preview
</Typography>
) : null}
<StyledChartContainer>
{hasError ? (
<Alert severity='error'>
Failed to load impact metrics. Please check
if Prometheus is configured and the feature
flag is enabled.
</Alert>
) : null}
<LineChart
data={
notEnoughData || isLoading
? placeholderData
: data
}
overrideOptions={
shouldShowPlaceholder
? {}
: {
scales: {
x: {
type: 'time',
min: minTime?.getTime(),
max: maxTime?.getTime(),
time: {
unit: getTimeUnit(
selectedRange,
),
displayFormats: {
[getTimeUnit(
selectedRange,
)]:
getDisplayFormat(
selectedRange,
),
},
tooltipFormat: 'PPpp',
},
},
y: {
beginAtZero,
title: {
display: false,
},
ticks: {
precision: 0,
callback: (
value: unknown,
): string | number =>
typeof value ===
'number'
? formatLargeNumbers(
value,
)
: (value as number),
},
},
},
plugins: {
legend: {
display:
timeSeriesData &&
timeSeriesData.length >
1,
position:
'bottom' as const,
labels: {
usePointStyle: true,
boxWidth: 8,
padding: 12,
},
},
},
animations: {
x: { duration: 0 },
y: { duration: 0 },
},
}
}
cover={cover}
/> />
</StyledChartContainer>
</StyledPreviewPanel> </StyledPreviewPanel>
</Box> </Box>
</DialogContent> </DialogContent>

View File

@ -1,25 +1,9 @@
import type { FC } from 'react'; import type { FC } from 'react';
import { useMemo } from 'react'; import { Box, Typography, IconButton, styled, Paper } from '@mui/material';
import {
Box,
Typography,
IconButton,
Alert,
styled,
Paper,
} from '@mui/material';
import Edit from '@mui/icons-material/Edit'; import Edit from '@mui/icons-material/Edit';
import Delete from '@mui/icons-material/Delete'; import Delete from '@mui/icons-material/Delete';
import DragHandle from '@mui/icons-material/DragHandle'; import DragHandle from '@mui/icons-material/DragHandle';
import { import { ImpactMetricsChart } from './ImpactMetricsChart.tsx';
LineChart,
NotEnoughData,
} from '../insights/components/LineChart/LineChart.tsx';
import { useImpactMetricsData } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData';
import { usePlaceholderData } from '../insights/hooks/usePlaceholderData.js';
import { getDisplayFormat, getTimeUnit, formatLargeNumbers } from './utils.ts';
import { fromUnixTime } from 'date-fns';
import { useChartData } from './hooks/useChartData.ts';
import type { ChartConfig } from './types.ts'; import type { ChartConfig } from './types.ts';
export interface ChartItemProps { export interface ChartItemProps {
@ -32,46 +16,30 @@ const getConfigDescription = (config: ChartConfig): string => {
const parts: string[] = []; const parts: string[] = [];
if (config.selectedSeries) { if (config.selectedSeries) {
parts.push(`Series: ${config.selectedSeries}`); parts.push(`${config.selectedSeries}`);
} }
parts.push(`Time range: last ${config.selectedRange}`); parts.push(`last ${config.selectedRange}`);
if (config.beginAtZero) {
parts.push('Begin at zero');
}
const labelCount = Object.keys(config.selectedLabels).length; const labelCount = Object.keys(config.selectedLabels).length;
if (labelCount > 0) { if (labelCount > 0) {
parts.push(`${labelCount} label filter${labelCount > 1 ? 's' : ''}`); parts.push(`${labelCount} filter${labelCount > 1 ? 's' : ''}`);
} }
return parts.join(' • '); return parts.join(' • ');
}; };
const StyledHeader = styled(Box)(({ theme }) => ({ const StyledChartWrapper = styled(Box)({
display: 'flex', height: '100%',
justifyContent: 'space-between', width: '100%',
alignItems: 'flex-start', '& > div': {
padding: theme.spacing(2, 3), height: '100% !important',
borderBottom: `1px solid ${theme.palette.divider}`, width: '100% !important',
}));
const StyledDragHandle = styled(Box)(({ theme }) => ({
display: 'flex',
alignItems: 'center',
cursor: 'move',
padding: theme.spacing(0.5),
borderRadius: theme.shape.borderRadius,
color: theme.palette.text.secondary,
'&:hover': {
backgroundColor: theme.palette.action.hover,
color: theme.palette.text.primary,
}, },
})); });
const StyledWidget = styled(Paper)(({ theme }) => ({ const StyledWidget = styled(Paper)(({ theme }) => ({
borderRadius: `${theme.shape.borderRadiusLarge}px`, borderRadius: `${theme.shape.borderRadiusMedium}px`,
boxShadow: 'none', boxShadow: 'none',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
@ -97,182 +65,81 @@ const StyledImpactChartContainer = styled(Box)(({ theme }) => ({
padding: theme.spacing(3), padding: theme.spacing(3),
})); }));
const StyledChartWrapper = styled(Box)({ const StyledHeader = styled(Box)(({ theme }) => ({
height: '100%', display: 'flex',
width: '100%', gap: theme.spacing(2),
'& > div': { alignItems: 'center',
height: '100% !important', padding: theme.spacing(1.5, 2),
width: '100% !important', borderBottom: `1px solid ${theme.palette.divider}`,
}));
const StyledDragHandle = styled(Box)(({ theme }) => ({
display: 'flex',
alignItems: 'center',
cursor: 'move',
padding: theme.spacing(0.5),
borderRadius: theme.shape.borderRadius,
color: theme.palette.text.secondary,
'&:hover': {
backgroundColor: theme.palette.action.hover,
color: theme.palette.text.primary,
}, },
}); }));
export const ChartItem: FC<ChartItemProps> = ({ config, onEdit, onDelete }) => { const StyledChartTitle = styled(Box)(({ theme }) => ({
const { display: 'flex',
data: { start, end, series: timeSeriesData }, flexDirection: 'column',
loading: dataLoading, justifyContent: 'flex-end',
error: dataError, flexGrow: 1,
} = useImpactMetricsData({ overflow: 'hidden',
series: config.selectedSeries, textOverflow: 'ellipsis',
range: config.selectedRange, }));
labels:
Object.keys(config.selectedLabels).length > 0
? config.selectedLabels
: undefined,
});
const placeholderData = usePlaceholderData({ const StyledChartActions = styled(Box)(({ theme }) => ({
fill: true, marginLeft: 'auto',
type: 'constant', display: 'flex',
}); alignItems: 'center',
gap: theme.spacing(0.5),
}));
const data = useChartData(timeSeriesData); export const ChartItem: FC<ChartItemProps> = ({ config, onEdit, onDelete }) => (
const hasError = !!dataError;
const isLoading = dataLoading;
const shouldShowPlaceholder = isLoading || hasError;
const notEnoughData = useMemo(
() =>
!isLoading &&
(!timeSeriesData ||
timeSeriesData.length === 0 ||
!data.datasets.some((d) => d.data.length > 1)),
[data, isLoading, timeSeriesData],
);
const minTime = start
? fromUnixTime(Number.parseInt(start, 10))
: undefined;
const maxTime = end ? fromUnixTime(Number.parseInt(end, 10)) : undefined;
const placeholder = (
<NotEnoughData description='Send impact metrics using Unleash SDK for this series to view the chart.' />
);
const cover = notEnoughData ? placeholder : isLoading;
return (
<StyledWidget> <StyledWidget>
<StyledHeader> <StyledHeader>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
<StyledDragHandle className='grid-item-drag-handle'> <StyledDragHandle className='grid-item-drag-handle'>
<DragHandle fontSize='small' /> <DragHandle fontSize='small' />
</StyledDragHandle> </StyledDragHandle>
<Box> <StyledChartTitle>
{config.title && ( {config.title && (
<Typography variant='h6' gutterBottom> <Typography variant='h6'>{config.title}</Typography>
{config.title}
</Typography>
)} )}
<Typography <Typography variant='body2' color='text.secondary'>
variant='body2'
color='text.secondary'
sx={{ mb: 1 }}
>
{getConfigDescription(config)} {getConfigDescription(config)}
</Typography> </Typography>
</Box> </StyledChartTitle>
</Box> <StyledChartActions>
<Box> <IconButton onClick={() => onEdit(config)}>
<IconButton <Edit />
onClick={() => onEdit(config)}
size='small'
sx={{ mr: 1 }}
>
<Edit fontSize='small' />
</IconButton> </IconButton>
<IconButton <IconButton onClick={() => onDelete(config.id)}>
onClick={() => onDelete(config.id)} <Delete />
size='small'
>
<Delete fontSize='small' />
</IconButton> </IconButton>
</Box> </StyledChartActions>
</StyledHeader> </StyledHeader>
<StyledChartContent> <StyledChartContent>
<StyledImpactChartContainer> <StyledImpactChartContainer>
{hasError ? (
<Alert severity='error'>
Failed to load impact metrics. Please check if
Prometheus is configured and the feature flag is
enabled.
</Alert>
) : null}
<StyledChartWrapper> <StyledChartWrapper>
<LineChart <ImpactMetricsChart
data={ selectedSeries={config.selectedSeries}
notEnoughData || isLoading selectedRange={config.selectedRange}
? placeholderData selectedLabels={config.selectedLabels}
: data beginAtZero={config.beginAtZero}
}
aspectRatio={1.5} aspectRatio={1.5}
overrideOptions={ overrideOptions={{ maintainAspectRatio: false }}
shouldShowPlaceholder emptyDataDescription='Send impact metrics using Unleash SDK for this series to view the chart.'
? { maintainAspectRatio: false }
: {
maintainAspectRatio: false,
scales: {
x: {
type: 'time',
min: minTime?.getTime(),
max: maxTime?.getTime(),
time: {
unit: getTimeUnit(
config.selectedRange,
),
displayFormats: {
[getTimeUnit(
config.selectedRange,
)]: getDisplayFormat(
config.selectedRange,
),
},
tooltipFormat: 'PPpp',
},
},
y: {
beginAtZero:
config.beginAtZero,
title: {
display: false,
},
ticks: {
precision: 0,
callback: (
value: unknown,
): string | number =>
typeof value ===
'number'
? formatLargeNumbers(
value,
)
: (value as number),
},
},
},
plugins: {
legend: {
display:
timeSeriesData &&
timeSeriesData.length > 1,
position: 'bottom' as const,
labels: {
usePointStyle: true,
boxWidth: 8,
padding: 12,
},
},
},
animations: {
x: { duration: 0 },
y: { duration: 0 },
},
}
}
cover={cover}
/> />
</StyledChartWrapper> </StyledChartWrapper>
</StyledImpactChartContainer> </StyledImpactChartContainer>
</StyledChartContent> </StyledChartContent>
</StyledWidget> </StyledWidget>
); );
};

View File

@ -15,7 +15,7 @@ const StyledGridContainer = styled('div')(({ theme }) => ({
'& .react-grid-item': { '& .react-grid-item': {
transition: 'all 200ms ease', transition: 'all 200ms ease',
border: `1px solid ${theme.palette.divider}`, border: `1px solid ${theme.palette.divider}`,
borderRadius: theme.shape.borderRadius, borderRadius: `${theme.shape.borderRadiusMedium}px`,
backgroundColor: theme.palette.background.paper, backgroundColor: theme.palette.background.paper,
overflow: 'hidden', overflow: 'hidden',
'&.react-grid-item--placeholder': { '&.react-grid-item--placeholder': {
@ -44,16 +44,15 @@ const StyledGridContainer = styled('div')(({ theme }) => ({
}, },
'& .react-resizable-handle': { '& .react-resizable-handle': {
position: 'absolute', position: 'absolute',
width: '20px', width: theme.spacing(3),
height: '20px', height: theme.spacing(3),
bottom: '0px', bottom: '0px',
right: '0px', right: '0px',
cursor: 'se-resize', cursor: 'se-resize',
backgroundImage: `url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHZpZXdCb3g9IjAgMCAyMCAyMCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTMgMTdMMTcgM00zIDEzTDEzIDNNNyAxN0wxNyA3IiBzdHJva2U9IiM5OTkiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIi8+Cjwvc3ZnPgo=')`,
backgroundRepeat: 'no-repeat', backgroundRepeat: 'no-repeat',
backgroundPosition: 'center', backgroundPosition: 'center',
'&:hover': { '&::after': {
backgroundImage: `url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHZpZXdCb3g9IjAgMCAyMCAyMCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTMgMTdMMTcgM00zIDEzTDEzIDNNNyAxN0wxNyA3IiBzdHJva2U9IiMzMzMiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIi8+Cjwvc3ZnPgo=')`, display: 'none',
}, },
}, },
})); }));
@ -93,7 +92,6 @@ export const GridLayoutWrapper: FC<GridLayoutWrapperProps> = ({
isResizable = true, isResizable = true,
compactType = 'vertical', compactType = 'vertical',
}) => { }) => {
// Memoize layouts to prevent unnecessary re-renders
const layouts = useMemo(() => { const layouts = useMemo(() => {
const baseLayout = items.map((item, index) => ({ const baseLayout = items.map((item, index) => ({
i: item.id, i: item.id,
@ -133,7 +131,6 @@ export const GridLayoutWrapper: FC<GridLayoutWrapperProps> = ({
}; };
}, [items, cols]); }, [items, cols]);
// Memoize children to improve performance
const children = useMemo( const children = useMemo(
() => items.map((item) => <div key={item.id}>{item.component}</div>), () => items.map((item) => <div key={item.id}>{item.component}</div>),
[items], [items],

View File

@ -7,7 +7,7 @@ import { useImpactMetricsMetadata } from 'hooks/api/getters/useImpactMetricsMeta
import { ChartConfigModal } from './ChartConfigModal.tsx'; import { ChartConfigModal } from './ChartConfigModal.tsx';
import { ChartItem } from './ChartItem.tsx'; import { ChartItem } from './ChartItem.tsx';
import { GridLayoutWrapper, type GridItem } from './GridLayoutWrapper.tsx'; import { GridLayoutWrapper, type GridItem } from './GridLayoutWrapper.tsx';
import { useUrlState } from './hooks/useUrlState.ts'; import { useImpactMetricsState } from './hooks/useImpactMetricsState.ts';
import type { ChartConfig, LayoutItem } from './types.ts'; import type { ChartConfig, LayoutItem } from './types.ts';
const StyledEmptyState = styled(Paper)(({ theme }) => ({ const StyledEmptyState = styled(Paper)(({ theme }) => ({
@ -23,7 +23,7 @@ export const ImpactMetrics: FC = () => {
const [editingChart, setEditingChart] = useState<ChartConfig | undefined>(); const [editingChart, setEditingChart] = useState<ChartConfig | undefined>();
const { charts, layout, addChart, updateChart, deleteChart, updateLayout } = const { charts, layout, addChart, updateChart, deleteChart, updateLayout } =
useUrlState(); useImpactMetricsState();
const { const {
metadata, metadata,

View File

@ -0,0 +1,150 @@
import type { FC, ReactNode } from 'react';
import { useMemo } from 'react';
import { Alert } from '@mui/material';
import {
LineChart,
NotEnoughData,
} from '../insights/components/LineChart/LineChart.tsx';
import { useImpactMetricsData } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData';
import { usePlaceholderData } from '../insights/hooks/usePlaceholderData.js';
import { getDisplayFormat, getTimeUnit, formatLargeNumbers } from './utils.ts';
import { fromUnixTime } from 'date-fns';
import { useChartData } from './hooks/useChartData.ts';
type ImpactMetricsChartProps = {
selectedSeries: string;
selectedRange: 'hour' | 'day' | 'week' | 'month';
selectedLabels: Record<string, string[]>;
beginAtZero: boolean;
aspectRatio?: number;
overrideOptions?: Record<string, unknown>;
errorTitle?: string;
emptyDataDescription?: string;
noSeriesPlaceholder?: ReactNode;
};
export const ImpactMetricsChart: FC<ImpactMetricsChartProps> = ({
selectedSeries,
selectedRange,
selectedLabels,
beginAtZero,
aspectRatio,
overrideOptions = {},
errorTitle = 'Failed to load impact metrics. Please check if Prometheus is configured and the feature flag is enabled.',
emptyDataDescription = 'Send impact metrics using Unleash SDK and select data series to view the chart.',
noSeriesPlaceholder,
}) => {
const {
data: { start, end, series: timeSeriesData },
loading: dataLoading,
error: dataError,
} = useImpactMetricsData(
selectedSeries
? {
series: selectedSeries,
range: selectedRange,
labels:
Object.keys(selectedLabels).length > 0
? selectedLabels
: undefined,
}
: undefined,
);
const placeholderData = usePlaceholderData({
fill: true,
type: 'constant',
});
const data = useChartData(timeSeriesData);
const hasError = !!dataError;
const isLoading = dataLoading;
const shouldShowPlaceholder = !selectedSeries || isLoading || hasError;
const notEnoughData = useMemo(
() =>
!isLoading &&
(!timeSeriesData ||
timeSeriesData.length === 0 ||
!data.datasets.some((d) => d.data.length > 1)),
[data, isLoading, timeSeriesData],
);
const minTime = start
? fromUnixTime(Number.parseInt(start, 10))
: undefined;
const maxTime = end ? fromUnixTime(Number.parseInt(end, 10)) : undefined;
const placeholder = selectedSeries ? (
<NotEnoughData description={emptyDataDescription} />
) : noSeriesPlaceholder ? (
noSeriesPlaceholder
) : (
<NotEnoughData
title='Select a metric series to view the chart.'
description=''
/>
);
const cover = notEnoughData ? placeholder : isLoading;
const chartOptions = shouldShowPlaceholder
? overrideOptions
: {
...overrideOptions,
scales: {
x: {
type: 'time',
min: minTime?.getTime(),
max: maxTime?.getTime(),
time: {
unit: getTimeUnit(selectedRange),
displayFormats: {
[getTimeUnit(selectedRange)]:
getDisplayFormat(selectedRange),
},
tooltipFormat: 'PPpp',
},
},
y: {
beginAtZero,
title: {
display: false,
},
ticks: {
precision: 0,
callback: (value: unknown): string | number =>
typeof value === 'number'
? formatLargeNumbers(value)
: (value as number),
},
},
},
plugins: {
legend: {
display: timeSeriesData && timeSeriesData.length > 1,
position: 'bottom' as const,
labels: {
usePointStyle: true,
boxWidth: 8,
padding: 12,
},
},
},
animations: {
x: { duration: 0 },
y: { duration: 0 },
},
};
return (
<>
{hasError ? <Alert severity='error'>{errorTitle}</Alert> : null}
<LineChart
data={notEnoughData || isLoading ? placeholderData : data}
aspectRatio={aspectRatio}
overrideOptions={chartOptions}
cover={cover}
/>
</>
);
};

View File

@ -0,0 +1,39 @@
import type { FC } from 'react';
import { Typography } from '@mui/material';
import { StyledChartContainer } from 'component/insights/InsightsCharts.styles';
import { ImpactMetricsChart } from './ImpactMetricsChart.tsx';
type ImpactMetricsChartPreviewProps = {
selectedSeries: string;
selectedRange: 'hour' | 'day' | 'week' | 'month';
selectedLabels: Record<string, string[]>;
beginAtZero: boolean;
};
export const ImpactMetricsChartPreview: FC<ImpactMetricsChartPreviewProps> = ({
selectedSeries,
selectedRange,
selectedLabels,
beginAtZero,
}) => (
<>
<Typography variant='h6' color='text.secondary'>
Preview
</Typography>
{!selectedSeries ? (
<Typography variant='body2' color='text.secondary'>
Select a metric series to view the preview
</Typography>
) : null}
<StyledChartContainer>
<ImpactMetricsChart
selectedSeries={selectedSeries}
selectedRange={selectedRange}
selectedLabels={selectedLabels}
beginAtZero={beginAtZero}
/>
</StyledChartContainer>
</>
);

View File

@ -1,203 +0,0 @@
import type { FC } from 'react';
import {
FormControl,
InputLabel,
Select,
MenuItem,
FormControlLabel,
Checkbox,
Box,
Autocomplete,
TextField,
Typography,
Chip,
} from '@mui/material';
import type { ImpactMetricsSeries } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata';
import type { ImpactMetricsLabels } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData';
import { Highlighter } from 'component/common/Highlighter/Highlighter';
export interface ImpactMetricsControlsProps {
selectedSeries: string;
onSeriesChange: (series: string) => void;
selectedRange: 'hour' | 'day' | 'week' | 'month';
onRangeChange: (range: 'hour' | 'day' | 'week' | 'month') => void;
beginAtZero: boolean;
onBeginAtZeroChange: (beginAtZero: boolean) => void;
metricSeries: (ImpactMetricsSeries & { name: string })[];
loading?: boolean;
selectedLabels: Record<string, string[]>;
onLabelsChange: (labels: Record<string, string[]>) => void;
availableLabels?: ImpactMetricsLabels;
}
export const ImpactMetricsControls: FC<ImpactMetricsControlsProps> = ({
selectedSeries,
onSeriesChange,
selectedRange,
onRangeChange,
beginAtZero,
onBeginAtZeroChange,
metricSeries,
loading = false,
selectedLabels,
onLabelsChange,
availableLabels,
}) => {
const handleLabelChange = (labelKey: string, values: string[]) => {
const newLabels = { ...selectedLabels };
if (values.length === 0) {
delete newLabels[labelKey];
} else {
newLabels[labelKey] = values;
}
onLabelsChange(newLabels);
};
const clearAllLabels = () => {
onLabelsChange({});
};
return (
<Box
sx={(theme) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(3),
maxWidth: 400,
})}
>
<Typography variant='body2' color='text.secondary'>
Select a custom metric to see its value over time. This can help
you understand the impact of your feature rollout on key
outcomes, such as system performance, usage patterns or error
rates.
</Typography>
<Autocomplete
options={metricSeries}
getOptionLabel={(option) => option.name}
value={
metricSeries.find(
(option) => option.name === selectedSeries,
) || null
}
onChange={(_, newValue) => onSeriesChange(newValue?.name || '')}
disabled={loading}
renderOption={(props, option, { inputValue }) => (
<Box component='li' {...props}>
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography variant='body2'>
<Highlighter search={inputValue}>
{option.name}
</Highlighter>
</Typography>
<Typography
variant='caption'
color='text.secondary'
>
<Highlighter search={inputValue}>
{option.help}
</Highlighter>
</Typography>
</Box>
</Box>
)}
renderInput={(params) => (
<TextField
{...params}
label='Data series'
placeholder='Search for a metric…'
variant='outlined'
size='small'
/>
)}
noOptionsText='No metrics available'
sx={{ minWidth: 300 }}
/>
<FormControl variant='outlined' size='small' sx={{ minWidth: 200 }}>
<InputLabel id='range-select-label'>Time</InputLabel>
<Select
labelId='range-select-label'
value={selectedRange}
onChange={(e) =>
onRangeChange(
e.target.value as 'hour' | 'day' | 'week' | 'month',
)
}
label='Time Range'
>
<MenuItem value='hour'>Last hour</MenuItem>
<MenuItem value='day'>Last 24 hours</MenuItem>
<MenuItem value='week'>Last 7 days</MenuItem>
<MenuItem value='month'>Last 30 days</MenuItem>
</Select>
</FormControl>
<FormControlLabel
control={
<Checkbox
checked={beginAtZero}
onChange={(e) => onBeginAtZeroChange(e.target.checked)}
/>
}
label='Begin at zero'
/>
{availableLabels && Object.keys(availableLabels).length > 0 ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant='subtitle2'>
Filter by labels
</Typography>
{Object.keys(selectedLabels).length > 0 && (
<Chip
label='Clear all'
size='small'
variant='outlined'
onClick={clearAllLabels}
/>
)}
</Box>
{Object.entries(availableLabels).map(
([labelKey, values]) => (
<Autocomplete
key={labelKey}
multiple
options={values}
value={selectedLabels[labelKey] || []}
onChange={(_, newValues) =>
handleLabelChange(labelKey, newValues)
}
renderTags={(value, getTagProps) =>
value.map((option, index) => {
const { key, ...chipProps } =
getTagProps({ index });
return (
<Chip
{...chipProps}
key={key}
label={option}
size='small'
/>
);
})
}
renderInput={(params) => (
<TextField
{...params}
label={labelKey}
placeholder='Select values...'
variant='outlined'
size='small'
/>
)}
sx={{ minWidth: 300 }}
/>
),
)}
</Box>
) : null}
</Box>
);
};

View File

@ -1,5 +1,7 @@
import { lazy } from 'react'; import { lazy } from 'react';
export const LazyImpactMetricsPage = lazy( export const LazyImpactMetricsPage = lazy(() =>
() => import('./LazyImpactMetricsPageExport.tsx'), import('./ImpactMetricsPage.tsx').then((module) => ({
default: module.ImpactMetricsPage,
})),
); );

View File

@ -1,3 +0,0 @@
import { ImpactMetricsPage } from './ImpactMetricsPage.tsx';
export default ImpactMetricsPage;

View File

@ -20,7 +20,7 @@ const createArrayParam = <T>() => ({
const ChartsParam = createArrayParam<ChartConfig>(); const ChartsParam = createArrayParam<ChartConfig>();
const LayoutParam = createArrayParam<LayoutItem>(); const LayoutParam = createArrayParam<LayoutItem>();
export const useUrlState = () => { export const useImpactMetricsState = () => {
const stateConfig = { const stateConfig = {
charts: withDefault(ChartsParam, []), charts: withDefault(ChartsParam, []),
layout: withDefault(LayoutParam, []), layout: withDefault(LayoutParam, []),

View File

@ -1,12 +1,12 @@
import { render } from 'utils/testRenderer'; import { render } from 'utils/testRenderer';
import { useUrlState } from './useUrlState.ts'; import { useImpactMetricsState } from './useImpactMetricsState.ts';
import { Route, Routes } from 'react-router-dom'; import { Route, Routes } from 'react-router-dom';
import { createLocalStorage } from '../../../utils/createLocalStorage.ts'; import { createLocalStorage } from '../../../utils/createLocalStorage.ts';
import type { FC } from 'react'; import type { FC } from 'react';
import type { ImpactMetricsState } from '../types.ts'; import type { ImpactMetricsState } from '../types.ts';
const TestComponent: FC = () => { const TestComponent: FC = () => {
const { charts, layout } = useUrlState(); const { charts, layout } = useImpactMetricsState();
return ( return (
<div> <div>