mirror of
https://github.com/Unleash/unleash.git
synced 2025-08-04 13:48:56 +02: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