mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	Feat: impact metrics grid layout (#10253)
This commit is contained in:
		
							parent
							
								
									f7fcd1c4df
								
							
						
					
					
						commit
						082a6fdb16
					
				@ -61,6 +61,7 @@
 | 
			
		||||
    "@types/node": "^22.0.0",
 | 
			
		||||
    "@types/react": "18.3.23",
 | 
			
		||||
    "@types/react-dom": "18.3.7",
 | 
			
		||||
    "@types/react-grid-layout": "^1.3.5",
 | 
			
		||||
    "@types/react-router-dom": "5.3.3",
 | 
			
		||||
    "@types/react-table": "7.7.20",
 | 
			
		||||
    "@types/react-test-renderer": "18.3.1",
 | 
			
		||||
@ -107,6 +108,7 @@
 | 
			
		||||
    "react-dropzone": "14.3.8",
 | 
			
		||||
    "react-error-boundary": "3.1.4",
 | 
			
		||||
    "react-github-calendar": "^4.5.1",
 | 
			
		||||
    "react-grid-layout": "^1.5.2",
 | 
			
		||||
    "react-hooks-global-state": "2.1.0",
 | 
			
		||||
    "react-joyride": "^2.5.3",
 | 
			
		||||
    "react-markdown": "^8.0.4",
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,4 @@
 | 
			
		||||
import type { FC } from 'react';
 | 
			
		||||
import { useState, useEffect, useMemo } from 'react';
 | 
			
		||||
import {
 | 
			
		||||
    Dialog,
 | 
			
		||||
    DialogTitle,
 | 
			
		||||
@ -8,21 +7,11 @@ import {
 | 
			
		||||
    Button,
 | 
			
		||||
    TextField,
 | 
			
		||||
    Box,
 | 
			
		||||
    Typography,
 | 
			
		||||
    Alert,
 | 
			
		||||
    styled,
 | 
			
		||||
} from '@mui/material';
 | 
			
		||||
import { ImpactMetricsControls } from './ImpactMetricsControls/ImpactMetricsControls.tsx';
 | 
			
		||||
import {
 | 
			
		||||
    LineChart,
 | 
			
		||||
    NotEnoughData,
 | 
			
		||||
} from '../insights/components/LineChart/LineChart.tsx';
 | 
			
		||||
import { StyledChartContainer } from 'component/insights/InsightsCharts.styles';
 | 
			
		||||
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 { ImpactMetricsChartPreview } from './ImpactMetricsChartPreview.tsx';
 | 
			
		||||
import { useChartFormState } from './hooks/useChartFormState.ts';
 | 
			
		||||
import type { ChartConfig } from './types.ts';
 | 
			
		||||
import type { ImpactMetricsSeries } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata';
 | 
			
		||||
 | 
			
		||||
@ -68,120 +57,19 @@ export const ChartConfigModal: FC<ChartConfigModalProps> = ({
 | 
			
		||||
    metricSeries,
 | 
			
		||||
    loading = false,
 | 
			
		||||
}) => {
 | 
			
		||||
    const [title, setTitle] = useState(initialConfig?.title || '');
 | 
			
		||||
    const [selectedSeries, setSelectedSeries] = useState(
 | 
			
		||||
        initialConfig?.selectedSeries || '',
 | 
			
		||||
    );
 | 
			
		||||
    const [selectedRange, setSelectedRange] = useState<
 | 
			
		||||
        'hour' | 'day' | 'week' | 'month'
 | 
			
		||||
    >(initialConfig?.selectedRange || 'day');
 | 
			
		||||
    const [beginAtZero, setBeginAtZero] = useState(
 | 
			
		||||
        initialConfig?.beginAtZero || false,
 | 
			
		||||
    );
 | 
			
		||||
    const [selectedLabels, setSelectedLabels] = useState<
 | 
			
		||||
        Record<string, string[]>
 | 
			
		||||
    >(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 {
 | 
			
		||||
        data: { labels: currentAvailableLabels },
 | 
			
		||||
    } = useImpactMetricsData(
 | 
			
		||||
        selectedSeries
 | 
			
		||||
            ? {
 | 
			
		||||
                  series: selectedSeries,
 | 
			
		||||
                  range: selectedRange,
 | 
			
		||||
              }
 | 
			
		||||
            : undefined,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const placeholderData = usePlaceholderData({
 | 
			
		||||
        fill: true,
 | 
			
		||||
        type: 'constant',
 | 
			
		||||
    const { formData, actions, isValid, currentAvailableLabels } =
 | 
			
		||||
        useChartFormState({
 | 
			
		||||
            open,
 | 
			
		||||
            initialConfig,
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
    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(() => {
 | 
			
		||||
        if (open && initialConfig) {
 | 
			
		||||
            setTitle(initialConfig.title || '');
 | 
			
		||||
            setSelectedSeries(initialConfig.selectedSeries);
 | 
			
		||||
            setSelectedRange(initialConfig.selectedRange);
 | 
			
		||||
            setBeginAtZero(initialConfig.beginAtZero);
 | 
			
		||||
            setSelectedLabels(initialConfig.selectedLabels);
 | 
			
		||||
        } else if (open && !initialConfig) {
 | 
			
		||||
            setTitle('');
 | 
			
		||||
            setSelectedSeries('');
 | 
			
		||||
            setSelectedRange('day');
 | 
			
		||||
            setBeginAtZero(false);
 | 
			
		||||
            setSelectedLabels({});
 | 
			
		||||
        }
 | 
			
		||||
    }, [open, initialConfig]);
 | 
			
		||||
 | 
			
		||||
    const handleSave = () => {
 | 
			
		||||
        if (!selectedSeries) return;
 | 
			
		||||
        if (!isValid) return;
 | 
			
		||||
 | 
			
		||||
        onSave({
 | 
			
		||||
            title: title || undefined,
 | 
			
		||||
            selectedSeries,
 | 
			
		||||
            selectedRange,
 | 
			
		||||
            beginAtZero,
 | 
			
		||||
            selectedLabels,
 | 
			
		||||
        });
 | 
			
		||||
        onSave(actions.getConfigToSave());
 | 
			
		||||
        onClose();
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleSeriesChange = (series: string) => {
 | 
			
		||||
        setSelectedSeries(series);
 | 
			
		||||
        setSelectedLabels({});
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const isValid = selectedSeries.length > 0;
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
        <Dialog
 | 
			
		||||
            open={open}
 | 
			
		||||
@ -211,121 +99,28 @@ export const ChartConfigModal: FC<ChartConfigModalProps> = ({
 | 
			
		||||
                    <StyledConfigPanel>
 | 
			
		||||
                        <TextField
 | 
			
		||||
                            label='Chart Title (optional)'
 | 
			
		||||
                            value={title}
 | 
			
		||||
                            onChange={(e) => setTitle(e.target.value)}
 | 
			
		||||
                            value={formData.title}
 | 
			
		||||
                            onChange={(e) => actions.setTitle(e.target.value)}
 | 
			
		||||
                            fullWidth
 | 
			
		||||
                            variant='outlined'
 | 
			
		||||
                            size='small'
 | 
			
		||||
                        />
 | 
			
		||||
 | 
			
		||||
                        <ImpactMetricsControls
 | 
			
		||||
                            selectedSeries={selectedSeries}
 | 
			
		||||
                            onSeriesChange={handleSeriesChange}
 | 
			
		||||
                            selectedRange={selectedRange}
 | 
			
		||||
                            onRangeChange={setSelectedRange}
 | 
			
		||||
                            beginAtZero={beginAtZero}
 | 
			
		||||
                            onBeginAtZeroChange={setBeginAtZero}
 | 
			
		||||
                            formData={formData}
 | 
			
		||||
                            actions={actions}
 | 
			
		||||
                            metricSeries={metricSeries}
 | 
			
		||||
                            loading={loading}
 | 
			
		||||
                            selectedLabels={selectedLabels}
 | 
			
		||||
                            onLabelsChange={setSelectedLabels}
 | 
			
		||||
                            availableLabels={currentAvailableLabels}
 | 
			
		||||
                        />
 | 
			
		||||
                    </StyledConfigPanel>
 | 
			
		||||
 | 
			
		||||
                    {/* Preview Panel */}
 | 
			
		||||
                    <StyledPreviewPanel>
 | 
			
		||||
                        <Typography variant='h6' color='text.secondary'>
 | 
			
		||||
                            Preview
 | 
			
		||||
                        </Typography>
 | 
			
		||||
 | 
			
		||||
                        {!selectedSeries && !isLoading ? (
 | 
			
		||||
                            <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}
 | 
			
		||||
                        <ImpactMetricsChartPreview
 | 
			
		||||
                            selectedSeries={formData.selectedSeries}
 | 
			
		||||
                            selectedRange={formData.selectedRange}
 | 
			
		||||
                            selectedLabels={formData.selectedLabels}
 | 
			
		||||
                            beginAtZero={formData.beginAtZero}
 | 
			
		||||
                        />
 | 
			
		||||
                        </StyledChartContainer>
 | 
			
		||||
                    </StyledPreviewPanel>
 | 
			
		||||
                </Box>
 | 
			
		||||
            </DialogContent>
 | 
			
		||||
 | 
			
		||||
@ -1,25 +1,9 @@
 | 
			
		||||
import type { FC } from 'react';
 | 
			
		||||
import { useMemo } from 'react';
 | 
			
		||||
import {
 | 
			
		||||
    Box,
 | 
			
		||||
    Typography,
 | 
			
		||||
    IconButton,
 | 
			
		||||
    Alert,
 | 
			
		||||
    styled,
 | 
			
		||||
    Paper,
 | 
			
		||||
} from '@mui/material';
 | 
			
		||||
import { Box, Typography, IconButton, styled, Paper } from '@mui/material';
 | 
			
		||||
import Edit from '@mui/icons-material/Edit';
 | 
			
		||||
import Delete from '@mui/icons-material/Delete';
 | 
			
		||||
import {
 | 
			
		||||
    LineChart,
 | 
			
		||||
    NotEnoughData,
 | 
			
		||||
} from '../insights/components/LineChart/LineChart.tsx';
 | 
			
		||||
import { StyledChartContainer } from 'component/insights/InsightsCharts.styles';
 | 
			
		||||
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 DragHandle from '@mui/icons-material/DragHandle';
 | 
			
		||||
import { ImpactMetricsChart } from './ImpactMetricsChart.tsx';
 | 
			
		||||
import type { ChartConfig } from './types.ts';
 | 
			
		||||
 | 
			
		||||
export interface ChartItemProps {
 | 
			
		||||
@ -32,180 +16,130 @@ const getConfigDescription = (config: ChartConfig): string => {
 | 
			
		||||
    const parts: string[] = [];
 | 
			
		||||
 | 
			
		||||
    if (config.selectedSeries) {
 | 
			
		||||
        parts.push(`Series: ${config.selectedSeries}`);
 | 
			
		||||
        parts.push(`${config.selectedSeries}`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    parts.push(`Time range: last ${config.selectedRange}`);
 | 
			
		||||
 | 
			
		||||
    if (config.beginAtZero) {
 | 
			
		||||
        parts.push('Begin at zero');
 | 
			
		||||
    }
 | 
			
		||||
    parts.push(`last ${config.selectedRange}`);
 | 
			
		||||
 | 
			
		||||
    const labelCount = Object.keys(config.selectedLabels).length;
 | 
			
		||||
    if (labelCount > 0) {
 | 
			
		||||
        parts.push(`${labelCount} label filter${labelCount > 1 ? 's' : ''}`);
 | 
			
		||||
        parts.push(`${labelCount} filter${labelCount > 1 ? 's' : ''}`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return parts.join(' • ');
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const StyledHeader = styled(Typography)(({ theme }) => ({
 | 
			
		||||
    display: 'flex',
 | 
			
		||||
    justifyContent: 'space-between',
 | 
			
		||||
    alignItems: 'center',
 | 
			
		||||
    padding: theme.spacing(2, 3),
 | 
			
		||||
}));
 | 
			
		||||
const StyledChartWrapper = styled(Box)({
 | 
			
		||||
    height: '100%',
 | 
			
		||||
    width: '100%',
 | 
			
		||||
    '& > div': {
 | 
			
		||||
        height: '100% !important',
 | 
			
		||||
        width: '100% !important',
 | 
			
		||||
    },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const StyledWidget = styled(Paper)(({ theme }) => ({
 | 
			
		||||
    borderRadius: `${theme.shape.borderRadiusLarge}px`,
 | 
			
		||||
    borderRadius: `${theme.shape.borderRadiusMedium}px`,
 | 
			
		||||
    boxShadow: 'none',
 | 
			
		||||
    display: 'flex',
 | 
			
		||||
    flexDirection: 'column',
 | 
			
		||||
    height: '100%',
 | 
			
		||||
    overflow: 'hidden',
 | 
			
		||||
}));
 | 
			
		||||
 | 
			
		||||
export const ChartItem: FC<ChartItemProps> = ({ config, onEdit, onDelete }) => {
 | 
			
		||||
    const {
 | 
			
		||||
        data: { start, end, series: timeSeriesData },
 | 
			
		||||
        loading: dataLoading,
 | 
			
		||||
        error: dataError,
 | 
			
		||||
    } = useImpactMetricsData({
 | 
			
		||||
        series: config.selectedSeries,
 | 
			
		||||
        range: config.selectedRange,
 | 
			
		||||
        labels:
 | 
			
		||||
            Object.keys(config.selectedLabels).length > 0
 | 
			
		||||
                ? config.selectedLabels
 | 
			
		||||
                : undefined,
 | 
			
		||||
const StyledChartContent = styled(Box)({
 | 
			
		||||
    flex: 1,
 | 
			
		||||
    display: 'flex',
 | 
			
		||||
    flexDirection: 'column',
 | 
			
		||||
    minHeight: 0,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
    const placeholderData = usePlaceholderData({
 | 
			
		||||
        fill: true,
 | 
			
		||||
        type: 'constant',
 | 
			
		||||
    });
 | 
			
		||||
const StyledImpactChartContainer = styled(Box)(({ theme }) => ({
 | 
			
		||||
    position: 'relative',
 | 
			
		||||
    minWidth: 0,
 | 
			
		||||
    flexGrow: 1,
 | 
			
		||||
    height: '100%',
 | 
			
		||||
    display: 'flex',
 | 
			
		||||
    flexDirection: 'column',
 | 
			
		||||
    margin: 'auto 0',
 | 
			
		||||
    padding: theme.spacing(3),
 | 
			
		||||
}));
 | 
			
		||||
 | 
			
		||||
    const data = useChartData(timeSeriesData);
 | 
			
		||||
const StyledHeader = styled(Box)(({ theme }) => ({
 | 
			
		||||
    display: 'flex',
 | 
			
		||||
    gap: theme.spacing(2),
 | 
			
		||||
    alignItems: 'center',
 | 
			
		||||
    padding: theme.spacing(1.5, 2),
 | 
			
		||||
    borderBottom: `1px solid ${theme.palette.divider}`,
 | 
			
		||||
}));
 | 
			
		||||
 | 
			
		||||
    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 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 minTime = start
 | 
			
		||||
        ? fromUnixTime(Number.parseInt(start, 10))
 | 
			
		||||
        : undefined;
 | 
			
		||||
    const maxTime = end ? fromUnixTime(Number.parseInt(end, 10)) : undefined;
 | 
			
		||||
const StyledChartTitle = styled(Box)(({ theme }) => ({
 | 
			
		||||
    display: 'flex',
 | 
			
		||||
    flexDirection: 'column',
 | 
			
		||||
    justifyContent: 'flex-end',
 | 
			
		||||
    flexGrow: 1,
 | 
			
		||||
    overflow: 'hidden',
 | 
			
		||||
    textOverflow: 'ellipsis',
 | 
			
		||||
}));
 | 
			
		||||
 | 
			
		||||
    const placeholder = (
 | 
			
		||||
        <NotEnoughData description='Send impact metrics using Unleash SDK for this series to view the chart.' />
 | 
			
		||||
    );
 | 
			
		||||
    const cover = notEnoughData ? placeholder : isLoading;
 | 
			
		||||
const StyledChartActions = styled(Box)(({ theme }) => ({
 | 
			
		||||
    marginLeft: 'auto',
 | 
			
		||||
    display: 'flex',
 | 
			
		||||
    alignItems: 'center',
 | 
			
		||||
    gap: theme.spacing(0.5),
 | 
			
		||||
}));
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
export const ChartItem: FC<ChartItemProps> = ({ config, onEdit, onDelete }) => (
 | 
			
		||||
    <StyledWidget>
 | 
			
		||||
        <StyledHeader>
 | 
			
		||||
                <Box>
 | 
			
		||||
            <StyledDragHandle className='grid-item-drag-handle'>
 | 
			
		||||
                <DragHandle fontSize='small' />
 | 
			
		||||
            </StyledDragHandle>
 | 
			
		||||
            <StyledChartTitle>
 | 
			
		||||
                {config.title && (
 | 
			
		||||
                        <Typography variant='h6' gutterBottom>
 | 
			
		||||
                            {config.title}
 | 
			
		||||
                        </Typography>
 | 
			
		||||
                    <Typography variant='h6'>{config.title}</Typography>
 | 
			
		||||
                )}
 | 
			
		||||
                    <Typography
 | 
			
		||||
                        variant='body2'
 | 
			
		||||
                        color='text.secondary'
 | 
			
		||||
                        sx={{ mb: 1 }}
 | 
			
		||||
                    >
 | 
			
		||||
                <Typography variant='body2' color='text.secondary'>
 | 
			
		||||
                    {getConfigDescription(config)}
 | 
			
		||||
                </Typography>
 | 
			
		||||
                </Box>
 | 
			
		||||
                <Box>
 | 
			
		||||
                    <IconButton onClick={() => onEdit(config)} sx={{ mr: 1 }}>
 | 
			
		||||
            </StyledChartTitle>
 | 
			
		||||
            <StyledChartActions>
 | 
			
		||||
                <IconButton onClick={() => onEdit(config)}>
 | 
			
		||||
                    <Edit />
 | 
			
		||||
                </IconButton>
 | 
			
		||||
                <IconButton onClick={() => onDelete(config.id)}>
 | 
			
		||||
                    <Delete />
 | 
			
		||||
                </IconButton>
 | 
			
		||||
                </Box>
 | 
			
		||||
            </StyledChartActions>
 | 
			
		||||
        </StyledHeader>
 | 
			
		||||
 | 
			
		||||
            <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(
 | 
			
		||||
                                                  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}
 | 
			
		||||
        <StyledChartContent>
 | 
			
		||||
            <StyledImpactChartContainer>
 | 
			
		||||
                <StyledChartWrapper>
 | 
			
		||||
                    <ImpactMetricsChart
 | 
			
		||||
                        selectedSeries={config.selectedSeries}
 | 
			
		||||
                        selectedRange={config.selectedRange}
 | 
			
		||||
                        selectedLabels={config.selectedLabels}
 | 
			
		||||
                        beginAtZero={config.beginAtZero}
 | 
			
		||||
                        aspectRatio={1.5}
 | 
			
		||||
                        overrideOptions={{ maintainAspectRatio: false }}
 | 
			
		||||
                        emptyDataDescription='Send impact metrics using Unleash SDK for this series to view the chart.'
 | 
			
		||||
                    />
 | 
			
		||||
            </StyledChartContainer>
 | 
			
		||||
                </StyledChartWrapper>
 | 
			
		||||
            </StyledImpactChartContainer>
 | 
			
		||||
        </StyledChartContent>
 | 
			
		||||
    </StyledWidget>
 | 
			
		||||
);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										131
									
								
								frontend/src/component/impact-metrics/GridLayoutWrapper.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										131
									
								
								frontend/src/component/impact-metrics/GridLayoutWrapper.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,131 @@
 | 
			
		||||
import type { FC, ReactNode } from 'react';
 | 
			
		||||
import { useMemo, useCallback } from 'react';
 | 
			
		||||
import { Responsive, WidthProvider } from 'react-grid-layout';
 | 
			
		||||
import { styled } from '@mui/material';
 | 
			
		||||
import 'react-grid-layout/css/styles.css';
 | 
			
		||||
import 'react-resizable/css/styles.css';
 | 
			
		||||
 | 
			
		||||
const ResponsiveGridLayout = WidthProvider(Responsive);
 | 
			
		||||
 | 
			
		||||
const StyledGridContainer = styled('div')(({ theme }) => ({
 | 
			
		||||
    '& .react-grid-item': {
 | 
			
		||||
        borderRadius: `${theme.shape.borderRadiusMedium}px`,
 | 
			
		||||
    },
 | 
			
		||||
    '& .react-resizable-handle': {
 | 
			
		||||
        '&::after': {
 | 
			
		||||
            opacity: 0.5,
 | 
			
		||||
        },
 | 
			
		||||
    },
 | 
			
		||||
}));
 | 
			
		||||
 | 
			
		||||
export type GridItem = {
 | 
			
		||||
    id: string;
 | 
			
		||||
    component: ReactNode;
 | 
			
		||||
    w?: number;
 | 
			
		||||
    h?: number;
 | 
			
		||||
    x?: number;
 | 
			
		||||
    y?: number;
 | 
			
		||||
    minW?: number;
 | 
			
		||||
    minH?: number;
 | 
			
		||||
    maxW?: number;
 | 
			
		||||
    maxH?: number;
 | 
			
		||||
    static?: boolean;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type GridLayoutWrapperProps = {
 | 
			
		||||
    items: GridItem[];
 | 
			
		||||
    onLayoutChange?: (layout: unknown[]) => void;
 | 
			
		||||
    cols?: { lg: number; md: number; sm: number; xs: number; xxs: number };
 | 
			
		||||
    rowHeight?: number;
 | 
			
		||||
    margin?: [number, number];
 | 
			
		||||
    isDraggable?: boolean;
 | 
			
		||||
    isResizable?: boolean;
 | 
			
		||||
    compactType?: 'vertical' | 'horizontal' | null;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const GridLayoutWrapper: FC<GridLayoutWrapperProps> = ({
 | 
			
		||||
    items,
 | 
			
		||||
    onLayoutChange,
 | 
			
		||||
    cols = { lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 },
 | 
			
		||||
    rowHeight = 180,
 | 
			
		||||
    margin = [16, 16],
 | 
			
		||||
    isDraggable = true,
 | 
			
		||||
    isResizable = true,
 | 
			
		||||
    compactType = 'vertical',
 | 
			
		||||
}) => {
 | 
			
		||||
    const layouts = useMemo(() => {
 | 
			
		||||
        const baseLayout = items.map((item, index) => ({
 | 
			
		||||
            i: item.id,
 | 
			
		||||
            x: item.x ?? (index % cols.lg) * (item.w ?? 6),
 | 
			
		||||
            y: item.y ?? Math.floor(index / cols.lg) * (item.h ?? 4),
 | 
			
		||||
            w: item.w ?? 6,
 | 
			
		||||
            h: item.h ?? 4,
 | 
			
		||||
            minW: item.minW ?? 3,
 | 
			
		||||
            minH: item.minH ?? 3,
 | 
			
		||||
            maxW: item.maxW ?? 12,
 | 
			
		||||
            maxH: item.maxH ?? 8,
 | 
			
		||||
            static: item.static ?? false,
 | 
			
		||||
        }));
 | 
			
		||||
 | 
			
		||||
        return {
 | 
			
		||||
            lg: baseLayout,
 | 
			
		||||
            md: baseLayout.map((item) => ({
 | 
			
		||||
                ...item,
 | 
			
		||||
                w: Math.min(item.w, cols.md),
 | 
			
		||||
                x: Math.min(item.x, cols.md - item.w),
 | 
			
		||||
            })),
 | 
			
		||||
            sm: baseLayout.map((item) => ({
 | 
			
		||||
                ...item,
 | 
			
		||||
                w: Math.min(item.w, cols.sm),
 | 
			
		||||
                x: Math.min(item.x, cols.sm - item.w),
 | 
			
		||||
            })),
 | 
			
		||||
            xs: baseLayout.map((item) => ({
 | 
			
		||||
                ...item,
 | 
			
		||||
                w: Math.min(item.w, cols.xs),
 | 
			
		||||
                x: Math.min(item.x, cols.xs - item.w),
 | 
			
		||||
            })),
 | 
			
		||||
            xxs: baseLayout.map((item) => ({
 | 
			
		||||
                ...item,
 | 
			
		||||
                w: Math.min(item.w, cols.xxs),
 | 
			
		||||
                x: Math.min(item.x, cols.xxs - item.w),
 | 
			
		||||
            })),
 | 
			
		||||
        };
 | 
			
		||||
    }, [items, cols]);
 | 
			
		||||
 | 
			
		||||
    const children = useMemo(
 | 
			
		||||
        () => items.map((item) => <div key={item.id}>{item.component}</div>),
 | 
			
		||||
        [items],
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const handleLayoutChange = useCallback(
 | 
			
		||||
        (layout: unknown[], layouts: unknown) => {
 | 
			
		||||
            onLayoutChange?.(layout);
 | 
			
		||||
        },
 | 
			
		||||
        [onLayoutChange],
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
        <StyledGridContainer>
 | 
			
		||||
            <ResponsiveGridLayout
 | 
			
		||||
                className='impact-metrics-grid'
 | 
			
		||||
                layouts={layouts}
 | 
			
		||||
                breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
 | 
			
		||||
                cols={cols}
 | 
			
		||||
                rowHeight={rowHeight}
 | 
			
		||||
                margin={margin}
 | 
			
		||||
                containerPadding={[0, 0]}
 | 
			
		||||
                isDraggable={isDraggable}
 | 
			
		||||
                isResizable={isResizable}
 | 
			
		||||
                onLayoutChange={handleLayoutChange}
 | 
			
		||||
                resizeHandles={['se']}
 | 
			
		||||
                draggableHandle='.grid-item-drag-handle'
 | 
			
		||||
                compactType={compactType}
 | 
			
		||||
                preventCollision={false}
 | 
			
		||||
                useCSSTransforms={true}
 | 
			
		||||
                autoSize={true}
 | 
			
		||||
            >
 | 
			
		||||
                {children}
 | 
			
		||||
            </ResponsiveGridLayout>
 | 
			
		||||
        </StyledGridContainer>
 | 
			
		||||
    );
 | 
			
		||||
};
 | 
			
		||||
@ -1,19 +1,29 @@
 | 
			
		||||
import type { FC } from 'react';
 | 
			
		||||
import { useMemo, useState } from 'react';
 | 
			
		||||
import { Box, Typography, Button } from '@mui/material';
 | 
			
		||||
import { useMemo, useState, useCallback } from 'react';
 | 
			
		||||
import { Typography, Button, Paper, styled } from '@mui/material';
 | 
			
		||||
import Add from '@mui/icons-material/Add';
 | 
			
		||||
import { PageHeader } from 'component/common/PageHeader/PageHeader.tsx';
 | 
			
		||||
import { useImpactMetricsMetadata } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata';
 | 
			
		||||
import { ChartConfigModal } from './ChartConfigModal.tsx';
 | 
			
		||||
import { ChartItem } from './ChartItem.tsx';
 | 
			
		||||
import { useUrlState } from './hooks/useUrlState.ts';
 | 
			
		||||
import type { ChartConfig } from './types.ts';
 | 
			
		||||
import { GridLayoutWrapper, type GridItem } from './GridLayoutWrapper.tsx';
 | 
			
		||||
import { useImpactMetricsState } from './hooks/useImpactMetricsState.ts';
 | 
			
		||||
import type { ChartConfig, LayoutItem } from './types.ts';
 | 
			
		||||
 | 
			
		||||
const StyledEmptyState = styled(Paper)(({ theme }) => ({
 | 
			
		||||
    textAlign: 'center',
 | 
			
		||||
    padding: theme.spacing(8),
 | 
			
		||||
    backgroundColor: theme.palette.background.default,
 | 
			
		||||
    borderRadius: theme.shape.borderRadius * 2,
 | 
			
		||||
    border: `2px dashed ${theme.palette.divider}`,
 | 
			
		||||
}));
 | 
			
		||||
 | 
			
		||||
export const ImpactMetrics: FC = () => {
 | 
			
		||||
    const [modalOpen, setModalOpen] = useState(false);
 | 
			
		||||
    const [editingChart, setEditingChart] = useState<ChartConfig | undefined>();
 | 
			
		||||
 | 
			
		||||
    const { charts, addChart, updateChart, deleteChart } = useUrlState();
 | 
			
		||||
    const { charts, layout, addChart, updateChart, deleteChart, updateLayout } =
 | 
			
		||||
        useImpactMetricsState();
 | 
			
		||||
 | 
			
		||||
    const {
 | 
			
		||||
        metadata,
 | 
			
		||||
@ -50,6 +60,41 @@ export const ImpactMetrics: FC = () => {
 | 
			
		||||
        setModalOpen(false);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleLayoutChange = useCallback(
 | 
			
		||||
        (layout: any[]) => {
 | 
			
		||||
            updateLayout(layout as LayoutItem[]);
 | 
			
		||||
        },
 | 
			
		||||
        [updateLayout],
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const gridItems: GridItem[] = useMemo(
 | 
			
		||||
        () =>
 | 
			
		||||
            charts.map((config, index) => {
 | 
			
		||||
                const existingLayout = layout?.find(
 | 
			
		||||
                    (item) => item.i === config.id,
 | 
			
		||||
                );
 | 
			
		||||
                return {
 | 
			
		||||
                    id: config.id,
 | 
			
		||||
                    component: (
 | 
			
		||||
                        <ChartItem
 | 
			
		||||
                            config={config}
 | 
			
		||||
                            onEdit={handleEditChart}
 | 
			
		||||
                            onDelete={deleteChart}
 | 
			
		||||
                        />
 | 
			
		||||
                    ),
 | 
			
		||||
                    w: existingLayout?.w ?? 6,
 | 
			
		||||
                    h: existingLayout?.h ?? 4,
 | 
			
		||||
                    x: existingLayout?.x,
 | 
			
		||||
                    y: existingLayout?.y,
 | 
			
		||||
                    minW: 4,
 | 
			
		||||
                    minH: 2,
 | 
			
		||||
                    maxW: 12,
 | 
			
		||||
                    maxH: 8,
 | 
			
		||||
                };
 | 
			
		||||
            }),
 | 
			
		||||
        [charts, layout, handleEditChart, deleteChart],
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const hasError = metadataError;
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
@ -62,7 +107,6 @@ export const ImpactMetrics: FC = () => {
 | 
			
		||||
                    </Typography>
 | 
			
		||||
                }
 | 
			
		||||
                actions={
 | 
			
		||||
                    charts.length > 0 ? (
 | 
			
		||||
                    <Button
 | 
			
		||||
                        variant='contained'
 | 
			
		||||
                        startIcon={<Add />}
 | 
			
		||||
@ -71,24 +115,11 @@ export const ImpactMetrics: FC = () => {
 | 
			
		||||
                    >
 | 
			
		||||
                        Add Chart
 | 
			
		||||
                    </Button>
 | 
			
		||||
                    ) : null
 | 
			
		||||
                }
 | 
			
		||||
            />
 | 
			
		||||
            <Box
 | 
			
		||||
                sx={(theme) => ({
 | 
			
		||||
                    display: 'flex',
 | 
			
		||||
                    flexDirection: 'column',
 | 
			
		||||
                    gap: theme.spacing(2),
 | 
			
		||||
                    width: '100%',
 | 
			
		||||
                })}
 | 
			
		||||
            >
 | 
			
		||||
 | 
			
		||||
            {charts.length === 0 && !metadataLoading && !hasError ? (
 | 
			
		||||
                    <Box
 | 
			
		||||
                        sx={(theme) => ({
 | 
			
		||||
                            textAlign: 'center',
 | 
			
		||||
                            py: theme.spacing(8),
 | 
			
		||||
                        })}
 | 
			
		||||
                    >
 | 
			
		||||
                <StyledEmptyState>
 | 
			
		||||
                    <Typography variant='h6' gutterBottom>
 | 
			
		||||
                        No charts configured
 | 
			
		||||
                    </Typography>
 | 
			
		||||
@ -97,8 +128,8 @@ export const ImpactMetrics: FC = () => {
 | 
			
		||||
                        color='text.secondary'
 | 
			
		||||
                        sx={{ mb: 3 }}
 | 
			
		||||
                    >
 | 
			
		||||
                            Add your first impact metrics chart to start
 | 
			
		||||
                            tracking performance
 | 
			
		||||
                        Add your first impact metrics chart to start tracking
 | 
			
		||||
                        performance with a beautiful drag-and-drop grid layout
 | 
			
		||||
                    </Typography>
 | 
			
		||||
                    <Button
 | 
			
		||||
                        variant='contained'
 | 
			
		||||
@ -108,17 +139,16 @@ export const ImpactMetrics: FC = () => {
 | 
			
		||||
                    >
 | 
			
		||||
                        Add Chart
 | 
			
		||||
                    </Button>
 | 
			
		||||
                    </Box>
 | 
			
		||||
                ) : (
 | 
			
		||||
                    charts.map((config) => (
 | 
			
		||||
                        <ChartItem
 | 
			
		||||
                            key={config.id}
 | 
			
		||||
                            config={config}
 | 
			
		||||
                            onEdit={handleEditChart}
 | 
			
		||||
                            onDelete={deleteChart}
 | 
			
		||||
                </StyledEmptyState>
 | 
			
		||||
            ) : charts.length > 0 ? (
 | 
			
		||||
                <GridLayoutWrapper
 | 
			
		||||
                    items={gridItems}
 | 
			
		||||
                    onLayoutChange={handleLayoutChange}
 | 
			
		||||
                    rowHeight={180}
 | 
			
		||||
                    margin={[16, 16]}
 | 
			
		||||
                    cols={{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }}
 | 
			
		||||
                />
 | 
			
		||||
                    ))
 | 
			
		||||
                )}
 | 
			
		||||
            ) : null}
 | 
			
		||||
 | 
			
		||||
            <ChartConfigModal
 | 
			
		||||
                open={modalOpen}
 | 
			
		||||
@ -128,7 +158,6 @@ export const ImpactMetrics: FC = () => {
 | 
			
		||||
                metricSeries={metricSeries}
 | 
			
		||||
                loading={metadataLoading}
 | 
			
		||||
            />
 | 
			
		||||
            </Box>
 | 
			
		||||
        </>
 | 
			
		||||
    );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										150
									
								
								frontend/src/component/impact-metrics/ImpactMetricsChart.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										150
									
								
								frontend/src/component/impact-metrics/ImpactMetricsChart.tsx
									
									
									
									
									
										Normal 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}
 | 
			
		||||
            />
 | 
			
		||||
        </>
 | 
			
		||||
    );
 | 
			
		||||
};
 | 
			
		||||
@ -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>
 | 
			
		||||
    </>
 | 
			
		||||
);
 | 
			
		||||
@ -1,29 +1,33 @@
 | 
			
		||||
import type { FC } from 'react';
 | 
			
		||||
import { Box, Typography } from '@mui/material';
 | 
			
		||||
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, type TimeRange } from './components/RangeSelector.tsx';
 | 
			
		||||
import { BeginAtZeroToggle } from './components/BeginAtZeroToggle.tsx';
 | 
			
		||||
import { RangeSelector } from './components/RangeSelector.tsx';
 | 
			
		||||
import { LabelsFilter } from './components/LabelsFilter.tsx';
 | 
			
		||||
import type { ChartFormState } from '../hooks/useChartFormState.ts';
 | 
			
		||||
 | 
			
		||||
export type ImpactMetricsControlsProps = {
 | 
			
		||||
    selectedSeries: string;
 | 
			
		||||
    onSeriesChange: (series: string) => void;
 | 
			
		||||
    selectedRange: TimeRange;
 | 
			
		||||
    onRangeChange: (range: TimeRange) => void;
 | 
			
		||||
    beginAtZero: boolean;
 | 
			
		||||
    onBeginAtZeroChange: (beginAtZero: boolean) => void;
 | 
			
		||||
    formData: ChartFormState['formData'];
 | 
			
		||||
    actions: Pick<
 | 
			
		||||
        ChartFormState['actions'],
 | 
			
		||||
        | 'handleSeriesChange'
 | 
			
		||||
        | 'setSelectedRange'
 | 
			
		||||
        | 'setBeginAtZero'
 | 
			
		||||
        | 'setSelectedLabels'
 | 
			
		||||
    >;
 | 
			
		||||
    metricSeries: (ImpactMetricsSeries & { name: string })[];
 | 
			
		||||
    loading?: boolean;
 | 
			
		||||
    selectedLabels: Record<string, string[]>;
 | 
			
		||||
    onLabelsChange: (labels: Record<string, string[]>) => void;
 | 
			
		||||
    availableLabels?: ImpactMetricsLabels;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const ImpactMetricsControls: FC<ImpactMetricsControlsProps> = (
 | 
			
		||||
    props,
 | 
			
		||||
) => (
 | 
			
		||||
export const ImpactMetricsControls: FC<ImpactMetricsControlsProps> = ({
 | 
			
		||||
    formData,
 | 
			
		||||
    actions,
 | 
			
		||||
    metricSeries,
 | 
			
		||||
    loading,
 | 
			
		||||
    availableLabels,
 | 
			
		||||
}) => (
 | 
			
		||||
    <Box
 | 
			
		||||
        sx={(theme) => ({
 | 
			
		||||
            display: 'flex',
 | 
			
		||||
@ -39,27 +43,32 @@ export const ImpactMetricsControls: FC<ImpactMetricsControlsProps> = (
 | 
			
		||||
        </Typography>
 | 
			
		||||
 | 
			
		||||
        <SeriesSelector
 | 
			
		||||
            value={props.selectedSeries}
 | 
			
		||||
            onChange={props.onSeriesChange}
 | 
			
		||||
            options={props.metricSeries}
 | 
			
		||||
            loading={props.loading}
 | 
			
		||||
            value={formData.selectedSeries}
 | 
			
		||||
            onChange={actions.handleSeriesChange}
 | 
			
		||||
            options={metricSeries}
 | 
			
		||||
            loading={loading}
 | 
			
		||||
        />
 | 
			
		||||
 | 
			
		||||
        <RangeSelector
 | 
			
		||||
            value={props.selectedRange}
 | 
			
		||||
            onChange={props.onRangeChange}
 | 
			
		||||
            value={formData.selectedRange}
 | 
			
		||||
            onChange={actions.setSelectedRange}
 | 
			
		||||
        />
 | 
			
		||||
 | 
			
		||||
        <BeginAtZeroToggle
 | 
			
		||||
            value={props.beginAtZero}
 | 
			
		||||
            onChange={props.onBeginAtZeroChange}
 | 
			
		||||
        <FormControlLabel
 | 
			
		||||
            control={
 | 
			
		||||
                <Checkbox
 | 
			
		||||
                    checked={formData.beginAtZero}
 | 
			
		||||
                    onChange={(e) => actions.setBeginAtZero(e.target.checked)}
 | 
			
		||||
                />
 | 
			
		||||
            }
 | 
			
		||||
            label='Begin at zero'
 | 
			
		||||
        />
 | 
			
		||||
 | 
			
		||||
        {props.availableLabels && (
 | 
			
		||||
        {availableLabels && (
 | 
			
		||||
            <LabelsFilter
 | 
			
		||||
                selectedLabels={props.selectedLabels}
 | 
			
		||||
                onChange={props.onLabelsChange}
 | 
			
		||||
                availableLabels={props.availableLabels}
 | 
			
		||||
                selectedLabels={formData.selectedLabels}
 | 
			
		||||
                onChange={actions.setSelectedLabels}
 | 
			
		||||
                availableLabels={availableLabels}
 | 
			
		||||
            />
 | 
			
		||||
        )}
 | 
			
		||||
    </Box>
 | 
			
		||||
 | 
			
		||||
@ -1,22 +0,0 @@
 | 
			
		||||
import type { FC } from 'react';
 | 
			
		||||
import { FormControlLabel, Checkbox } from '@mui/material';
 | 
			
		||||
 | 
			
		||||
export type BeginAtZeroToggleProps = {
 | 
			
		||||
    value: boolean;
 | 
			
		||||
    onChange: (beginAtZero: boolean) => void;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const BeginAtZeroToggle: FC<BeginAtZeroToggleProps> = ({
 | 
			
		||||
    value,
 | 
			
		||||
    onChange,
 | 
			
		||||
}) => (
 | 
			
		||||
    <FormControlLabel
 | 
			
		||||
        control={
 | 
			
		||||
            <Checkbox
 | 
			
		||||
                checked={value}
 | 
			
		||||
                onChange={(e) => onChange(e.target.checked)}
 | 
			
		||||
            />
 | 
			
		||||
        }
 | 
			
		||||
        label='Begin at zero'
 | 
			
		||||
    />
 | 
			
		||||
);
 | 
			
		||||
@ -25,7 +25,7 @@ export const SeriesSelector: FC<SeriesSelectorProps> = ({
 | 
			
		||||
        onChange={(_, newValue) => onChange(newValue?.name || '')}
 | 
			
		||||
        disabled={loading}
 | 
			
		||||
        renderOption={(props, option, { inputValue }) => (
 | 
			
		||||
            <Box component='li' {...props}>
 | 
			
		||||
            <Box component='li' {...props} key={option.name}>
 | 
			
		||||
                <Box sx={{ display: 'flex', flexDirection: 'column' }}>
 | 
			
		||||
                    <Typography variant='body2'>
 | 
			
		||||
                        <Highlighter search={inputValue}>
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,7 @@
 | 
			
		||||
import { lazy } from 'react';
 | 
			
		||||
 | 
			
		||||
export const LazyImpactMetricsPage = lazy(() =>
 | 
			
		||||
    import('./ImpactMetricsPage.tsx').then((module) => ({
 | 
			
		||||
        default: module.ImpactMetricsPage,
 | 
			
		||||
    })),
 | 
			
		||||
);
 | 
			
		||||
							
								
								
									
										112
									
								
								frontend/src/component/impact-metrics/hooks/useChartFormState.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										112
									
								
								frontend/src/component/impact-metrics/hooks/useChartFormState.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,112 @@
 | 
			
		||||
import { useState, useEffect } from 'react';
 | 
			
		||||
import { useImpactMetricsData } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData';
 | 
			
		||||
import type { ChartConfig } from '../types.ts';
 | 
			
		||||
import type { ImpactMetricsLabels } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData';
 | 
			
		||||
 | 
			
		||||
type UseChartConfigParams = {
 | 
			
		||||
    open: boolean;
 | 
			
		||||
    initialConfig?: ChartConfig;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type ChartFormState = {
 | 
			
		||||
    formData: {
 | 
			
		||||
        title: string;
 | 
			
		||||
        selectedSeries: string;
 | 
			
		||||
        selectedRange: 'hour' | 'day' | 'week' | 'month';
 | 
			
		||||
        beginAtZero: boolean;
 | 
			
		||||
        selectedLabels: Record<string, string[]>;
 | 
			
		||||
    };
 | 
			
		||||
    actions: {
 | 
			
		||||
        setTitle: (title: string) => void;
 | 
			
		||||
        setSelectedSeries: (series: string) => void;
 | 
			
		||||
        setSelectedRange: (range: 'hour' | 'day' | 'week' | 'month') => void;
 | 
			
		||||
        setBeginAtZero: (beginAtZero: boolean) => void;
 | 
			
		||||
        setSelectedLabels: (labels: Record<string, string[]>) => void;
 | 
			
		||||
        handleSeriesChange: (series: string) => void;
 | 
			
		||||
        getConfigToSave: () => Omit<ChartConfig, 'id'>;
 | 
			
		||||
    };
 | 
			
		||||
    isValid: boolean;
 | 
			
		||||
    currentAvailableLabels: ImpactMetricsLabels | undefined;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const useChartFormState = ({
 | 
			
		||||
    open,
 | 
			
		||||
    initialConfig,
 | 
			
		||||
}: UseChartConfigParams): ChartFormState => {
 | 
			
		||||
    const [title, setTitle] = useState(initialConfig?.title || '');
 | 
			
		||||
    const [selectedSeries, setSelectedSeries] = useState(
 | 
			
		||||
        initialConfig?.selectedSeries || '',
 | 
			
		||||
    );
 | 
			
		||||
    const [selectedRange, setSelectedRange] = useState<
 | 
			
		||||
        'hour' | 'day' | 'week' | 'month'
 | 
			
		||||
    >(initialConfig?.selectedRange || 'day');
 | 
			
		||||
    const [beginAtZero, setBeginAtZero] = useState(
 | 
			
		||||
        initialConfig?.beginAtZero || false,
 | 
			
		||||
    );
 | 
			
		||||
    const [selectedLabels, setSelectedLabels] = useState<
 | 
			
		||||
        Record<string, string[]>
 | 
			
		||||
    >(initialConfig?.selectedLabels || {});
 | 
			
		||||
 | 
			
		||||
    const {
 | 
			
		||||
        data: { labels: currentAvailableLabels },
 | 
			
		||||
    } = useImpactMetricsData(
 | 
			
		||||
        selectedSeries
 | 
			
		||||
            ? {
 | 
			
		||||
                  series: selectedSeries,
 | 
			
		||||
                  range: selectedRange,
 | 
			
		||||
              }
 | 
			
		||||
            : undefined,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    useEffect(() => {
 | 
			
		||||
        if (open && initialConfig) {
 | 
			
		||||
            setTitle(initialConfig.title || '');
 | 
			
		||||
            setSelectedSeries(initialConfig.selectedSeries);
 | 
			
		||||
            setSelectedRange(initialConfig.selectedRange);
 | 
			
		||||
            setBeginAtZero(initialConfig.beginAtZero);
 | 
			
		||||
            setSelectedLabels(initialConfig.selectedLabels);
 | 
			
		||||
        } else if (open && !initialConfig) {
 | 
			
		||||
            setTitle('');
 | 
			
		||||
            setSelectedSeries('');
 | 
			
		||||
            setSelectedRange('day');
 | 
			
		||||
            setBeginAtZero(false);
 | 
			
		||||
            setSelectedLabels({});
 | 
			
		||||
        }
 | 
			
		||||
    }, [open, initialConfig]);
 | 
			
		||||
 | 
			
		||||
    const handleSeriesChange = (series: string) => {
 | 
			
		||||
        setSelectedSeries(series);
 | 
			
		||||
        setSelectedLabels({});
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const getConfigToSave = (): Omit<ChartConfig, 'id'> => ({
 | 
			
		||||
        title: title || undefined,
 | 
			
		||||
        selectedSeries,
 | 
			
		||||
        selectedRange,
 | 
			
		||||
        beginAtZero,
 | 
			
		||||
        selectedLabels,
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    const isValid = selectedSeries.length > 0;
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
        formData: {
 | 
			
		||||
            title,
 | 
			
		||||
            selectedSeries,
 | 
			
		||||
            selectedRange,
 | 
			
		||||
            beginAtZero,
 | 
			
		||||
            selectedLabels,
 | 
			
		||||
        },
 | 
			
		||||
        actions: {
 | 
			
		||||
            setTitle,
 | 
			
		||||
            setSelectedSeries,
 | 
			
		||||
            setSelectedRange,
 | 
			
		||||
            setBeginAtZero,
 | 
			
		||||
            setSelectedLabels,
 | 
			
		||||
            handleSeriesChange,
 | 
			
		||||
            getConfigToSave,
 | 
			
		||||
        },
 | 
			
		||||
        isValid,
 | 
			
		||||
        currentAvailableLabels,
 | 
			
		||||
    };
 | 
			
		||||
};
 | 
			
		||||
@ -0,0 +1,120 @@
 | 
			
		||||
import { render } from 'utils/testRenderer';
 | 
			
		||||
import { useImpactMetricsState } from './useImpactMetricsState.ts';
 | 
			
		||||
import { Route, Routes } from 'react-router-dom';
 | 
			
		||||
import { createLocalStorage } from '../../../utils/createLocalStorage.ts';
 | 
			
		||||
import type { FC } from 'react';
 | 
			
		||||
import type { ImpactMetricsState } from '../types.ts';
 | 
			
		||||
 | 
			
		||||
const TestComponent: FC = () => {
 | 
			
		||||
    const { charts, layout } = useImpactMetricsState();
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
        <div>
 | 
			
		||||
            <span data-testid='charts-count'>{charts.length}</span>
 | 
			
		||||
            <span data-testid='layout-count'>{layout.length}</span>
 | 
			
		||||
        </div>
 | 
			
		||||
    );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const TestWrapper = () => (
 | 
			
		||||
    <Routes>
 | 
			
		||||
        <Route path='/impact-metrics' element={<TestComponent />} />
 | 
			
		||||
    </Routes>
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
describe('useImpactMetricsState', () => {
 | 
			
		||||
    beforeEach(() => {
 | 
			
		||||
        window.localStorage.clear();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('loads state from localStorage to the URL after opening page without URL state', async () => {
 | 
			
		||||
        const { setValue } = createLocalStorage<ImpactMetricsState>(
 | 
			
		||||
            'impact-metrics-state',
 | 
			
		||||
            {
 | 
			
		||||
                charts: [],
 | 
			
		||||
                layout: [],
 | 
			
		||||
            },
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        setValue({
 | 
			
		||||
            charts: [
 | 
			
		||||
                {
 | 
			
		||||
                    id: 'test-chart',
 | 
			
		||||
                    selectedSeries: 'test-series',
 | 
			
		||||
                    selectedRange: 'day' as const,
 | 
			
		||||
                    beginAtZero: true,
 | 
			
		||||
                    selectedLabels: {},
 | 
			
		||||
                    title: 'Test Chart',
 | 
			
		||||
                },
 | 
			
		||||
            ],
 | 
			
		||||
            layout: [
 | 
			
		||||
                {
 | 
			
		||||
                    i: 'test-chart',
 | 
			
		||||
                    x: 0,
 | 
			
		||||
                    y: 0,
 | 
			
		||||
                    w: 6,
 | 
			
		||||
                    h: 4,
 | 
			
		||||
                    minW: 4,
 | 
			
		||||
                    minH: 2,
 | 
			
		||||
                    maxW: 12,
 | 
			
		||||
                    maxH: 8,
 | 
			
		||||
                },
 | 
			
		||||
            ],
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        render(<TestWrapper />, { route: '/impact-metrics' });
 | 
			
		||||
 | 
			
		||||
        expect(window.location.href).toContain('charts=');
 | 
			
		||||
        expect(window.location.href).toContain('layout=');
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('does not modify URL when URL already contains data', async () => {
 | 
			
		||||
        const { setValue } = createLocalStorage<ImpactMetricsState>(
 | 
			
		||||
            'impact-metrics-state',
 | 
			
		||||
            {
 | 
			
		||||
                charts: [],
 | 
			
		||||
                layout: [],
 | 
			
		||||
            },
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        setValue({
 | 
			
		||||
            charts: [
 | 
			
		||||
                {
 | 
			
		||||
                    id: 'old-chart',
 | 
			
		||||
                    selectedSeries: 'old-series',
 | 
			
		||||
                    selectedRange: 'day' as const,
 | 
			
		||||
                    beginAtZero: true,
 | 
			
		||||
                    selectedLabels: {},
 | 
			
		||||
                    title: 'Old Chart',
 | 
			
		||||
                },
 | 
			
		||||
            ],
 | 
			
		||||
            layout: [],
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        const urlCharts = btoa(
 | 
			
		||||
            JSON.stringify([
 | 
			
		||||
                {
 | 
			
		||||
                    id: 'url-chart',
 | 
			
		||||
                    selectedSeries: 'url-series',
 | 
			
		||||
                    selectedRange: 'day',
 | 
			
		||||
                    beginAtZero: true,
 | 
			
		||||
                    selectedLabels: {},
 | 
			
		||||
                    title: 'URL Chart',
 | 
			
		||||
                },
 | 
			
		||||
            ]),
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        render(<TestWrapper />, {
 | 
			
		||||
            route: `/impact-metrics?charts=${encodeURIComponent(urlCharts)}`,
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        const urlParams = new URLSearchParams(window.location.search);
 | 
			
		||||
        const chartsParam = urlParams.get('charts');
 | 
			
		||||
 | 
			
		||||
        expect(chartsParam).toBeTruthy();
 | 
			
		||||
 | 
			
		||||
        const decodedCharts = JSON.parse(atob(chartsParam!));
 | 
			
		||||
        expect(decodedCharts[0].id).toBe('url-chart');
 | 
			
		||||
        expect(decodedCharts[0].id).not.toBe('old-chart');
 | 
			
		||||
    });
 | 
			
		||||
});
 | 
			
		||||
@ -0,0 +1,127 @@
 | 
			
		||||
import { useCallback, useMemo } from 'react';
 | 
			
		||||
import { withDefault } from 'use-query-params';
 | 
			
		||||
import { usePersistentTableState } from 'hooks/usePersistentTableState';
 | 
			
		||||
import type { ChartConfig, ImpactMetricsState, LayoutItem } from '../types.ts';
 | 
			
		||||
 | 
			
		||||
const createArrayParam = <T>() => ({
 | 
			
		||||
    encode: (items: T[]): string =>
 | 
			
		||||
        items.length > 0 ? btoa(JSON.stringify(items)) : '',
 | 
			
		||||
 | 
			
		||||
    decode: (value: string | (string | null)[] | null | undefined): T[] => {
 | 
			
		||||
        if (typeof value !== 'string' || !value) return [];
 | 
			
		||||
        try {
 | 
			
		||||
            return JSON.parse(atob(value));
 | 
			
		||||
        } catch {
 | 
			
		||||
            return [];
 | 
			
		||||
        }
 | 
			
		||||
    },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const ChartsParam = createArrayParam<ChartConfig>();
 | 
			
		||||
const LayoutParam = createArrayParam<LayoutItem>();
 | 
			
		||||
 | 
			
		||||
export const useImpactMetricsState = () => {
 | 
			
		||||
    const stateConfig = {
 | 
			
		||||
        charts: withDefault(ChartsParam, []),
 | 
			
		||||
        layout: withDefault(LayoutParam, []),
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const [tableState, setTableState] = usePersistentTableState(
 | 
			
		||||
        'impact-metrics-state',
 | 
			
		||||
        stateConfig,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const currentState: ImpactMetricsState = useMemo(
 | 
			
		||||
        () => ({
 | 
			
		||||
            charts: tableState.charts || [],
 | 
			
		||||
            layout: tableState.layout || [],
 | 
			
		||||
        }),
 | 
			
		||||
        [tableState.charts, tableState.layout],
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const updateState = useCallback(
 | 
			
		||||
        (newState: ImpactMetricsState) => {
 | 
			
		||||
            setTableState({
 | 
			
		||||
                charts: newState.charts,
 | 
			
		||||
                layout: newState.layout,
 | 
			
		||||
            });
 | 
			
		||||
        },
 | 
			
		||||
        [setTableState],
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const addChart = useCallback(
 | 
			
		||||
        (config: Omit<ChartConfig, 'id'>) => {
 | 
			
		||||
            const newChart: ChartConfig = {
 | 
			
		||||
                ...config,
 | 
			
		||||
                id: `chart-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
 | 
			
		||||
            };
 | 
			
		||||
 | 
			
		||||
            const maxY =
 | 
			
		||||
                currentState.layout.length > 0
 | 
			
		||||
                    ? Math.max(
 | 
			
		||||
                          ...currentState.layout.map((item) => item.y + item.h),
 | 
			
		||||
                      )
 | 
			
		||||
                    : 0;
 | 
			
		||||
 | 
			
		||||
            updateState({
 | 
			
		||||
                charts: [...currentState.charts, newChart],
 | 
			
		||||
                layout: [
 | 
			
		||||
                    ...currentState.layout,
 | 
			
		||||
                    {
 | 
			
		||||
                        i: newChart.id,
 | 
			
		||||
                        x: 0,
 | 
			
		||||
                        y: maxY,
 | 
			
		||||
                        w: 6,
 | 
			
		||||
                        h: 4,
 | 
			
		||||
                        minW: 4,
 | 
			
		||||
                        minH: 2,
 | 
			
		||||
                        maxW: 12,
 | 
			
		||||
                        maxH: 8,
 | 
			
		||||
                    },
 | 
			
		||||
                ],
 | 
			
		||||
            });
 | 
			
		||||
        },
 | 
			
		||||
        [currentState.charts, currentState.layout, updateState],
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const updateChart = useCallback(
 | 
			
		||||
        (id: string, updates: Partial<ChartConfig>) => {
 | 
			
		||||
            updateState({
 | 
			
		||||
                charts: currentState.charts.map((chart) =>
 | 
			
		||||
                    chart.id === id ? { ...chart, ...updates } : chart,
 | 
			
		||||
                ),
 | 
			
		||||
                layout: currentState.layout,
 | 
			
		||||
            });
 | 
			
		||||
        },
 | 
			
		||||
        [currentState.charts, currentState.layout, updateState],
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const deleteChart = useCallback(
 | 
			
		||||
        (id: string) => {
 | 
			
		||||
            updateState({
 | 
			
		||||
                charts: currentState.charts.filter((chart) => chart.id !== id),
 | 
			
		||||
                layout: currentState.layout.filter((item) => item.i !== id),
 | 
			
		||||
            });
 | 
			
		||||
        },
 | 
			
		||||
        [currentState.charts, currentState.layout, updateState],
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const updateLayout = useCallback(
 | 
			
		||||
        (newLayout: LayoutItem[]) => {
 | 
			
		||||
            updateState({
 | 
			
		||||
                charts: currentState.charts,
 | 
			
		||||
                layout: newLayout,
 | 
			
		||||
            });
 | 
			
		||||
        },
 | 
			
		||||
        [currentState.charts, updateState],
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
        charts: currentState.charts || [],
 | 
			
		||||
        layout: currentState.layout || [],
 | 
			
		||||
        addChart,
 | 
			
		||||
        updateChart,
 | 
			
		||||
        deleteChart,
 | 
			
		||||
        updateLayout,
 | 
			
		||||
    };
 | 
			
		||||
};
 | 
			
		||||
@ -1,108 +0,0 @@
 | 
			
		||||
import { useCallback, useEffect } from 'react';
 | 
			
		||||
import { useSearchParams } from 'react-router-dom';
 | 
			
		||||
import { useLocalStorageState } from 'hooks/useLocalStorageState';
 | 
			
		||||
import type { ChartConfig, ImpactMetricsState } from '../types.ts';
 | 
			
		||||
 | 
			
		||||
const encodeState = (
 | 
			
		||||
    state: ImpactMetricsState | null | undefined,
 | 
			
		||||
): string | undefined =>
 | 
			
		||||
    state && state.charts.length > 0 ? btoa(JSON.stringify(state)) : undefined;
 | 
			
		||||
 | 
			
		||||
const decodeState = (
 | 
			
		||||
    value: string | (string | null)[] | null | undefined,
 | 
			
		||||
): ImpactMetricsState | null => {
 | 
			
		||||
    if (typeof value !== 'string') return null;
 | 
			
		||||
    try {
 | 
			
		||||
        return JSON.parse(atob(value));
 | 
			
		||||
    } catch {
 | 
			
		||||
        return null;
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const useUrlState = () => {
 | 
			
		||||
    const [searchParams, setSearchParams] = useSearchParams();
 | 
			
		||||
    const [storedState, setStoredState] =
 | 
			
		||||
        useLocalStorageState<ImpactMetricsState>('impact-metrics-state', {
 | 
			
		||||
            charts: [],
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
    const urlState = decodeState(searchParams.get('data'));
 | 
			
		||||
    const currentState = urlState || storedState;
 | 
			
		||||
 | 
			
		||||
    useEffect(() => {
 | 
			
		||||
        if (urlState) {
 | 
			
		||||
            setStoredState(urlState);
 | 
			
		||||
        } else if (storedState.charts.length > 0) {
 | 
			
		||||
            const encoded = encodeState(storedState);
 | 
			
		||||
            if (encoded) {
 | 
			
		||||
                setSearchParams(
 | 
			
		||||
                    (prev) => {
 | 
			
		||||
                        prev.set('data', encoded);
 | 
			
		||||
                        return prev;
 | 
			
		||||
                    },
 | 
			
		||||
                    { replace: true },
 | 
			
		||||
                );
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }, [urlState, storedState.charts.length, setStoredState, setSearchParams]);
 | 
			
		||||
 | 
			
		||||
    const updateState = useCallback(
 | 
			
		||||
        (newState: ImpactMetricsState) => {
 | 
			
		||||
            setStoredState(newState);
 | 
			
		||||
            setSearchParams(
 | 
			
		||||
                (prev) => {
 | 
			
		||||
                    const encoded = encodeState(newState);
 | 
			
		||||
                    if (encoded) {
 | 
			
		||||
                        prev.set('data', encoded);
 | 
			
		||||
                    } else {
 | 
			
		||||
                        prev.delete('data');
 | 
			
		||||
                    }
 | 
			
		||||
                    return prev;
 | 
			
		||||
                },
 | 
			
		||||
                { replace: true },
 | 
			
		||||
            );
 | 
			
		||||
        },
 | 
			
		||||
        [setStoredState, setSearchParams],
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const addChart = useCallback(
 | 
			
		||||
        (config: Omit<ChartConfig, 'id'>) => {
 | 
			
		||||
            const newChart: ChartConfig = {
 | 
			
		||||
                ...config,
 | 
			
		||||
                id: `chart-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
 | 
			
		||||
            };
 | 
			
		||||
 | 
			
		||||
            updateState({
 | 
			
		||||
                charts: [...currentState.charts, newChart],
 | 
			
		||||
            });
 | 
			
		||||
        },
 | 
			
		||||
        [currentState.charts, updateState],
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const updateChart = useCallback(
 | 
			
		||||
        (id: string, updates: Partial<ChartConfig>) => {
 | 
			
		||||
            updateState({
 | 
			
		||||
                charts: currentState.charts.map((chart) =>
 | 
			
		||||
                    chart.id === id ? { ...chart, ...updates } : chart,
 | 
			
		||||
                ),
 | 
			
		||||
            });
 | 
			
		||||
        },
 | 
			
		||||
        [currentState.charts, updateState],
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const deleteChart = useCallback(
 | 
			
		||||
        (id: string) => {
 | 
			
		||||
            updateState({
 | 
			
		||||
                charts: currentState.charts.filter((chart) => chart.id !== id),
 | 
			
		||||
            });
 | 
			
		||||
        },
 | 
			
		||||
        [currentState.charts, updateState],
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
        charts: currentState.charts,
 | 
			
		||||
        addChart,
 | 
			
		||||
        updateChart,
 | 
			
		||||
        deleteChart,
 | 
			
		||||
    };
 | 
			
		||||
};
 | 
			
		||||
@ -7,6 +7,19 @@ export type ChartConfig = {
 | 
			
		||||
    title?: string;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type LayoutItem = {
 | 
			
		||||
    i: string;
 | 
			
		||||
    x: number;
 | 
			
		||||
    y: number;
 | 
			
		||||
    w: number;
 | 
			
		||||
    h: number;
 | 
			
		||||
    minW?: number;
 | 
			
		||||
    minH?: number;
 | 
			
		||||
    maxW?: number;
 | 
			
		||||
    maxH?: number;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type ImpactMetricsState = {
 | 
			
		||||
    charts: ChartConfig[];
 | 
			
		||||
    layout: LayoutItem[];
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -117,7 +117,7 @@ const LineChartComponent: FC<{
 | 
			
		||||
                ),
 | 
			
		||||
                overrideOptions ?? {},
 | 
			
		||||
            ]),
 | 
			
		||||
        [theme, locationSettings, overrideOptions, cover],
 | 
			
		||||
        [theme, locationSettings, setTooltip, overrideOptions, cover],
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
 | 
			
		||||
@ -136,7 +136,14 @@ exports[`returns all baseRoutes 1`] = `
 | 
			
		||||
    "type": "protected",
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "component": [Function],
 | 
			
		||||
    "component": {
 | 
			
		||||
      "$$typeof": Symbol(react.lazy),
 | 
			
		||||
      "_init": [Function],
 | 
			
		||||
      "_payload": {
 | 
			
		||||
        "_result": [Function],
 | 
			
		||||
        "_status": -1,
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
    "enterprise": true,
 | 
			
		||||
    "flag": "impactMetrics",
 | 
			
		||||
    "menu": {
 | 
			
		||||
 | 
			
		||||
@ -42,7 +42,7 @@ import { ViewIntegration } from 'component/integrations/ViewIntegration/ViewInte
 | 
			
		||||
import { PaginatedApplicationList } from '../application/ApplicationList/PaginatedApplicationList.jsx';
 | 
			
		||||
import { AddonRedirect } from 'component/integrations/AddonRedirect/AddonRedirect';
 | 
			
		||||
import { Insights } from '../insights/Insights.jsx';
 | 
			
		||||
import { ImpactMetricsPage } from '../impact-metrics/ImpactMetricsPage.tsx';
 | 
			
		||||
import { LazyImpactMetricsPage } from '../impact-metrics/LazyImpactMetricsPage.tsx';
 | 
			
		||||
import { FeedbackList } from '../feedbackNew/FeedbackList.jsx';
 | 
			
		||||
import { Application } from 'component/application/Application';
 | 
			
		||||
import { Signals } from 'component/signals/Signals';
 | 
			
		||||
@ -164,7 +164,7 @@ export const routes: IRoute[] = [
 | 
			
		||||
    {
 | 
			
		||||
        path: '/impact-metrics',
 | 
			
		||||
        title: 'Impact metrics',
 | 
			
		||||
        component: ImpactMetricsPage,
 | 
			
		||||
        component: LazyImpactMetricsPage,
 | 
			
		||||
        type: 'protected',
 | 
			
		||||
        menu: { primary: true },
 | 
			
		||||
        enterprise: true,
 | 
			
		||||
 | 
			
		||||
@ -162,3 +162,7 @@ input.hide-clear[type="search"]::-webkit-search-results-decoration {
 | 
			
		||||
.jse-message.jse-error {
 | 
			
		||||
    display: none !important;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.react-grid-item.react-grid-placeholder {
 | 
			
		||||
    background: #6c65e5 !important;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -3256,6 +3256,15 @@ __metadata:
 | 
			
		||||
  languageName: node
 | 
			
		||||
  linkType: hard
 | 
			
		||||
 | 
			
		||||
"@types/react-grid-layout@npm:^1.3.5":
 | 
			
		||||
  version: 1.3.5
 | 
			
		||||
  resolution: "@types/react-grid-layout@npm:1.3.5"
 | 
			
		||||
  dependencies:
 | 
			
		||||
    "@types/react": "npm:*"
 | 
			
		||||
  checksum: 10c0/abd2a1dda9625c753ff2571a10b69740b2fb9ed1d3141755d54d5814cc12a9701c7c5cd78e8797e945486b441303b82543be71043a32d6a988b57a14237f93c6
 | 
			
		||||
  languageName: node
 | 
			
		||||
  linkType: hard
 | 
			
		||||
 | 
			
		||||
"@types/react-router-dom@npm:5.3.3":
 | 
			
		||||
  version: 5.3.3
 | 
			
		||||
  resolution: "@types/react-router-dom@npm:5.3.3"
 | 
			
		||||
@ -5597,6 +5606,13 @@ __metadata:
 | 
			
		||||
  languageName: node
 | 
			
		||||
  linkType: hard
 | 
			
		||||
 | 
			
		||||
"fast-equals@npm:^4.0.3":
 | 
			
		||||
  version: 4.0.3
 | 
			
		||||
  resolution: "fast-equals@npm:4.0.3"
 | 
			
		||||
  checksum: 10c0/87fd2609c945ee61e9ed4d041eb2a8f92723fc02884115f67e429dd858d880279e962334894f116b3e9b223f387d246e3db5424ae779287849015ddadbf5ff27
 | 
			
		||||
  languageName: node
 | 
			
		||||
  linkType: hard
 | 
			
		||||
 | 
			
		||||
"fast-glob@npm:^3.2.9":
 | 
			
		||||
  version: 3.3.2
 | 
			
		||||
  resolution: "fast-glob@npm:3.3.2"
 | 
			
		||||
@ -8479,7 +8495,7 @@ __metadata:
 | 
			
		||||
  languageName: node
 | 
			
		||||
  linkType: hard
 | 
			
		||||
 | 
			
		||||
"prop-types@npm:15.8.1, prop-types@npm:^15.0.0, prop-types@npm:^15.6.2, prop-types@npm:^15.8.1":
 | 
			
		||||
"prop-types@npm:15.8.1, prop-types@npm:15.x, prop-types@npm:^15.0.0, prop-types@npm:^15.6.2, prop-types@npm:^15.8.1":
 | 
			
		||||
  version: 15.8.1
 | 
			
		||||
  resolution: "prop-types@npm:15.8.1"
 | 
			
		||||
  dependencies:
 | 
			
		||||
@ -8616,6 +8632,19 @@ __metadata:
 | 
			
		||||
  languageName: node
 | 
			
		||||
  linkType: hard
 | 
			
		||||
 | 
			
		||||
"react-draggable@npm:^4.0.3, react-draggable@npm:^4.4.6":
 | 
			
		||||
  version: 4.5.0
 | 
			
		||||
  resolution: "react-draggable@npm:4.5.0"
 | 
			
		||||
  dependencies:
 | 
			
		||||
    clsx: "npm:^2.1.1"
 | 
			
		||||
    prop-types: "npm:^15.8.1"
 | 
			
		||||
  peerDependencies:
 | 
			
		||||
    react: ">= 16.3.0"
 | 
			
		||||
    react-dom: ">= 16.3.0"
 | 
			
		||||
  checksum: 10c0/6f7591fe450555218bf0d9e31984be02451bf3f678fb121f51ac0a0a645d01a1b5ea8248ef9afddcd24239028911fd88032194b9c00b30ad5ece76ea13397fc3
 | 
			
		||||
  languageName: node
 | 
			
		||||
  linkType: hard
 | 
			
		||||
 | 
			
		||||
"react-dropzone@npm:14.3.8":
 | 
			
		||||
  version: 14.3.8
 | 
			
		||||
  resolution: "react-dropzone@npm:14.3.8"
 | 
			
		||||
@ -8686,6 +8715,23 @@ __metadata:
 | 
			
		||||
  languageName: node
 | 
			
		||||
  linkType: hard
 | 
			
		||||
 | 
			
		||||
"react-grid-layout@npm:^1.5.2":
 | 
			
		||||
  version: 1.5.2
 | 
			
		||||
  resolution: "react-grid-layout@npm:1.5.2"
 | 
			
		||||
  dependencies:
 | 
			
		||||
    clsx: "npm:^2.1.1"
 | 
			
		||||
    fast-equals: "npm:^4.0.3"
 | 
			
		||||
    prop-types: "npm:^15.8.1"
 | 
			
		||||
    react-draggable: "npm:^4.4.6"
 | 
			
		||||
    react-resizable: "npm:^3.0.5"
 | 
			
		||||
    resize-observer-polyfill: "npm:^1.5.1"
 | 
			
		||||
  peerDependencies:
 | 
			
		||||
    react: ">= 16.3.0"
 | 
			
		||||
    react-dom: ">= 16.3.0"
 | 
			
		||||
  checksum: 10c0/b6605d1435fe116c3720d168100a5a08da924c6905686fe8a486c33b82abbde8ccacbb59e5c6243fa52f5e808ad393a7bdf0c09a3446ebf76efe43f29d9f13ee
 | 
			
		||||
  languageName: node
 | 
			
		||||
  linkType: hard
 | 
			
		||||
 | 
			
		||||
"react-hooks-global-state@npm:2.1.0":
 | 
			
		||||
  version: 2.1.0
 | 
			
		||||
  resolution: "react-hooks-global-state@npm:2.1.0"
 | 
			
		||||
@ -8790,6 +8836,18 @@ __metadata:
 | 
			
		||||
  languageName: node
 | 
			
		||||
  linkType: hard
 | 
			
		||||
 | 
			
		||||
"react-resizable@npm:^3.0.5":
 | 
			
		||||
  version: 3.0.5
 | 
			
		||||
  resolution: "react-resizable@npm:3.0.5"
 | 
			
		||||
  dependencies:
 | 
			
		||||
    prop-types: "npm:15.x"
 | 
			
		||||
    react-draggable: "npm:^4.0.3"
 | 
			
		||||
  peerDependencies:
 | 
			
		||||
    react: ">= 16.3"
 | 
			
		||||
  checksum: 10c0/cfe50aa6efb79e0aa09bd681a5beab2fcd1186737c4952eb4c3974ed9395d5d263ccd1130961d06b8f5e24c8f544dd2967b5c740ce68719962d1771de7bdb350
 | 
			
		||||
  languageName: node
 | 
			
		||||
  linkType: hard
 | 
			
		||||
 | 
			
		||||
"react-router-dom@npm:6.16.0":
 | 
			
		||||
  version: 6.16.0
 | 
			
		||||
  resolution: "react-router-dom@npm:6.16.0"
 | 
			
		||||
@ -8984,6 +9042,13 @@ __metadata:
 | 
			
		||||
  languageName: node
 | 
			
		||||
  linkType: hard
 | 
			
		||||
 | 
			
		||||
"resize-observer-polyfill@npm:^1.5.1":
 | 
			
		||||
  version: 1.5.1
 | 
			
		||||
  resolution: "resize-observer-polyfill@npm:1.5.1"
 | 
			
		||||
  checksum: 10c0/5e882475067f0b97dc07e0f37c3e335ac5bc3520d463f777cec7e894bb273eddbfecb857ae668e6fb6881fd6f6bb7148246967172139302da50fa12ea3a15d95
 | 
			
		||||
  languageName: node
 | 
			
		||||
  linkType: hard
 | 
			
		||||
 | 
			
		||||
"resolve-from@npm:^4.0.0":
 | 
			
		||||
  version: 4.0.0
 | 
			
		||||
  resolution: "resolve-from@npm:4.0.0"
 | 
			
		||||
@ -10376,6 +10441,7 @@ __metadata:
 | 
			
		||||
    "@types/node": "npm:^22.0.0"
 | 
			
		||||
    "@types/react": "npm:18.3.23"
 | 
			
		||||
    "@types/react-dom": "npm:18.3.7"
 | 
			
		||||
    "@types/react-grid-layout": "npm:^1.3.5"
 | 
			
		||||
    "@types/react-router-dom": "npm:5.3.3"
 | 
			
		||||
    "@types/react-table": "npm:7.7.20"
 | 
			
		||||
    "@types/react-test-renderer": "npm:18.3.1"
 | 
			
		||||
@ -10425,6 +10491,7 @@ __metadata:
 | 
			
		||||
    react-dropzone: "npm:14.3.8"
 | 
			
		||||
    react-error-boundary: "npm:3.1.4"
 | 
			
		||||
    react-github-calendar: "npm:^4.5.1"
 | 
			
		||||
    react-grid-layout: "npm:^1.5.2"
 | 
			
		||||
    react-hooks-global-state: "npm:2.1.0"
 | 
			
		||||
    react-joyride: "npm:^2.5.3"
 | 
			
		||||
    react-markdown: "npm:^8.0.4"
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user