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/node": "^22.0.0",
 | 
				
			||||||
    "@types/react": "18.3.23",
 | 
					    "@types/react": "18.3.23",
 | 
				
			||||||
    "@types/react-dom": "18.3.7",
 | 
					    "@types/react-dom": "18.3.7",
 | 
				
			||||||
 | 
					    "@types/react-grid-layout": "^1.3.5",
 | 
				
			||||||
    "@types/react-router-dom": "5.3.3",
 | 
					    "@types/react-router-dom": "5.3.3",
 | 
				
			||||||
    "@types/react-table": "7.7.20",
 | 
					    "@types/react-table": "7.7.20",
 | 
				
			||||||
    "@types/react-test-renderer": "18.3.1",
 | 
					    "@types/react-test-renderer": "18.3.1",
 | 
				
			||||||
@ -107,6 +108,7 @@
 | 
				
			|||||||
    "react-dropzone": "14.3.8",
 | 
					    "react-dropzone": "14.3.8",
 | 
				
			||||||
    "react-error-boundary": "3.1.4",
 | 
					    "react-error-boundary": "3.1.4",
 | 
				
			||||||
    "react-github-calendar": "^4.5.1",
 | 
					    "react-github-calendar": "^4.5.1",
 | 
				
			||||||
 | 
					    "react-grid-layout": "^1.5.2",
 | 
				
			||||||
    "react-hooks-global-state": "2.1.0",
 | 
					    "react-hooks-global-state": "2.1.0",
 | 
				
			||||||
    "react-joyride": "^2.5.3",
 | 
					    "react-joyride": "^2.5.3",
 | 
				
			||||||
    "react-markdown": "^8.0.4",
 | 
					    "react-markdown": "^8.0.4",
 | 
				
			||||||
 | 
				
			|||||||
@ -1,5 +1,4 @@
 | 
				
			|||||||
import type { FC } from 'react';
 | 
					import type { FC } from 'react';
 | 
				
			||||||
import { useState, useEffect, useMemo } from 'react';
 | 
					 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
    Dialog,
 | 
					    Dialog,
 | 
				
			||||||
    DialogTitle,
 | 
					    DialogTitle,
 | 
				
			||||||
@ -8,21 +7,11 @@ import {
 | 
				
			|||||||
    Button,
 | 
					    Button,
 | 
				
			||||||
    TextField,
 | 
					    TextField,
 | 
				
			||||||
    Box,
 | 
					    Box,
 | 
				
			||||||
    Typography,
 | 
					 | 
				
			||||||
    Alert,
 | 
					 | 
				
			||||||
    styled,
 | 
					    styled,
 | 
				
			||||||
} from '@mui/material';
 | 
					} from '@mui/material';
 | 
				
			||||||
import { ImpactMetricsControls } from './ImpactMetricsControls/ImpactMetricsControls.tsx';
 | 
					import { ImpactMetricsControls } from './ImpactMetricsControls/ImpactMetricsControls.tsx';
 | 
				
			||||||
import {
 | 
					import { ImpactMetricsChartPreview } from './ImpactMetricsChartPreview.tsx';
 | 
				
			||||||
    LineChart,
 | 
					import { useChartFormState } from './hooks/useChartFormState.ts';
 | 
				
			||||||
    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 type { ChartConfig } from './types.ts';
 | 
					import type { ChartConfig } from './types.ts';
 | 
				
			||||||
import type { ImpactMetricsSeries } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata';
 | 
					import type { ImpactMetricsSeries } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -68,120 +57,19 @@ export const ChartConfigModal: FC<ChartConfigModalProps> = ({
 | 
				
			|||||||
    metricSeries,
 | 
					    metricSeries,
 | 
				
			||||||
    loading = false,
 | 
					    loading = false,
 | 
				
			||||||
}) => {
 | 
					}) => {
 | 
				
			||||||
    const [title, setTitle] = useState(initialConfig?.title || '');
 | 
					    const { formData, actions, isValid, currentAvailableLabels } =
 | 
				
			||||||
    const [selectedSeries, setSelectedSeries] = useState(
 | 
					        useChartFormState({
 | 
				
			||||||
        initialConfig?.selectedSeries || '',
 | 
					            open,
 | 
				
			||||||
    );
 | 
					            initialConfig,
 | 
				
			||||||
    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 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 = () => {
 | 
					    const handleSave = () => {
 | 
				
			||||||
        if (!selectedSeries) return;
 | 
					        if (!isValid) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        onSave({
 | 
					        onSave(actions.getConfigToSave());
 | 
				
			||||||
            title: title || undefined,
 | 
					 | 
				
			||||||
            selectedSeries,
 | 
					 | 
				
			||||||
            selectedRange,
 | 
					 | 
				
			||||||
            beginAtZero,
 | 
					 | 
				
			||||||
            selectedLabels,
 | 
					 | 
				
			||||||
        });
 | 
					 | 
				
			||||||
        onClose();
 | 
					        onClose();
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const handleSeriesChange = (series: string) => {
 | 
					 | 
				
			||||||
        setSelectedSeries(series);
 | 
					 | 
				
			||||||
        setSelectedLabels({});
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const isValid = selectedSeries.length > 0;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
        <Dialog
 | 
					        <Dialog
 | 
				
			||||||
            open={open}
 | 
					            open={open}
 | 
				
			||||||
@ -211,121 +99,28 @@ export const ChartConfigModal: FC<ChartConfigModalProps> = ({
 | 
				
			|||||||
                    <StyledConfigPanel>
 | 
					                    <StyledConfigPanel>
 | 
				
			||||||
                        <TextField
 | 
					                        <TextField
 | 
				
			||||||
                            label='Chart Title (optional)'
 | 
					                            label='Chart Title (optional)'
 | 
				
			||||||
                            value={title}
 | 
					                            value={formData.title}
 | 
				
			||||||
                            onChange={(e) => setTitle(e.target.value)}
 | 
					                            onChange={(e) => actions.setTitle(e.target.value)}
 | 
				
			||||||
                            fullWidth
 | 
					                            fullWidth
 | 
				
			||||||
                            variant='outlined'
 | 
					                            variant='outlined'
 | 
				
			||||||
                            size='small'
 | 
					                            size='small'
 | 
				
			||||||
                        />
 | 
					                        />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                        <ImpactMetricsControls
 | 
					                        <ImpactMetricsControls
 | 
				
			||||||
                            selectedSeries={selectedSeries}
 | 
					                            formData={formData}
 | 
				
			||||||
                            onSeriesChange={handleSeriesChange}
 | 
					                            actions={actions}
 | 
				
			||||||
                            selectedRange={selectedRange}
 | 
					 | 
				
			||||||
                            onRangeChange={setSelectedRange}
 | 
					 | 
				
			||||||
                            beginAtZero={beginAtZero}
 | 
					 | 
				
			||||||
                            onBeginAtZeroChange={setBeginAtZero}
 | 
					 | 
				
			||||||
                            metricSeries={metricSeries}
 | 
					                            metricSeries={metricSeries}
 | 
				
			||||||
                            loading={loading}
 | 
					                            loading={loading}
 | 
				
			||||||
                            selectedLabels={selectedLabels}
 | 
					 | 
				
			||||||
                            onLabelsChange={setSelectedLabels}
 | 
					 | 
				
			||||||
                            availableLabels={currentAvailableLabels}
 | 
					                            availableLabels={currentAvailableLabels}
 | 
				
			||||||
                        />
 | 
					                        />
 | 
				
			||||||
                    </StyledConfigPanel>
 | 
					                    </StyledConfigPanel>
 | 
				
			||||||
 | 
					 | 
				
			||||||
                    {/* Preview Panel */}
 | 
					 | 
				
			||||||
                    <StyledPreviewPanel>
 | 
					                    <StyledPreviewPanel>
 | 
				
			||||||
                        <Typography variant='h6' color='text.secondary'>
 | 
					                        <ImpactMetricsChartPreview
 | 
				
			||||||
                            Preview
 | 
					                            selectedSeries={formData.selectedSeries}
 | 
				
			||||||
                        </Typography>
 | 
					                            selectedRange={formData.selectedRange}
 | 
				
			||||||
 | 
					                            selectedLabels={formData.selectedLabels}
 | 
				
			||||||
                        {!selectedSeries && !isLoading ? (
 | 
					                            beginAtZero={formData.beginAtZero}
 | 
				
			||||||
                            <Typography variant='body2' color='text.secondary'>
 | 
					 | 
				
			||||||
                                Select a metric series to view the preview
 | 
					 | 
				
			||||||
                            </Typography>
 | 
					 | 
				
			||||||
                        ) : null}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                        <StyledChartContainer>
 | 
					 | 
				
			||||||
                            {hasError ? (
 | 
					 | 
				
			||||||
                                <Alert severity='error'>
 | 
					 | 
				
			||||||
                                    Failed to load impact metrics. Please check
 | 
					 | 
				
			||||||
                                    if Prometheus is configured and the feature
 | 
					 | 
				
			||||||
                                    flag is enabled.
 | 
					 | 
				
			||||||
                                </Alert>
 | 
					 | 
				
			||||||
                            ) : null}
 | 
					 | 
				
			||||||
                            <LineChart
 | 
					 | 
				
			||||||
                                data={
 | 
					 | 
				
			||||||
                                    notEnoughData || isLoading
 | 
					 | 
				
			||||||
                                        ? placeholderData
 | 
					 | 
				
			||||||
                                        : data
 | 
					 | 
				
			||||||
                                }
 | 
					 | 
				
			||||||
                                overrideOptions={
 | 
					 | 
				
			||||||
                                    shouldShowPlaceholder
 | 
					 | 
				
			||||||
                                        ? {}
 | 
					 | 
				
			||||||
                                        : {
 | 
					 | 
				
			||||||
                                              scales: {
 | 
					 | 
				
			||||||
                                                  x: {
 | 
					 | 
				
			||||||
                                                      type: 'time',
 | 
					 | 
				
			||||||
                                                      min: minTime?.getTime(),
 | 
					 | 
				
			||||||
                                                      max: maxTime?.getTime(),
 | 
					 | 
				
			||||||
                                                      time: {
 | 
					 | 
				
			||||||
                                                          unit: getTimeUnit(
 | 
					 | 
				
			||||||
                                                              selectedRange,
 | 
					 | 
				
			||||||
                                                          ),
 | 
					 | 
				
			||||||
                                                          displayFormats: {
 | 
					 | 
				
			||||||
                                                              [getTimeUnit(
 | 
					 | 
				
			||||||
                                                                  selectedRange,
 | 
					 | 
				
			||||||
                                                              )]:
 | 
					 | 
				
			||||||
                                                                  getDisplayFormat(
 | 
					 | 
				
			||||||
                                                                      selectedRange,
 | 
					 | 
				
			||||||
                                                                  ),
 | 
					 | 
				
			||||||
                                                          },
 | 
					 | 
				
			||||||
                                                          tooltipFormat: 'PPpp',
 | 
					 | 
				
			||||||
                                                      },
 | 
					 | 
				
			||||||
                                                  },
 | 
					 | 
				
			||||||
                                                  y: {
 | 
					 | 
				
			||||||
                                                      beginAtZero,
 | 
					 | 
				
			||||||
                                                      title: {
 | 
					 | 
				
			||||||
                                                          display: false,
 | 
					 | 
				
			||||||
                                                      },
 | 
					 | 
				
			||||||
                                                      ticks: {
 | 
					 | 
				
			||||||
                                                          precision: 0,
 | 
					 | 
				
			||||||
                                                          callback: (
 | 
					 | 
				
			||||||
                                                              value: unknown,
 | 
					 | 
				
			||||||
                                                          ): string | number =>
 | 
					 | 
				
			||||||
                                                              typeof value ===
 | 
					 | 
				
			||||||
                                                              'number'
 | 
					 | 
				
			||||||
                                                                  ? formatLargeNumbers(
 | 
					 | 
				
			||||||
                                                                        value,
 | 
					 | 
				
			||||||
                                                                    )
 | 
					 | 
				
			||||||
                                                                  : (value as number),
 | 
					 | 
				
			||||||
                                                      },
 | 
					 | 
				
			||||||
                                                  },
 | 
					 | 
				
			||||||
                                              },
 | 
					 | 
				
			||||||
                                              plugins: {
 | 
					 | 
				
			||||||
                                                  legend: {
 | 
					 | 
				
			||||||
                                                      display:
 | 
					 | 
				
			||||||
                                                          timeSeriesData &&
 | 
					 | 
				
			||||||
                                                          timeSeriesData.length >
 | 
					 | 
				
			||||||
                                                              1,
 | 
					 | 
				
			||||||
                                                      position:
 | 
					 | 
				
			||||||
                                                          'bottom' as const,
 | 
					 | 
				
			||||||
                                                      labels: {
 | 
					 | 
				
			||||||
                                                          usePointStyle: true,
 | 
					 | 
				
			||||||
                                                          boxWidth: 8,
 | 
					 | 
				
			||||||
                                                          padding: 12,
 | 
					 | 
				
			||||||
                                                      },
 | 
					 | 
				
			||||||
                                                  },
 | 
					 | 
				
			||||||
                                              },
 | 
					 | 
				
			||||||
                                              animations: {
 | 
					 | 
				
			||||||
                                                  x: { duration: 0 },
 | 
					 | 
				
			||||||
                                                  y: { duration: 0 },
 | 
					 | 
				
			||||||
                                              },
 | 
					 | 
				
			||||||
                                          }
 | 
					 | 
				
			||||||
                                }
 | 
					 | 
				
			||||||
                                cover={cover}
 | 
					 | 
				
			||||||
                        />
 | 
					                        />
 | 
				
			||||||
                        </StyledChartContainer>
 | 
					 | 
				
			||||||
                    </StyledPreviewPanel>
 | 
					                    </StyledPreviewPanel>
 | 
				
			||||||
                </Box>
 | 
					                </Box>
 | 
				
			||||||
            </DialogContent>
 | 
					            </DialogContent>
 | 
				
			||||||
 | 
				
			|||||||
@ -1,25 +1,9 @@
 | 
				
			|||||||
import type { FC } from 'react';
 | 
					import type { FC } from 'react';
 | 
				
			||||||
import { useMemo } from 'react';
 | 
					import { Box, Typography, IconButton, styled, Paper } from '@mui/material';
 | 
				
			||||||
import {
 | 
					 | 
				
			||||||
    Box,
 | 
					 | 
				
			||||||
    Typography,
 | 
					 | 
				
			||||||
    IconButton,
 | 
					 | 
				
			||||||
    Alert,
 | 
					 | 
				
			||||||
    styled,
 | 
					 | 
				
			||||||
    Paper,
 | 
					 | 
				
			||||||
} from '@mui/material';
 | 
					 | 
				
			||||||
import Edit from '@mui/icons-material/Edit';
 | 
					import Edit from '@mui/icons-material/Edit';
 | 
				
			||||||
import Delete from '@mui/icons-material/Delete';
 | 
					import Delete from '@mui/icons-material/Delete';
 | 
				
			||||||
import {
 | 
					import DragHandle from '@mui/icons-material/DragHandle';
 | 
				
			||||||
    LineChart,
 | 
					import { ImpactMetricsChart } from './ImpactMetricsChart.tsx';
 | 
				
			||||||
    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 type { ChartConfig } from './types.ts';
 | 
					import type { ChartConfig } from './types.ts';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export interface ChartItemProps {
 | 
					export interface ChartItemProps {
 | 
				
			||||||
@ -32,180 +16,130 @@ const getConfigDescription = (config: ChartConfig): string => {
 | 
				
			|||||||
    const parts: string[] = [];
 | 
					    const parts: string[] = [];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (config.selectedSeries) {
 | 
					    if (config.selectedSeries) {
 | 
				
			||||||
        parts.push(`Series: ${config.selectedSeries}`);
 | 
					        parts.push(`${config.selectedSeries}`);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    parts.push(`Time range: last ${config.selectedRange}`);
 | 
					    parts.push(`last ${config.selectedRange}`);
 | 
				
			||||||
 | 
					 | 
				
			||||||
    if (config.beginAtZero) {
 | 
					 | 
				
			||||||
        parts.push('Begin at zero');
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const labelCount = Object.keys(config.selectedLabels).length;
 | 
					    const labelCount = Object.keys(config.selectedLabels).length;
 | 
				
			||||||
    if (labelCount > 0) {
 | 
					    if (labelCount > 0) {
 | 
				
			||||||
        parts.push(`${labelCount} label filter${labelCount > 1 ? 's' : ''}`);
 | 
					        parts.push(`${labelCount} filter${labelCount > 1 ? 's' : ''}`);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return parts.join(' • ');
 | 
					    return parts.join(' • ');
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const StyledHeader = styled(Typography)(({ theme }) => ({
 | 
					const StyledChartWrapper = styled(Box)({
 | 
				
			||||||
    display: 'flex',
 | 
					    height: '100%',
 | 
				
			||||||
    justifyContent: 'space-between',
 | 
					    width: '100%',
 | 
				
			||||||
    alignItems: 'center',
 | 
					    '& > div': {
 | 
				
			||||||
    padding: theme.spacing(2, 3),
 | 
					        height: '100% !important',
 | 
				
			||||||
}));
 | 
					        width: '100% !important',
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const StyledWidget = styled(Paper)(({ theme }) => ({
 | 
					const StyledWidget = styled(Paper)(({ theme }) => ({
 | 
				
			||||||
    borderRadius: `${theme.shape.borderRadiusLarge}px`,
 | 
					    borderRadius: `${theme.shape.borderRadiusMedium}px`,
 | 
				
			||||||
    boxShadow: 'none',
 | 
					    boxShadow: 'none',
 | 
				
			||||||
    display: 'flex',
 | 
					    display: 'flex',
 | 
				
			||||||
    flexDirection: 'column',
 | 
					    flexDirection: 'column',
 | 
				
			||||||
 | 
					    height: '100%',
 | 
				
			||||||
 | 
					    overflow: 'hidden',
 | 
				
			||||||
}));
 | 
					}));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const ChartItem: FC<ChartItemProps> = ({ config, onEdit, onDelete }) => {
 | 
					const StyledChartContent = styled(Box)({
 | 
				
			||||||
    const {
 | 
					    flex: 1,
 | 
				
			||||||
        data: { start, end, series: timeSeriesData },
 | 
					    display: 'flex',
 | 
				
			||||||
        loading: dataLoading,
 | 
					    flexDirection: 'column',
 | 
				
			||||||
        error: dataError,
 | 
					    minHeight: 0,
 | 
				
			||||||
    } = useImpactMetricsData({
 | 
					 | 
				
			||||||
        series: config.selectedSeries,
 | 
					 | 
				
			||||||
        range: config.selectedRange,
 | 
					 | 
				
			||||||
        labels:
 | 
					 | 
				
			||||||
            Object.keys(config.selectedLabels).length > 0
 | 
					 | 
				
			||||||
                ? config.selectedLabels
 | 
					 | 
				
			||||||
                : undefined,
 | 
					 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const placeholderData = usePlaceholderData({
 | 
					const StyledImpactChartContainer = styled(Box)(({ theme }) => ({
 | 
				
			||||||
        fill: true,
 | 
					    position: 'relative',
 | 
				
			||||||
        type: 'constant',
 | 
					    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 StyledDragHandle = styled(Box)(({ theme }) => ({
 | 
				
			||||||
    const isLoading = dataLoading;
 | 
					    display: 'flex',
 | 
				
			||||||
    const shouldShowPlaceholder = isLoading || hasError;
 | 
					    alignItems: 'center',
 | 
				
			||||||
    const notEnoughData = useMemo(
 | 
					    cursor: 'move',
 | 
				
			||||||
        () =>
 | 
					    padding: theme.spacing(0.5),
 | 
				
			||||||
            !isLoading &&
 | 
					    borderRadius: theme.shape.borderRadius,
 | 
				
			||||||
            (!timeSeriesData ||
 | 
					    color: theme.palette.text.secondary,
 | 
				
			||||||
                timeSeriesData.length === 0 ||
 | 
					    '&:hover': {
 | 
				
			||||||
                !data.datasets.some((d) => d.data.length > 1)),
 | 
					        backgroundColor: theme.palette.action.hover,
 | 
				
			||||||
        [data, isLoading, timeSeriesData],
 | 
					        color: theme.palette.text.primary,
 | 
				
			||||||
    );
 | 
					    },
 | 
				
			||||||
 | 
					}));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const minTime = start
 | 
					const StyledChartTitle = styled(Box)(({ theme }) => ({
 | 
				
			||||||
        ? fromUnixTime(Number.parseInt(start, 10))
 | 
					    display: 'flex',
 | 
				
			||||||
        : undefined;
 | 
					    flexDirection: 'column',
 | 
				
			||||||
    const maxTime = end ? fromUnixTime(Number.parseInt(end, 10)) : undefined;
 | 
					    justifyContent: 'flex-end',
 | 
				
			||||||
 | 
					    flexGrow: 1,
 | 
				
			||||||
 | 
					    overflow: 'hidden',
 | 
				
			||||||
 | 
					    textOverflow: 'ellipsis',
 | 
				
			||||||
 | 
					}));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const placeholder = (
 | 
					const StyledChartActions = styled(Box)(({ theme }) => ({
 | 
				
			||||||
        <NotEnoughData description='Send impact metrics using Unleash SDK for this series to view the chart.' />
 | 
					    marginLeft: 'auto',
 | 
				
			||||||
    );
 | 
					    display: 'flex',
 | 
				
			||||||
    const cover = notEnoughData ? placeholder : isLoading;
 | 
					    alignItems: 'center',
 | 
				
			||||||
 | 
					    gap: theme.spacing(0.5),
 | 
				
			||||||
 | 
					}));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return (
 | 
					export const ChartItem: FC<ChartItemProps> = ({ config, onEdit, onDelete }) => (
 | 
				
			||||||
    <StyledWidget>
 | 
					    <StyledWidget>
 | 
				
			||||||
        <StyledHeader>
 | 
					        <StyledHeader>
 | 
				
			||||||
                <Box>
 | 
					            <StyledDragHandle className='grid-item-drag-handle'>
 | 
				
			||||||
 | 
					                <DragHandle fontSize='small' />
 | 
				
			||||||
 | 
					            </StyledDragHandle>
 | 
				
			||||||
 | 
					            <StyledChartTitle>
 | 
				
			||||||
                {config.title && (
 | 
					                {config.title && (
 | 
				
			||||||
                        <Typography variant='h6' gutterBottom>
 | 
					                    <Typography variant='h6'>{config.title}</Typography>
 | 
				
			||||||
                            {config.title}
 | 
					 | 
				
			||||||
                        </Typography>
 | 
					 | 
				
			||||||
                )}
 | 
					                )}
 | 
				
			||||||
                    <Typography
 | 
					                <Typography variant='body2' color='text.secondary'>
 | 
				
			||||||
                        variant='body2'
 | 
					 | 
				
			||||||
                        color='text.secondary'
 | 
					 | 
				
			||||||
                        sx={{ mb: 1 }}
 | 
					 | 
				
			||||||
                    >
 | 
					 | 
				
			||||||
                    {getConfigDescription(config)}
 | 
					                    {getConfigDescription(config)}
 | 
				
			||||||
                </Typography>
 | 
					                </Typography>
 | 
				
			||||||
                </Box>
 | 
					            </StyledChartTitle>
 | 
				
			||||||
                <Box>
 | 
					            <StyledChartActions>
 | 
				
			||||||
                    <IconButton onClick={() => onEdit(config)} sx={{ mr: 1 }}>
 | 
					                <IconButton onClick={() => onEdit(config)}>
 | 
				
			||||||
                    <Edit />
 | 
					                    <Edit />
 | 
				
			||||||
                </IconButton>
 | 
					                </IconButton>
 | 
				
			||||||
                <IconButton onClick={() => onDelete(config.id)}>
 | 
					                <IconButton onClick={() => onDelete(config.id)}>
 | 
				
			||||||
                    <Delete />
 | 
					                    <Delete />
 | 
				
			||||||
                </IconButton>
 | 
					                </IconButton>
 | 
				
			||||||
                </Box>
 | 
					            </StyledChartActions>
 | 
				
			||||||
        </StyledHeader>
 | 
					        </StyledHeader>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            <StyledChartContainer>
 | 
					        <StyledChartContent>
 | 
				
			||||||
                {hasError ? (
 | 
					            <StyledImpactChartContainer>
 | 
				
			||||||
                    <Alert severity='error'>
 | 
					                <StyledChartWrapper>
 | 
				
			||||||
                        Failed to load impact metrics. Please check if
 | 
					                    <ImpactMetricsChart
 | 
				
			||||||
                        Prometheus is configured and the feature flag is
 | 
					                        selectedSeries={config.selectedSeries}
 | 
				
			||||||
                        enabled.
 | 
					                        selectedRange={config.selectedRange}
 | 
				
			||||||
                    </Alert>
 | 
					                        selectedLabels={config.selectedLabels}
 | 
				
			||||||
                ) : null}
 | 
					                        beginAtZero={config.beginAtZero}
 | 
				
			||||||
                <LineChart
 | 
					                        aspectRatio={1.5}
 | 
				
			||||||
                    data={notEnoughData || isLoading ? placeholderData : data}
 | 
					                        overrideOptions={{ maintainAspectRatio: false }}
 | 
				
			||||||
                    overrideOptions={
 | 
					                        emptyDataDescription='Send impact metrics using Unleash SDK for this series to view the chart.'
 | 
				
			||||||
                        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}
 | 
					 | 
				
			||||||
                    />
 | 
					                    />
 | 
				
			||||||
            </StyledChartContainer>
 | 
					                </StyledChartWrapper>
 | 
				
			||||||
 | 
					            </StyledImpactChartContainer>
 | 
				
			||||||
 | 
					        </StyledChartContent>
 | 
				
			||||||
    </StyledWidget>
 | 
					    </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 type { FC } from 'react';
 | 
				
			||||||
import { useMemo, useState } from 'react';
 | 
					import { useMemo, useState, useCallback } from 'react';
 | 
				
			||||||
import { Box, Typography, Button } from '@mui/material';
 | 
					import { Typography, Button, Paper, styled } from '@mui/material';
 | 
				
			||||||
import Add from '@mui/icons-material/Add';
 | 
					import Add from '@mui/icons-material/Add';
 | 
				
			||||||
import { PageHeader } from 'component/common/PageHeader/PageHeader.tsx';
 | 
					import { PageHeader } from 'component/common/PageHeader/PageHeader.tsx';
 | 
				
			||||||
import { useImpactMetricsMetadata } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata';
 | 
					import { useImpactMetricsMetadata } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata';
 | 
				
			||||||
import { ChartConfigModal } from './ChartConfigModal.tsx';
 | 
					import { ChartConfigModal } from './ChartConfigModal.tsx';
 | 
				
			||||||
import { ChartItem } from './ChartItem.tsx';
 | 
					import { ChartItem } from './ChartItem.tsx';
 | 
				
			||||||
import { useUrlState } from './hooks/useUrlState.ts';
 | 
					import { GridLayoutWrapper, type GridItem } from './GridLayoutWrapper.tsx';
 | 
				
			||||||
import type { ChartConfig } from './types.ts';
 | 
					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 = () => {
 | 
					export const ImpactMetrics: FC = () => {
 | 
				
			||||||
    const [modalOpen, setModalOpen] = useState(false);
 | 
					    const [modalOpen, setModalOpen] = useState(false);
 | 
				
			||||||
    const [editingChart, setEditingChart] = useState<ChartConfig | undefined>();
 | 
					    const [editingChart, setEditingChart] = useState<ChartConfig | undefined>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const { charts, addChart, updateChart, deleteChart } = useUrlState();
 | 
					    const { charts, layout, addChart, updateChart, deleteChart, updateLayout } =
 | 
				
			||||||
 | 
					        useImpactMetricsState();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const {
 | 
					    const {
 | 
				
			||||||
        metadata,
 | 
					        metadata,
 | 
				
			||||||
@ -50,6 +60,41 @@ export const ImpactMetrics: FC = () => {
 | 
				
			|||||||
        setModalOpen(false);
 | 
					        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;
 | 
					    const hasError = metadataError;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
@ -62,7 +107,6 @@ export const ImpactMetrics: FC = () => {
 | 
				
			|||||||
                    </Typography>
 | 
					                    </Typography>
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
                actions={
 | 
					                actions={
 | 
				
			||||||
                    charts.length > 0 ? (
 | 
					 | 
				
			||||||
                    <Button
 | 
					                    <Button
 | 
				
			||||||
                        variant='contained'
 | 
					                        variant='contained'
 | 
				
			||||||
                        startIcon={<Add />}
 | 
					                        startIcon={<Add />}
 | 
				
			||||||
@ -71,24 +115,11 @@ export const ImpactMetrics: FC = () => {
 | 
				
			|||||||
                    >
 | 
					                    >
 | 
				
			||||||
                        Add Chart
 | 
					                        Add Chart
 | 
				
			||||||
                    </Button>
 | 
					                    </Button>
 | 
				
			||||||
                    ) : null
 | 
					 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
            />
 | 
					            />
 | 
				
			||||||
            <Box
 | 
					
 | 
				
			||||||
                sx={(theme) => ({
 | 
					 | 
				
			||||||
                    display: 'flex',
 | 
					 | 
				
			||||||
                    flexDirection: 'column',
 | 
					 | 
				
			||||||
                    gap: theme.spacing(2),
 | 
					 | 
				
			||||||
                    width: '100%',
 | 
					 | 
				
			||||||
                })}
 | 
					 | 
				
			||||||
            >
 | 
					 | 
				
			||||||
            {charts.length === 0 && !metadataLoading && !hasError ? (
 | 
					            {charts.length === 0 && !metadataLoading && !hasError ? (
 | 
				
			||||||
                    <Box
 | 
					                <StyledEmptyState>
 | 
				
			||||||
                        sx={(theme) => ({
 | 
					 | 
				
			||||||
                            textAlign: 'center',
 | 
					 | 
				
			||||||
                            py: theme.spacing(8),
 | 
					 | 
				
			||||||
                        })}
 | 
					 | 
				
			||||||
                    >
 | 
					 | 
				
			||||||
                    <Typography variant='h6' gutterBottom>
 | 
					                    <Typography variant='h6' gutterBottom>
 | 
				
			||||||
                        No charts configured
 | 
					                        No charts configured
 | 
				
			||||||
                    </Typography>
 | 
					                    </Typography>
 | 
				
			||||||
@ -97,8 +128,8 @@ export const ImpactMetrics: FC = () => {
 | 
				
			|||||||
                        color='text.secondary'
 | 
					                        color='text.secondary'
 | 
				
			||||||
                        sx={{ mb: 3 }}
 | 
					                        sx={{ mb: 3 }}
 | 
				
			||||||
                    >
 | 
					                    >
 | 
				
			||||||
                            Add your first impact metrics chart to start
 | 
					                        Add your first impact metrics chart to start tracking
 | 
				
			||||||
                            tracking performance
 | 
					                        performance with a beautiful drag-and-drop grid layout
 | 
				
			||||||
                    </Typography>
 | 
					                    </Typography>
 | 
				
			||||||
                    <Button
 | 
					                    <Button
 | 
				
			||||||
                        variant='contained'
 | 
					                        variant='contained'
 | 
				
			||||||
@ -108,17 +139,16 @@ export const ImpactMetrics: FC = () => {
 | 
				
			|||||||
                    >
 | 
					                    >
 | 
				
			||||||
                        Add Chart
 | 
					                        Add Chart
 | 
				
			||||||
                    </Button>
 | 
					                    </Button>
 | 
				
			||||||
                    </Box>
 | 
					                </StyledEmptyState>
 | 
				
			||||||
                ) : (
 | 
					            ) : charts.length > 0 ? (
 | 
				
			||||||
                    charts.map((config) => (
 | 
					                <GridLayoutWrapper
 | 
				
			||||||
                        <ChartItem
 | 
					                    items={gridItems}
 | 
				
			||||||
                            key={config.id}
 | 
					                    onLayoutChange={handleLayoutChange}
 | 
				
			||||||
                            config={config}
 | 
					                    rowHeight={180}
 | 
				
			||||||
                            onEdit={handleEditChart}
 | 
					                    margin={[16, 16]}
 | 
				
			||||||
                            onDelete={deleteChart}
 | 
					                    cols={{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }}
 | 
				
			||||||
                />
 | 
					                />
 | 
				
			||||||
                    ))
 | 
					            ) : null}
 | 
				
			||||||
                )}
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
            <ChartConfigModal
 | 
					            <ChartConfigModal
 | 
				
			||||||
                open={modalOpen}
 | 
					                open={modalOpen}
 | 
				
			||||||
@ -128,7 +158,6 @@ export const ImpactMetrics: FC = () => {
 | 
				
			|||||||
                metricSeries={metricSeries}
 | 
					                metricSeries={metricSeries}
 | 
				
			||||||
                loading={metadataLoading}
 | 
					                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 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 { ImpactMetricsSeries } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata';
 | 
				
			||||||
import type { ImpactMetricsLabels } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData';
 | 
					import type { ImpactMetricsLabels } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData';
 | 
				
			||||||
import { SeriesSelector } from './components/SeriesSelector.tsx';
 | 
					import { SeriesSelector } from './components/SeriesSelector.tsx';
 | 
				
			||||||
import { RangeSelector, type TimeRange } from './components/RangeSelector.tsx';
 | 
					import { RangeSelector } from './components/RangeSelector.tsx';
 | 
				
			||||||
import { BeginAtZeroToggle } from './components/BeginAtZeroToggle.tsx';
 | 
					 | 
				
			||||||
import { LabelsFilter } from './components/LabelsFilter.tsx';
 | 
					import { LabelsFilter } from './components/LabelsFilter.tsx';
 | 
				
			||||||
 | 
					import type { ChartFormState } from '../hooks/useChartFormState.ts';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export type ImpactMetricsControlsProps = {
 | 
					export type ImpactMetricsControlsProps = {
 | 
				
			||||||
    selectedSeries: string;
 | 
					    formData: ChartFormState['formData'];
 | 
				
			||||||
    onSeriesChange: (series: string) => void;
 | 
					    actions: Pick<
 | 
				
			||||||
    selectedRange: TimeRange;
 | 
					        ChartFormState['actions'],
 | 
				
			||||||
    onRangeChange: (range: TimeRange) => void;
 | 
					        | 'handleSeriesChange'
 | 
				
			||||||
    beginAtZero: boolean;
 | 
					        | 'setSelectedRange'
 | 
				
			||||||
    onBeginAtZeroChange: (beginAtZero: boolean) => void;
 | 
					        | 'setBeginAtZero'
 | 
				
			||||||
 | 
					        | 'setSelectedLabels'
 | 
				
			||||||
 | 
					    >;
 | 
				
			||||||
    metricSeries: (ImpactMetricsSeries & { name: string })[];
 | 
					    metricSeries: (ImpactMetricsSeries & { name: string })[];
 | 
				
			||||||
    loading?: boolean;
 | 
					    loading?: boolean;
 | 
				
			||||||
    selectedLabels: Record<string, string[]>;
 | 
					 | 
				
			||||||
    onLabelsChange: (labels: Record<string, string[]>) => void;
 | 
					 | 
				
			||||||
    availableLabels?: ImpactMetricsLabels;
 | 
					    availableLabels?: ImpactMetricsLabels;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const ImpactMetricsControls: FC<ImpactMetricsControlsProps> = (
 | 
					export const ImpactMetricsControls: FC<ImpactMetricsControlsProps> = ({
 | 
				
			||||||
    props,
 | 
					    formData,
 | 
				
			||||||
) => (
 | 
					    actions,
 | 
				
			||||||
 | 
					    metricSeries,
 | 
				
			||||||
 | 
					    loading,
 | 
				
			||||||
 | 
					    availableLabels,
 | 
				
			||||||
 | 
					}) => (
 | 
				
			||||||
    <Box
 | 
					    <Box
 | 
				
			||||||
        sx={(theme) => ({
 | 
					        sx={(theme) => ({
 | 
				
			||||||
            display: 'flex',
 | 
					            display: 'flex',
 | 
				
			||||||
@ -39,27 +43,32 @@ export const ImpactMetricsControls: FC<ImpactMetricsControlsProps> = (
 | 
				
			|||||||
        </Typography>
 | 
					        </Typography>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        <SeriesSelector
 | 
					        <SeriesSelector
 | 
				
			||||||
            value={props.selectedSeries}
 | 
					            value={formData.selectedSeries}
 | 
				
			||||||
            onChange={props.onSeriesChange}
 | 
					            onChange={actions.handleSeriesChange}
 | 
				
			||||||
            options={props.metricSeries}
 | 
					            options={metricSeries}
 | 
				
			||||||
            loading={props.loading}
 | 
					            loading={loading}
 | 
				
			||||||
        />
 | 
					        />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        <RangeSelector
 | 
					        <RangeSelector
 | 
				
			||||||
            value={props.selectedRange}
 | 
					            value={formData.selectedRange}
 | 
				
			||||||
            onChange={props.onRangeChange}
 | 
					            onChange={actions.setSelectedRange}
 | 
				
			||||||
        />
 | 
					        />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        <BeginAtZeroToggle
 | 
					        <FormControlLabel
 | 
				
			||||||
            value={props.beginAtZero}
 | 
					            control={
 | 
				
			||||||
            onChange={props.onBeginAtZeroChange}
 | 
					                <Checkbox
 | 
				
			||||||
 | 
					                    checked={formData.beginAtZero}
 | 
				
			||||||
 | 
					                    onChange={(e) => actions.setBeginAtZero(e.target.checked)}
 | 
				
			||||||
 | 
					                />
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            label='Begin at zero'
 | 
				
			||||||
        />
 | 
					        />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        {props.availableLabels && (
 | 
					        {availableLabels && (
 | 
				
			||||||
            <LabelsFilter
 | 
					            <LabelsFilter
 | 
				
			||||||
                selectedLabels={props.selectedLabels}
 | 
					                selectedLabels={formData.selectedLabels}
 | 
				
			||||||
                onChange={props.onLabelsChange}
 | 
					                onChange={actions.setSelectedLabels}
 | 
				
			||||||
                availableLabels={props.availableLabels}
 | 
					                availableLabels={availableLabels}
 | 
				
			||||||
            />
 | 
					            />
 | 
				
			||||||
        )}
 | 
					        )}
 | 
				
			||||||
    </Box>
 | 
					    </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 || '')}
 | 
					        onChange={(_, newValue) => onChange(newValue?.name || '')}
 | 
				
			||||||
        disabled={loading}
 | 
					        disabled={loading}
 | 
				
			||||||
        renderOption={(props, option, { inputValue }) => (
 | 
					        renderOption={(props, option, { inputValue }) => (
 | 
				
			||||||
            <Box component='li' {...props}>
 | 
					            <Box component='li' {...props} key={option.name}>
 | 
				
			||||||
                <Box sx={{ display: 'flex', flexDirection: 'column' }}>
 | 
					                <Box sx={{ display: 'flex', flexDirection: 'column' }}>
 | 
				
			||||||
                    <Typography variant='body2'>
 | 
					                    <Typography variant='body2'>
 | 
				
			||||||
                        <Highlighter search={inputValue}>
 | 
					                        <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;
 | 
					    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 = {
 | 
					export type ImpactMetricsState = {
 | 
				
			||||||
    charts: ChartConfig[];
 | 
					    charts: ChartConfig[];
 | 
				
			||||||
 | 
					    layout: LayoutItem[];
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
				
			|||||||
@ -117,7 +117,7 @@ const LineChartComponent: FC<{
 | 
				
			|||||||
                ),
 | 
					                ),
 | 
				
			||||||
                overrideOptions ?? {},
 | 
					                overrideOptions ?? {},
 | 
				
			||||||
            ]),
 | 
					            ]),
 | 
				
			||||||
        [theme, locationSettings, overrideOptions, cover],
 | 
					        [theme, locationSettings, setTooltip, overrideOptions, cover],
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
 | 
				
			|||||||
@ -136,7 +136,14 @@ exports[`returns all baseRoutes 1`] = `
 | 
				
			|||||||
    "type": "protected",
 | 
					    "type": "protected",
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  {
 | 
					  {
 | 
				
			||||||
    "component": [Function],
 | 
					    "component": {
 | 
				
			||||||
 | 
					      "$$typeof": Symbol(react.lazy),
 | 
				
			||||||
 | 
					      "_init": [Function],
 | 
				
			||||||
 | 
					      "_payload": {
 | 
				
			||||||
 | 
					        "_result": [Function],
 | 
				
			||||||
 | 
					        "_status": -1,
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
    "enterprise": true,
 | 
					    "enterprise": true,
 | 
				
			||||||
    "flag": "impactMetrics",
 | 
					    "flag": "impactMetrics",
 | 
				
			||||||
    "menu": {
 | 
					    "menu": {
 | 
				
			||||||
 | 
				
			|||||||
@ -42,7 +42,7 @@ import { ViewIntegration } from 'component/integrations/ViewIntegration/ViewInte
 | 
				
			|||||||
import { PaginatedApplicationList } from '../application/ApplicationList/PaginatedApplicationList.jsx';
 | 
					import { PaginatedApplicationList } from '../application/ApplicationList/PaginatedApplicationList.jsx';
 | 
				
			||||||
import { AddonRedirect } from 'component/integrations/AddonRedirect/AddonRedirect';
 | 
					import { AddonRedirect } from 'component/integrations/AddonRedirect/AddonRedirect';
 | 
				
			||||||
import { Insights } from '../insights/Insights.jsx';
 | 
					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 { FeedbackList } from '../feedbackNew/FeedbackList.jsx';
 | 
				
			||||||
import { Application } from 'component/application/Application';
 | 
					import { Application } from 'component/application/Application';
 | 
				
			||||||
import { Signals } from 'component/signals/Signals';
 | 
					import { Signals } from 'component/signals/Signals';
 | 
				
			||||||
@ -164,7 +164,7 @@ export const routes: IRoute[] = [
 | 
				
			|||||||
    {
 | 
					    {
 | 
				
			||||||
        path: '/impact-metrics',
 | 
					        path: '/impact-metrics',
 | 
				
			||||||
        title: 'Impact metrics',
 | 
					        title: 'Impact metrics',
 | 
				
			||||||
        component: ImpactMetricsPage,
 | 
					        component: LazyImpactMetricsPage,
 | 
				
			||||||
        type: 'protected',
 | 
					        type: 'protected',
 | 
				
			||||||
        menu: { primary: true },
 | 
					        menu: { primary: true },
 | 
				
			||||||
        enterprise: true,
 | 
					        enterprise: true,
 | 
				
			||||||
 | 
				
			|||||||
@ -162,3 +162,7 @@ input.hide-clear[type="search"]::-webkit-search-results-decoration {
 | 
				
			|||||||
.jse-message.jse-error {
 | 
					.jse-message.jse-error {
 | 
				
			||||||
    display: none !important;
 | 
					    display: none !important;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.react-grid-item.react-grid-placeholder {
 | 
				
			||||||
 | 
					    background: #6c65e5 !important;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -3256,6 +3256,15 @@ __metadata:
 | 
				
			|||||||
  languageName: node
 | 
					  languageName: node
 | 
				
			||||||
  linkType: hard
 | 
					  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":
 | 
					"@types/react-router-dom@npm:5.3.3":
 | 
				
			||||||
  version: 5.3.3
 | 
					  version: 5.3.3
 | 
				
			||||||
  resolution: "@types/react-router-dom@npm:5.3.3"
 | 
					  resolution: "@types/react-router-dom@npm:5.3.3"
 | 
				
			||||||
@ -5597,6 +5606,13 @@ __metadata:
 | 
				
			|||||||
  languageName: node
 | 
					  languageName: node
 | 
				
			||||||
  linkType: hard
 | 
					  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":
 | 
					"fast-glob@npm:^3.2.9":
 | 
				
			||||||
  version: 3.3.2
 | 
					  version: 3.3.2
 | 
				
			||||||
  resolution: "fast-glob@npm:3.3.2"
 | 
					  resolution: "fast-glob@npm:3.3.2"
 | 
				
			||||||
@ -8479,7 +8495,7 @@ __metadata:
 | 
				
			|||||||
  languageName: node
 | 
					  languageName: node
 | 
				
			||||||
  linkType: hard
 | 
					  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
 | 
					  version: 15.8.1
 | 
				
			||||||
  resolution: "prop-types@npm:15.8.1"
 | 
					  resolution: "prop-types@npm:15.8.1"
 | 
				
			||||||
  dependencies:
 | 
					  dependencies:
 | 
				
			||||||
@ -8616,6 +8632,19 @@ __metadata:
 | 
				
			|||||||
  languageName: node
 | 
					  languageName: node
 | 
				
			||||||
  linkType: hard
 | 
					  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":
 | 
					"react-dropzone@npm:14.3.8":
 | 
				
			||||||
  version: 14.3.8
 | 
					  version: 14.3.8
 | 
				
			||||||
  resolution: "react-dropzone@npm:14.3.8"
 | 
					  resolution: "react-dropzone@npm:14.3.8"
 | 
				
			||||||
@ -8686,6 +8715,23 @@ __metadata:
 | 
				
			|||||||
  languageName: node
 | 
					  languageName: node
 | 
				
			||||||
  linkType: hard
 | 
					  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":
 | 
					"react-hooks-global-state@npm:2.1.0":
 | 
				
			||||||
  version: 2.1.0
 | 
					  version: 2.1.0
 | 
				
			||||||
  resolution: "react-hooks-global-state@npm:2.1.0"
 | 
					  resolution: "react-hooks-global-state@npm:2.1.0"
 | 
				
			||||||
@ -8790,6 +8836,18 @@ __metadata:
 | 
				
			|||||||
  languageName: node
 | 
					  languageName: node
 | 
				
			||||||
  linkType: hard
 | 
					  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":
 | 
					"react-router-dom@npm:6.16.0":
 | 
				
			||||||
  version: 6.16.0
 | 
					  version: 6.16.0
 | 
				
			||||||
  resolution: "react-router-dom@npm:6.16.0"
 | 
					  resolution: "react-router-dom@npm:6.16.0"
 | 
				
			||||||
@ -8984,6 +9042,13 @@ __metadata:
 | 
				
			|||||||
  languageName: node
 | 
					  languageName: node
 | 
				
			||||||
  linkType: hard
 | 
					  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":
 | 
					"resolve-from@npm:^4.0.0":
 | 
				
			||||||
  version: 4.0.0
 | 
					  version: 4.0.0
 | 
				
			||||||
  resolution: "resolve-from@npm:4.0.0"
 | 
					  resolution: "resolve-from@npm:4.0.0"
 | 
				
			||||||
@ -10376,6 +10441,7 @@ __metadata:
 | 
				
			|||||||
    "@types/node": "npm:^22.0.0"
 | 
					    "@types/node": "npm:^22.0.0"
 | 
				
			||||||
    "@types/react": "npm:18.3.23"
 | 
					    "@types/react": "npm:18.3.23"
 | 
				
			||||||
    "@types/react-dom": "npm:18.3.7"
 | 
					    "@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-router-dom": "npm:5.3.3"
 | 
				
			||||||
    "@types/react-table": "npm:7.7.20"
 | 
					    "@types/react-table": "npm:7.7.20"
 | 
				
			||||||
    "@types/react-test-renderer": "npm:18.3.1"
 | 
					    "@types/react-test-renderer": "npm:18.3.1"
 | 
				
			||||||
@ -10425,6 +10491,7 @@ __metadata:
 | 
				
			|||||||
    react-dropzone: "npm:14.3.8"
 | 
					    react-dropzone: "npm:14.3.8"
 | 
				
			||||||
    react-error-boundary: "npm:3.1.4"
 | 
					    react-error-boundary: "npm:3.1.4"
 | 
				
			||||||
    react-github-calendar: "npm:^4.5.1"
 | 
					    react-github-calendar: "npm:^4.5.1"
 | 
				
			||||||
 | 
					    react-grid-layout: "npm:^1.5.2"
 | 
				
			||||||
    react-hooks-global-state: "npm:2.1.0"
 | 
					    react-hooks-global-state: "npm:2.1.0"
 | 
				
			||||||
    react-joyride: "npm:^2.5.3"
 | 
					    react-joyride: "npm:^2.5.3"
 | 
				
			||||||
    react-markdown: "npm:^8.0.4"
 | 
					    react-markdown: "npm:^8.0.4"
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
		Reference in New Issue
	
	Block a user