mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	refactor: impact metrics modal - label filtering and UX (#10377)
Modal for editing a chart now follows design of other parts of the app more closely.
This commit is contained in:
		
							parent
							
								
									51f8244a5d
								
							
						
					
					
						commit
						f54305c8b7
					
				@ -8,12 +8,16 @@ import {
 | 
				
			|||||||
    TextField,
 | 
					    TextField,
 | 
				
			||||||
    Box,
 | 
					    Box,
 | 
				
			||||||
    styled,
 | 
					    styled,
 | 
				
			||||||
 | 
					    useTheme,
 | 
				
			||||||
 | 
					    useMediaQuery,
 | 
				
			||||||
 | 
					    Divider,
 | 
				
			||||||
} from '@mui/material';
 | 
					} from '@mui/material';
 | 
				
			||||||
import { ImpactMetricsControls } from './ImpactMetricsControls/ImpactMetricsControls.tsx';
 | 
					import { ImpactMetricsControls } from './ImpactMetricsControls/ImpactMetricsControls.tsx';
 | 
				
			||||||
import { ImpactMetricsChartPreview } from './ImpactMetricsChartPreview.tsx';
 | 
					import { useChartFormState } from '../hooks/useChartFormState.ts';
 | 
				
			||||||
import { useChartFormState } from './hooks/useChartFormState.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';
 | 
				
			||||||
 | 
					import { LabelsFilter } from './LabelFilter/LabelsFilter.tsx';
 | 
				
			||||||
 | 
					import { ImpactMetricsChart } from '../ImpactMetricsChart.tsx';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const StyledConfigPanel = styled(Box)(({ theme }) => ({
 | 
					export const StyledConfigPanel = styled(Box)(({ theme }) => ({
 | 
				
			||||||
    display: 'flex',
 | 
					    display: 'flex',
 | 
				
			||||||
@ -62,6 +66,8 @@ export const ChartConfigModal: FC<ChartConfigModalProps> = ({
 | 
				
			|||||||
            open,
 | 
					            open,
 | 
				
			||||||
            initialConfig,
 | 
					            initialConfig,
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
 | 
					    const theme = useTheme();
 | 
				
			||||||
 | 
					    const screenBreakpoint = useMediaQuery(theme.breakpoints.down('lg'));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const handleSave = () => {
 | 
					    const handleSave = () => {
 | 
				
			||||||
        if (!isValid) return;
 | 
					        if (!isValid) return;
 | 
				
			||||||
@ -111,21 +117,33 @@ export const ChartConfigModal: FC<ChartConfigModalProps> = ({
 | 
				
			|||||||
                            actions={actions}
 | 
					                            actions={actions}
 | 
				
			||||||
                            metricSeries={metricSeries}
 | 
					                            metricSeries={metricSeries}
 | 
				
			||||||
                            loading={loading}
 | 
					                            loading={loading}
 | 
				
			||||||
                            availableLabels={currentAvailableLabels}
 | 
					 | 
				
			||||||
                        />
 | 
					                        />
 | 
				
			||||||
                    </StyledConfigPanel>
 | 
					                    </StyledConfigPanel>
 | 
				
			||||||
                    <StyledPreviewPanel>
 | 
					                    <StyledPreviewPanel>
 | 
				
			||||||
                        <ImpactMetricsChartPreview
 | 
					                        <Box sx={(theme) => ({ padding: theme.spacing(1) })}>
 | 
				
			||||||
                            selectedSeries={formData.selectedSeries}
 | 
					                            <ImpactMetricsChart
 | 
				
			||||||
                            selectedRange={formData.selectedRange}
 | 
					                                key={screenBreakpoint ? 'small' : 'large'}
 | 
				
			||||||
                            selectedLabels={formData.selectedLabels}
 | 
					                                selectedSeries={formData.selectedSeries}
 | 
				
			||||||
                            beginAtZero={formData.beginAtZero}
 | 
					                                selectedRange={formData.selectedRange}
 | 
				
			||||||
                            aggregationMode={formData.aggregationMode}
 | 
					                                selectedLabels={formData.selectedLabels}
 | 
				
			||||||
                        />
 | 
					                                beginAtZero={formData.beginAtZero}
 | 
				
			||||||
 | 
					                                aggregationMode={formData.aggregationMode}
 | 
				
			||||||
 | 
					                                isPreview
 | 
				
			||||||
 | 
					                            />
 | 
				
			||||||
 | 
					                        </Box>
 | 
				
			||||||
                    </StyledPreviewPanel>
 | 
					                    </StyledPreviewPanel>
 | 
				
			||||||
                </Box>
 | 
					                </Box>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                {currentAvailableLabels ? (
 | 
				
			||||||
 | 
					                    <LabelsFilter
 | 
				
			||||||
 | 
					                        selectedLabels={formData.selectedLabels}
 | 
				
			||||||
 | 
					                        onChange={actions.setSelectedLabels}
 | 
				
			||||||
 | 
					                        availableLabels={currentAvailableLabels}
 | 
				
			||||||
 | 
					                    />
 | 
				
			||||||
 | 
					                ) : null}
 | 
				
			||||||
            </DialogContent>
 | 
					            </DialogContent>
 | 
				
			||||||
            <DialogActions>
 | 
					            <Divider />
 | 
				
			||||||
 | 
					            <DialogActions sx={(theme) => ({ margin: theme.spacing(2, 3, 3) })}>
 | 
				
			||||||
                <Button onClick={onClose}>Cancel</Button>
 | 
					                <Button onClick={onClose}>Cancel</Button>
 | 
				
			||||||
                <Button
 | 
					                <Button
 | 
				
			||||||
                    onClick={handleSave}
 | 
					                    onClick={handleSave}
 | 
				
			||||||
@ -0,0 +1,81 @@
 | 
				
			|||||||
 | 
					import type { FC } from 'react';
 | 
				
			||||||
 | 
					import { Box, Typography, FormControlLabel, Checkbox } from '@mui/material';
 | 
				
			||||||
 | 
					import type { ImpactMetricsSeries } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata';
 | 
				
			||||||
 | 
					import { SeriesSelector } from './SeriesSelector/SeriesSelector.tsx';
 | 
				
			||||||
 | 
					import { RangeSelector } from './RangeSelector/RangeSelector.tsx';
 | 
				
			||||||
 | 
					import { ModeSelector } from './ModeSelector/ModeSelector.tsx';
 | 
				
			||||||
 | 
					import type { ChartFormState } from '../../hooks/useChartFormState.ts';
 | 
				
			||||||
 | 
					import { getMetricType } from '../../utils.ts';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export type ImpactMetricsControlsProps = {
 | 
				
			||||||
 | 
					    formData: ChartFormState['formData'];
 | 
				
			||||||
 | 
					    actions: Pick<
 | 
				
			||||||
 | 
					        ChartFormState['actions'],
 | 
				
			||||||
 | 
					        | 'handleSeriesChange'
 | 
				
			||||||
 | 
					        | 'setSelectedRange'
 | 
				
			||||||
 | 
					        | 'setBeginAtZero'
 | 
				
			||||||
 | 
					        | 'setSelectedLabels'
 | 
				
			||||||
 | 
					        | 'setAggregationMode'
 | 
				
			||||||
 | 
					    >;
 | 
				
			||||||
 | 
					    metricSeries: (ImpactMetricsSeries & { name: string })[];
 | 
				
			||||||
 | 
					    loading?: boolean;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const ImpactMetricsControls: FC<ImpactMetricsControlsProps> = ({
 | 
				
			||||||
 | 
					    formData,
 | 
				
			||||||
 | 
					    actions,
 | 
				
			||||||
 | 
					    metricSeries,
 | 
				
			||||||
 | 
					    loading,
 | 
				
			||||||
 | 
					}) => (
 | 
				
			||||||
 | 
					    <Box>
 | 
				
			||||||
 | 
					        <Box
 | 
				
			||||||
 | 
					            sx={(theme) => ({
 | 
				
			||||||
 | 
					                display: 'flex',
 | 
				
			||||||
 | 
					                flexDirection: 'column',
 | 
				
			||||||
 | 
					                gap: theme.spacing(3),
 | 
				
			||||||
 | 
					            })}
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					            <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>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            <SeriesSelector
 | 
				
			||||||
 | 
					                value={formData.selectedSeries}
 | 
				
			||||||
 | 
					                onChange={actions.handleSeriesChange}
 | 
				
			||||||
 | 
					                options={metricSeries}
 | 
				
			||||||
 | 
					                loading={loading}
 | 
				
			||||||
 | 
					            />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            {formData.selectedSeries ? (
 | 
				
			||||||
 | 
					                <>
 | 
				
			||||||
 | 
					                    <RangeSelector
 | 
				
			||||||
 | 
					                        value={formData.selectedRange}
 | 
				
			||||||
 | 
					                        onChange={actions.setSelectedRange}
 | 
				
			||||||
 | 
					                    />
 | 
				
			||||||
 | 
					                    <ModeSelector
 | 
				
			||||||
 | 
					                        value={formData.aggregationMode}
 | 
				
			||||||
 | 
					                        onChange={actions.setAggregationMode}
 | 
				
			||||||
 | 
					                        seriesType={getMetricType(formData.selectedSeries)!}
 | 
				
			||||||
 | 
					                    />
 | 
				
			||||||
 | 
					                </>
 | 
				
			||||||
 | 
					            ) : null}
 | 
				
			||||||
 | 
					        </Box>
 | 
				
			||||||
 | 
					        {formData.selectedSeries ? (
 | 
				
			||||||
 | 
					            <FormControlLabel
 | 
				
			||||||
 | 
					                sx={(theme) => ({ margin: theme.spacing(1.5, 0) })}
 | 
				
			||||||
 | 
					                control={
 | 
				
			||||||
 | 
					                    <Checkbox
 | 
				
			||||||
 | 
					                        checked={formData.beginAtZero}
 | 
				
			||||||
 | 
					                        onChange={(e) =>
 | 
				
			||||||
 | 
					                            actions.setBeginAtZero(e.target.checked)
 | 
				
			||||||
 | 
					                        }
 | 
				
			||||||
 | 
					                    />
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					                label='Begin at zero'
 | 
				
			||||||
 | 
					            />
 | 
				
			||||||
 | 
					        ) : null}
 | 
				
			||||||
 | 
					    </Box>
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
@ -1,6 +1,6 @@
 | 
				
			|||||||
import type { FC } from 'react';
 | 
					import type { FC } from 'react';
 | 
				
			||||||
import { FormControl, InputLabel, Select, MenuItem } from '@mui/material';
 | 
					import { FormControl, InputLabel, Select, MenuItem } from '@mui/material';
 | 
				
			||||||
import type { AggregationMode } from '../../types.ts';
 | 
					import type { AggregationMode } from '../../../types.ts';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export type ModeSelectorProps = {
 | 
					export type ModeSelectorProps = {
 | 
				
			||||||
    value: AggregationMode;
 | 
					    value: AggregationMode;
 | 
				
			||||||
@ -15,11 +15,7 @@ export const ModeSelector: FC<ModeSelectorProps> = ({
 | 
				
			|||||||
}) => {
 | 
					}) => {
 | 
				
			||||||
    if (seriesType === 'unknown') return null;
 | 
					    if (seriesType === 'unknown') return null;
 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
        <FormControl
 | 
					        <FormControl variant='outlined' size='small' sx={{ minWidth: 200 }}>
 | 
				
			||||||
            variant='outlined'
 | 
					 | 
				
			||||||
            size='small'
 | 
					 | 
				
			||||||
            sx={{ minWidth: 200, mt: 1 }}
 | 
					 | 
				
			||||||
        >
 | 
					 | 
				
			||||||
            <InputLabel id='mode-select-label'>Mode</InputLabel>
 | 
					            <InputLabel id='mode-select-label'>Mode</InputLabel>
 | 
				
			||||||
            <Select
 | 
					            <Select
 | 
				
			||||||
                labelId='mode-select-label'
 | 
					                labelId='mode-select-label'
 | 
				
			||||||
@ -47,6 +47,7 @@ export const SeriesSelector: FC<SeriesSelectorProps> = ({
 | 
				
			|||||||
                placeholder='Search for a metric…'
 | 
					                placeholder='Search for a metric…'
 | 
				
			||||||
                variant='outlined'
 | 
					                variant='outlined'
 | 
				
			||||||
                size='small'
 | 
					                size='small'
 | 
				
			||||||
 | 
					                required
 | 
				
			||||||
            />
 | 
					            />
 | 
				
			||||||
        )}
 | 
					        )}
 | 
				
			||||||
        noOptionsText='No metrics available'
 | 
					        noOptionsText='No metrics available'
 | 
				
			||||||
@ -0,0 +1,104 @@
 | 
				
			|||||||
 | 
					import {
 | 
				
			||||||
 | 
					    Autocomplete,
 | 
				
			||||||
 | 
					    Box,
 | 
				
			||||||
 | 
					    Checkbox,
 | 
				
			||||||
 | 
					    Chip,
 | 
				
			||||||
 | 
					    FormControlLabel,
 | 
				
			||||||
 | 
					    TextField,
 | 
				
			||||||
 | 
					} from '@mui/material';
 | 
				
			||||||
 | 
					import type { FC } from 'react';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type LabelFilterItemProps = {
 | 
				
			||||||
 | 
					    labelKey: string;
 | 
				
			||||||
 | 
					    options: string[];
 | 
				
			||||||
 | 
					    value: string[];
 | 
				
			||||||
 | 
					    onChange: (values: string[]) => void;
 | 
				
			||||||
 | 
					    handleAllToggle: (labelKey: string, checked: boolean) => void;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const LabelFilterItem: FC<LabelFilterItemProps> = ({
 | 
				
			||||||
 | 
					    labelKey,
 | 
				
			||||||
 | 
					    options,
 | 
				
			||||||
 | 
					    value,
 | 
				
			||||||
 | 
					    onChange,
 | 
				
			||||||
 | 
					}) => {
 | 
				
			||||||
 | 
					    const isAllSelected = value.includes('*');
 | 
				
			||||||
 | 
					    const autocompleteId = `autocomplete-${labelKey}`;
 | 
				
			||||||
 | 
					    const selectAllId = `select-all-${labelKey}`;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return (
 | 
				
			||||||
 | 
					        <>
 | 
				
			||||||
 | 
					            <FormControlLabel
 | 
				
			||||||
 | 
					                sx={(theme) => ({
 | 
				
			||||||
 | 
					                    marginLeft: theme.spacing(0),
 | 
				
			||||||
 | 
					                })}
 | 
				
			||||||
 | 
					                control={
 | 
				
			||||||
 | 
					                    <Checkbox
 | 
				
			||||||
 | 
					                        id={selectAllId}
 | 
				
			||||||
 | 
					                        size='small'
 | 
				
			||||||
 | 
					                        checked={isAllSelected}
 | 
				
			||||||
 | 
					                        onChange={(e) =>
 | 
				
			||||||
 | 
					                            onChange(e.target.checked ? ['*'] : [])
 | 
				
			||||||
 | 
					                        }
 | 
				
			||||||
 | 
					                        inputProps={{
 | 
				
			||||||
 | 
					                            'aria-describedby': autocompleteId,
 | 
				
			||||||
 | 
					                            'aria-label': `Select all ${labelKey} options`,
 | 
				
			||||||
 | 
					                        }}
 | 
				
			||||||
 | 
					                    />
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					                label={
 | 
				
			||||||
 | 
					                    <Box
 | 
				
			||||||
 | 
					                        component='span'
 | 
				
			||||||
 | 
					                        sx={(theme) => ({
 | 
				
			||||||
 | 
					                            fontSize: theme.fontSizes.smallBody,
 | 
				
			||||||
 | 
					                        })}
 | 
				
			||||||
 | 
					                    >
 | 
				
			||||||
 | 
					                        Select all
 | 
				
			||||||
 | 
					                    </Box>
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            />
 | 
				
			||||||
 | 
					            <Autocomplete
 | 
				
			||||||
 | 
					                multiple
 | 
				
			||||||
 | 
					                id={autocompleteId}
 | 
				
			||||||
 | 
					                options={options}
 | 
				
			||||||
 | 
					                value={isAllSelected ? options : value}
 | 
				
			||||||
 | 
					                onChange={(_, newValues) => {
 | 
				
			||||||
 | 
					                    onChange(newValues);
 | 
				
			||||||
 | 
					                }}
 | 
				
			||||||
 | 
					                disabled={isAllSelected}
 | 
				
			||||||
 | 
					                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={
 | 
				
			||||||
 | 
					                            isAllSelected ? undefined : 'Select values…'
 | 
				
			||||||
 | 
					                        }
 | 
				
			||||||
 | 
					                        variant='outlined'
 | 
				
			||||||
 | 
					                        size='small'
 | 
				
			||||||
 | 
					                        inputProps={{
 | 
				
			||||||
 | 
					                            ...params.inputProps,
 | 
				
			||||||
 | 
					                            'aria-describedby': isAllSelected
 | 
				
			||||||
 | 
					                                ? `${selectAllId}-description`
 | 
				
			||||||
 | 
					                                : undefined,
 | 
				
			||||||
 | 
					                        }}
 | 
				
			||||||
 | 
					                    />
 | 
				
			||||||
 | 
					                )}
 | 
				
			||||||
 | 
					            />
 | 
				
			||||||
 | 
					        </>
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
@ -0,0 +1,102 @@
 | 
				
			|||||||
 | 
					import type { FC } from 'react';
 | 
				
			||||||
 | 
					import { Box, Typography, Chip } from '@mui/material';
 | 
				
			||||||
 | 
					import type { ImpactMetricsLabels } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData';
 | 
				
			||||||
 | 
					import { LabelFilterItem } from './LabelFilterItem/LabelFilterItem.tsx';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export type LabelsFilterProps = {
 | 
				
			||||||
 | 
					    selectedLabels: Record<string, string[]>;
 | 
				
			||||||
 | 
					    onChange: (labels: Record<string, string[]>) => void;
 | 
				
			||||||
 | 
					    availableLabels: ImpactMetricsLabels;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const LabelsFilter: FC<LabelsFilterProps> = ({
 | 
				
			||||||
 | 
					    selectedLabels,
 | 
				
			||||||
 | 
					    onChange,
 | 
				
			||||||
 | 
					    availableLabels,
 | 
				
			||||||
 | 
					}) => {
 | 
				
			||||||
 | 
					    const handleLabelChange = (labelKey: string, values: string[]) => {
 | 
				
			||||||
 | 
					        const newLabels = { ...selectedLabels };
 | 
				
			||||||
 | 
					        if (values.length === 0) {
 | 
				
			||||||
 | 
					            delete newLabels[labelKey];
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					            newLabels[labelKey] = values;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        onChange(newLabels);
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const handleAllToggle = (labelKey: string, checked: boolean) => {
 | 
				
			||||||
 | 
					        const newLabels = { ...selectedLabels };
 | 
				
			||||||
 | 
					        if (checked) {
 | 
				
			||||||
 | 
					            newLabels[labelKey] = ['*'];
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					            delete newLabels[labelKey];
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        onChange(newLabels);
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const clearAllLabels = () => {
 | 
				
			||||||
 | 
					        onChange({});
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!availableLabels || Object.keys(availableLabels).length === 0) {
 | 
				
			||||||
 | 
					        return null;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return (
 | 
				
			||||||
 | 
					        <Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
 | 
				
			||||||
 | 
					            <Box
 | 
				
			||||||
 | 
					                sx={{
 | 
				
			||||||
 | 
					                    display: 'flex',
 | 
				
			||||||
 | 
					                    alignItems: 'center',
 | 
				
			||||||
 | 
					                    gap: 1,
 | 
				
			||||||
 | 
					                    width: '100%',
 | 
				
			||||||
 | 
					                }}
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					                <Typography variant='subtitle2'>Filter by labels</Typography>
 | 
				
			||||||
 | 
					                {Object.keys(selectedLabels).length > 0 && (
 | 
				
			||||||
 | 
					                    <Chip
 | 
				
			||||||
 | 
					                        label='Clear all'
 | 
				
			||||||
 | 
					                        size='small'
 | 
				
			||||||
 | 
					                        variant='outlined'
 | 
				
			||||||
 | 
					                        onClick={clearAllLabels}
 | 
				
			||||||
 | 
					                    />
 | 
				
			||||||
 | 
					                )}
 | 
				
			||||||
 | 
					            </Box>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            <Box
 | 
				
			||||||
 | 
					                sx={{
 | 
				
			||||||
 | 
					                    display: 'grid',
 | 
				
			||||||
 | 
					                    gridTemplateColumns:
 | 
				
			||||||
 | 
					                        'repeat(auto-fill, minmax(300px, 1fr))',
 | 
				
			||||||
 | 
					                    gap: 2,
 | 
				
			||||||
 | 
					                    flexGrow: 1,
 | 
				
			||||||
 | 
					                }}
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					                {Object.entries(availableLabels).map(([labelKey, values]) => {
 | 
				
			||||||
 | 
					                    const currentSelection = selectedLabels[labelKey] || [];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    return (
 | 
				
			||||||
 | 
					                        <Box
 | 
				
			||||||
 | 
					                            key={labelKey}
 | 
				
			||||||
 | 
					                            sx={{
 | 
				
			||||||
 | 
					                                display: 'flex',
 | 
				
			||||||
 | 
					                                flexDirection: 'column',
 | 
				
			||||||
 | 
					                                flexGrow: 1,
 | 
				
			||||||
 | 
					                            }}
 | 
				
			||||||
 | 
					                        >
 | 
				
			||||||
 | 
					                            <LabelFilterItem
 | 
				
			||||||
 | 
					                                labelKey={labelKey}
 | 
				
			||||||
 | 
					                                options={values}
 | 
				
			||||||
 | 
					                                value={currentSelection}
 | 
				
			||||||
 | 
					                                onChange={(newValues) =>
 | 
				
			||||||
 | 
					                                    handleLabelChange(labelKey, newValues)
 | 
				
			||||||
 | 
					                                }
 | 
				
			||||||
 | 
					                                handleAllToggle={handleAllToggle}
 | 
				
			||||||
 | 
					                            />
 | 
				
			||||||
 | 
					                        </Box>
 | 
				
			||||||
 | 
					                    );
 | 
				
			||||||
 | 
					                })}
 | 
				
			||||||
 | 
					            </Box>
 | 
				
			||||||
 | 
					        </Box>
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
@ -4,7 +4,7 @@ import { Typography, Button, Paper, styled } from '@mui/material';
 | 
				
			|||||||
import Add from '@mui/icons-material/Add';
 | 
					import Add from '@mui/icons-material/Add';
 | 
				
			||||||
import { PageHeader } from 'component/common/PageHeader/PageHeader.tsx';
 | 
					import { PageHeader } from 'component/common/PageHeader/PageHeader.tsx';
 | 
				
			||||||
import { useImpactMetricsMetadata } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata';
 | 
					import { useImpactMetricsMetadata } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata';
 | 
				
			||||||
import { ChartConfigModal } from './ChartConfigModal.tsx';
 | 
					import { ChartConfigModal } from './ChartConfigModal/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 { useImpactMetricsState } from './hooks/useImpactMetricsState.ts';
 | 
					import { useImpactMetricsState } from './hooks/useImpactMetricsState.ts';
 | 
				
			||||||
 | 
				
			|||||||
@ -1,50 +0,0 @@
 | 
				
			|||||||
import type { FC } from 'react';
 | 
					 | 
				
			||||||
import { Box, Typography, useMediaQuery, useTheme } from '@mui/material';
 | 
					 | 
				
			||||||
import { ImpactMetricsChart } from './ImpactMetricsChart.tsx';
 | 
					 | 
				
			||||||
import type { AggregationMode } from './types.ts';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
type ImpactMetricsChartPreviewProps = {
 | 
					 | 
				
			||||||
    selectedSeries: string;
 | 
					 | 
				
			||||||
    selectedRange: 'hour' | 'day' | 'week' | 'month';
 | 
					 | 
				
			||||||
    selectedLabels: Record<string, string[]>;
 | 
					 | 
				
			||||||
    beginAtZero: boolean;
 | 
					 | 
				
			||||||
    aggregationMode?: AggregationMode;
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export const ImpactMetricsChartPreview: FC<ImpactMetricsChartPreviewProps> = ({
 | 
					 | 
				
			||||||
    selectedSeries,
 | 
					 | 
				
			||||||
    selectedRange,
 | 
					 | 
				
			||||||
    selectedLabels,
 | 
					 | 
				
			||||||
    beginAtZero,
 | 
					 | 
				
			||||||
    aggregationMode,
 | 
					 | 
				
			||||||
}) => {
 | 
					 | 
				
			||||||
    const theme = useTheme();
 | 
					 | 
				
			||||||
    const screenBreakpoint = useMediaQuery(theme.breakpoints.down('lg'));
 | 
					 | 
				
			||||||
    const key = screenBreakpoint ? 'small' : 'large';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return (
 | 
					 | 
				
			||||||
        <>
 | 
					 | 
				
			||||||
            <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}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            <Box sx={(theme) => ({ padding: theme.spacing(1) })}>
 | 
					 | 
				
			||||||
                <ImpactMetricsChart
 | 
					 | 
				
			||||||
                    key={key}
 | 
					 | 
				
			||||||
                    selectedSeries={selectedSeries}
 | 
					 | 
				
			||||||
                    selectedRange={selectedRange}
 | 
					 | 
				
			||||||
                    selectedLabels={selectedLabels}
 | 
					 | 
				
			||||||
                    beginAtZero={beginAtZero}
 | 
					 | 
				
			||||||
                    aggregationMode={aggregationMode}
 | 
					 | 
				
			||||||
                    isPreview
 | 
					 | 
				
			||||||
                />
 | 
					 | 
				
			||||||
            </Box>
 | 
					 | 
				
			||||||
        </>
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
@ -1,88 +0,0 @@
 | 
				
			|||||||
import type { FC } from 'react';
 | 
					 | 
				
			||||||
import { Box, Typography, FormControlLabel, Checkbox } from '@mui/material';
 | 
					 | 
				
			||||||
import type { ImpactMetricsSeries } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata';
 | 
					 | 
				
			||||||
import type { ImpactMetricsLabels } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData';
 | 
					 | 
				
			||||||
import { SeriesSelector } from './components/SeriesSelector.tsx';
 | 
					 | 
				
			||||||
import { RangeSelector } from './components/RangeSelector.tsx';
 | 
					 | 
				
			||||||
import { LabelsFilter } from './components/LabelsFilter.tsx';
 | 
					 | 
				
			||||||
import { ModeSelector } from './components/ModeSelector.tsx';
 | 
					 | 
				
			||||||
import type { ChartFormState } from '../hooks/useChartFormState.ts';
 | 
					 | 
				
			||||||
import { getMetricType } from '../utils.ts';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export type ImpactMetricsControlsProps = {
 | 
					 | 
				
			||||||
    formData: ChartFormState['formData'];
 | 
					 | 
				
			||||||
    actions: Pick<
 | 
					 | 
				
			||||||
        ChartFormState['actions'],
 | 
					 | 
				
			||||||
        | 'handleSeriesChange'
 | 
					 | 
				
			||||||
        | 'setSelectedRange'
 | 
					 | 
				
			||||||
        | 'setBeginAtZero'
 | 
					 | 
				
			||||||
        | 'setSelectedLabels'
 | 
					 | 
				
			||||||
        | 'setAggregationMode'
 | 
					 | 
				
			||||||
    >;
 | 
					 | 
				
			||||||
    metricSeries: (ImpactMetricsSeries & { name: string })[];
 | 
					 | 
				
			||||||
    loading?: boolean;
 | 
					 | 
				
			||||||
    availableLabels?: ImpactMetricsLabels;
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export const ImpactMetricsControls: FC<ImpactMetricsControlsProps> = ({
 | 
					 | 
				
			||||||
    formData,
 | 
					 | 
				
			||||||
    actions,
 | 
					 | 
				
			||||||
    metricSeries,
 | 
					 | 
				
			||||||
    loading,
 | 
					 | 
				
			||||||
    availableLabels,
 | 
					 | 
				
			||||||
}) => (
 | 
					 | 
				
			||||||
    <Box
 | 
					 | 
				
			||||||
        sx={(theme) => ({
 | 
					 | 
				
			||||||
            display: 'flex',
 | 
					 | 
				
			||||||
            flexDirection: 'column',
 | 
					 | 
				
			||||||
            gap: theme.spacing(3),
 | 
					 | 
				
			||||||
        })}
 | 
					 | 
				
			||||||
    >
 | 
					 | 
				
			||||||
        <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>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        <SeriesSelector
 | 
					 | 
				
			||||||
            value={formData.selectedSeries}
 | 
					 | 
				
			||||||
            onChange={actions.handleSeriesChange}
 | 
					 | 
				
			||||||
            options={metricSeries}
 | 
					 | 
				
			||||||
            loading={loading}
 | 
					 | 
				
			||||||
        />
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        <RangeSelector
 | 
					 | 
				
			||||||
            value={formData.selectedRange}
 | 
					 | 
				
			||||||
            onChange={actions.setSelectedRange}
 | 
					 | 
				
			||||||
        />
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        <Box sx={{ display: 'flex', flexDirection: 'column' }}>
 | 
					 | 
				
			||||||
            <FormControlLabel
 | 
					 | 
				
			||||||
                control={
 | 
					 | 
				
			||||||
                    <Checkbox
 | 
					 | 
				
			||||||
                        checked={formData.beginAtZero}
 | 
					 | 
				
			||||||
                        onChange={(e) =>
 | 
					 | 
				
			||||||
                            actions.setBeginAtZero(e.target.checked)
 | 
					 | 
				
			||||||
                        }
 | 
					 | 
				
			||||||
                    />
 | 
					 | 
				
			||||||
                }
 | 
					 | 
				
			||||||
                label='Begin at zero'
 | 
					 | 
				
			||||||
            />
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            {formData.selectedSeries ? (
 | 
					 | 
				
			||||||
                <ModeSelector
 | 
					 | 
				
			||||||
                    value={formData.aggregationMode}
 | 
					 | 
				
			||||||
                    onChange={actions.setAggregationMode}
 | 
					 | 
				
			||||||
                    seriesType={getMetricType(formData.selectedSeries)!}
 | 
					 | 
				
			||||||
                />
 | 
					 | 
				
			||||||
            ) : null}
 | 
					 | 
				
			||||||
        </Box>
 | 
					 | 
				
			||||||
        {availableLabels && (
 | 
					 | 
				
			||||||
            <LabelsFilter
 | 
					 | 
				
			||||||
                selectedLabels={formData.selectedLabels}
 | 
					 | 
				
			||||||
                onChange={actions.setSelectedLabels}
 | 
					 | 
				
			||||||
                availableLabels={availableLabels}
 | 
					 | 
				
			||||||
            />
 | 
					 | 
				
			||||||
        )}
 | 
					 | 
				
			||||||
    </Box>
 | 
					 | 
				
			||||||
);
 | 
					 | 
				
			||||||
@ -1,136 +0,0 @@
 | 
				
			|||||||
import type { FC } from 'react';
 | 
					 | 
				
			||||||
import {
 | 
					 | 
				
			||||||
    Box,
 | 
					 | 
				
			||||||
    Autocomplete,
 | 
					 | 
				
			||||||
    TextField,
 | 
					 | 
				
			||||||
    Typography,
 | 
					 | 
				
			||||||
    Chip,
 | 
					 | 
				
			||||||
    Checkbox,
 | 
					 | 
				
			||||||
    FormControlLabel,
 | 
					 | 
				
			||||||
} from '@mui/material';
 | 
					 | 
				
			||||||
import type { ImpactMetricsLabels } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export type LabelsFilterProps = {
 | 
					 | 
				
			||||||
    selectedLabels: Record<string, string[]>;
 | 
					 | 
				
			||||||
    onChange: (labels: Record<string, string[]>) => void;
 | 
					 | 
				
			||||||
    availableLabels: ImpactMetricsLabels;
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export const LabelsFilter: FC<LabelsFilterProps> = ({
 | 
					 | 
				
			||||||
    selectedLabels,
 | 
					 | 
				
			||||||
    onChange,
 | 
					 | 
				
			||||||
    availableLabels,
 | 
					 | 
				
			||||||
}) => {
 | 
					 | 
				
			||||||
    const handleLabelChange = (labelKey: string, values: string[]) => {
 | 
					 | 
				
			||||||
        const newLabels = { ...selectedLabels };
 | 
					 | 
				
			||||||
        if (values.length === 0) {
 | 
					 | 
				
			||||||
            delete newLabels[labelKey];
 | 
					 | 
				
			||||||
        } else {
 | 
					 | 
				
			||||||
            newLabels[labelKey] = values;
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        onChange(newLabels);
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const handleAllToggle = (labelKey: string, checked: boolean) => {
 | 
					 | 
				
			||||||
        const newLabels = { ...selectedLabels };
 | 
					 | 
				
			||||||
        if (checked) {
 | 
					 | 
				
			||||||
            newLabels[labelKey] = ['*'];
 | 
					 | 
				
			||||||
        } else {
 | 
					 | 
				
			||||||
            delete newLabels[labelKey];
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        onChange(newLabels);
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const clearAllLabels = () => {
 | 
					 | 
				
			||||||
        onChange({});
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if (!availableLabels || Object.keys(availableLabels).length === 0) {
 | 
					 | 
				
			||||||
        return null;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return (
 | 
					 | 
				
			||||||
        <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]) => {
 | 
					 | 
				
			||||||
                const currentSelection = selectedLabels[labelKey] || [];
 | 
					 | 
				
			||||||
                const isAllSelected = currentSelection.includes('*');
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                return (
 | 
					 | 
				
			||||||
                    <Box
 | 
					 | 
				
			||||||
                        key={labelKey}
 | 
					 | 
				
			||||||
                        sx={(theme) => ({
 | 
					 | 
				
			||||||
                            display: 'flex',
 | 
					 | 
				
			||||||
                            alignItems: 'center',
 | 
					 | 
				
			||||||
                            gap: theme.spacing(3),
 | 
					 | 
				
			||||||
                        })}
 | 
					 | 
				
			||||||
                    >
 | 
					 | 
				
			||||||
                        <Autocomplete
 | 
					 | 
				
			||||||
                            multiple
 | 
					 | 
				
			||||||
                            options={values}
 | 
					 | 
				
			||||||
                            value={isAllSelected ? [] : currentSelection}
 | 
					 | 
				
			||||||
                            onChange={(_, newValues) => {
 | 
					 | 
				
			||||||
                                handleLabelChange(labelKey, newValues);
 | 
					 | 
				
			||||||
                            }}
 | 
					 | 
				
			||||||
                            disabled={isAllSelected}
 | 
					 | 
				
			||||||
                            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={
 | 
					 | 
				
			||||||
                                        isAllSelected
 | 
					 | 
				
			||||||
                                            ? 'All values selected'
 | 
					 | 
				
			||||||
                                            : 'Select values…'
 | 
					 | 
				
			||||||
                                    }
 | 
					 | 
				
			||||||
                                    variant='outlined'
 | 
					 | 
				
			||||||
                                    size='small'
 | 
					 | 
				
			||||||
                                />
 | 
					 | 
				
			||||||
                            )}
 | 
					 | 
				
			||||||
                            sx={{ minWidth: 300, flexGrow: 1 }}
 | 
					 | 
				
			||||||
                        />
 | 
					 | 
				
			||||||
                        <FormControlLabel
 | 
					 | 
				
			||||||
                            control={
 | 
					 | 
				
			||||||
                                <Checkbox
 | 
					 | 
				
			||||||
                                    checked={isAllSelected}
 | 
					 | 
				
			||||||
                                    onChange={(e) =>
 | 
					 | 
				
			||||||
                                        handleAllToggle(
 | 
					 | 
				
			||||||
                                            labelKey,
 | 
					 | 
				
			||||||
                                            e.target.checked,
 | 
					 | 
				
			||||||
                                        )
 | 
					 | 
				
			||||||
                                    }
 | 
					 | 
				
			||||||
                                />
 | 
					 | 
				
			||||||
                            }
 | 
					 | 
				
			||||||
                            label='All'
 | 
					 | 
				
			||||||
                        />
 | 
					 | 
				
			||||||
                    </Box>
 | 
					 | 
				
			||||||
                );
 | 
					 | 
				
			||||||
            })}
 | 
					 | 
				
			||||||
        </Box>
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
		Loading…
	
		Reference in New Issue
	
	Block a user