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

Feat: impact metrics grid layout (#10253)

This commit is contained in:
Tymoteusz Czech 2025-07-03 11:09:03 +02:00 committed by GitHub
parent f7fcd1c4df
commit 082a6fdb16
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1043 additions and 627 deletions

View File

@ -61,6 +61,7 @@
"@types/node": "^22.0.0",
"@types/react": "18.3.23",
"@types/react-dom": "18.3.7",
"@types/react-grid-layout": "^1.3.5",
"@types/react-router-dom": "5.3.3",
"@types/react-table": "7.7.20",
"@types/react-test-renderer": "18.3.1",
@ -107,6 +108,7 @@
"react-dropzone": "14.3.8",
"react-error-boundary": "3.1.4",
"react-github-calendar": "^4.5.1",
"react-grid-layout": "^1.5.2",
"react-hooks-global-state": "2.1.0",
"react-joyride": "^2.5.3",
"react-markdown": "^8.0.4",

View File

@ -1,5 +1,4 @@
import type { FC } from 'react';
import { useState, useEffect, useMemo } from 'react';
import {
Dialog,
DialogTitle,
@ -8,21 +7,11 @@ import {
Button,
TextField,
Box,
Typography,
Alert,
styled,
} from '@mui/material';
import { ImpactMetricsControls } from './ImpactMetricsControls/ImpactMetricsControls.tsx';
import {
LineChart,
NotEnoughData,
} from '../insights/components/LineChart/LineChart.tsx';
import { StyledChartContainer } from 'component/insights/InsightsCharts.styles';
import { useImpactMetricsData } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData';
import { usePlaceholderData } from '../insights/hooks/usePlaceholderData.js';
import { getDisplayFormat, getTimeUnit, formatLargeNumbers } from './utils.ts';
import { fromUnixTime } from 'date-fns';
import { useChartData } from './hooks/useChartData.ts';
import { ImpactMetricsChartPreview } from './ImpactMetricsChartPreview.tsx';
import { useChartFormState } from './hooks/useChartFormState.ts';
import type { ChartConfig } from './types.ts';
import type { ImpactMetricsSeries } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata';
@ -68,120 +57,19 @@ export const ChartConfigModal: FC<ChartConfigModalProps> = ({
metricSeries,
loading = false,
}) => {
const [title, setTitle] = useState(initialConfig?.title || '');
const [selectedSeries, setSelectedSeries] = useState(
initialConfig?.selectedSeries || '',
);
const [selectedRange, setSelectedRange] = useState<
'hour' | 'day' | 'week' | 'month'
>(initialConfig?.selectedRange || 'day');
const [beginAtZero, setBeginAtZero] = useState(
initialConfig?.beginAtZero || false,
);
const [selectedLabels, setSelectedLabels] = useState<
Record<string, string[]>
>(initialConfig?.selectedLabels || {});
// Data for preview
const {
data: { start, end, series: timeSeriesData },
loading: dataLoading,
error: dataError,
} = useImpactMetricsData(
selectedSeries
? {
series: selectedSeries,
range: selectedRange,
labels:
Object.keys(selectedLabels).length > 0
? selectedLabels
: undefined,
}
: undefined,
);
// Fetch available labels for the currently selected series
const {
data: { labels: currentAvailableLabels },
} = useImpactMetricsData(
selectedSeries
? {
series: selectedSeries,
range: selectedRange,
}
: undefined,
);
const placeholderData = usePlaceholderData({
fill: true,
type: 'constant',
});
const 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 { formData, actions, isValid, currentAvailableLabels } =
useChartFormState({
open,
initialConfig,
});
const handleSave = () => {
if (!selectedSeries) return;
if (!isValid) return;
onSave({
title: title || undefined,
selectedSeries,
selectedRange,
beginAtZero,
selectedLabels,
});
onSave(actions.getConfigToSave());
onClose();
};
const handleSeriesChange = (series: string) => {
setSelectedSeries(series);
setSelectedLabels({});
};
const isValid = selectedSeries.length > 0;
return (
<Dialog
open={open}
@ -211,121 +99,28 @@ export const ChartConfigModal: FC<ChartConfigModalProps> = ({
<StyledConfigPanel>
<TextField
label='Chart Title (optional)'
value={title}
onChange={(e) => setTitle(e.target.value)}
value={formData.title}
onChange={(e) => actions.setTitle(e.target.value)}
fullWidth
variant='outlined'
size='small'
/>
<ImpactMetricsControls
selectedSeries={selectedSeries}
onSeriesChange={handleSeriesChange}
selectedRange={selectedRange}
onRangeChange={setSelectedRange}
beginAtZero={beginAtZero}
onBeginAtZeroChange={setBeginAtZero}
formData={formData}
actions={actions}
metricSeries={metricSeries}
loading={loading}
selectedLabels={selectedLabels}
onLabelsChange={setSelectedLabels}
availableLabels={currentAvailableLabels}
/>
</StyledConfigPanel>
{/* Preview Panel */}
<StyledPreviewPanel>
<Typography variant='h6' color='text.secondary'>
Preview
</Typography>
{!selectedSeries && !isLoading ? (
<Typography variant='body2' color='text.secondary'>
Select a metric series to view the preview
</Typography>
) : null}
<StyledChartContainer>
{hasError ? (
<Alert severity='error'>
Failed to load impact metrics. Please check
if Prometheus is configured and the feature
flag is enabled.
</Alert>
) : null}
<LineChart
data={
notEnoughData || isLoading
? placeholderData
: data
}
overrideOptions={
shouldShowPlaceholder
? {}
: {
scales: {
x: {
type: 'time',
min: minTime?.getTime(),
max: maxTime?.getTime(),
time: {
unit: getTimeUnit(
selectedRange,
),
displayFormats: {
[getTimeUnit(
selectedRange,
)]:
getDisplayFormat(
selectedRange,
),
},
tooltipFormat: 'PPpp',
},
},
y: {
beginAtZero,
title: {
display: false,
},
ticks: {
precision: 0,
callback: (
value: unknown,
): string | number =>
typeof value ===
'number'
? formatLargeNumbers(
value,
)
: (value as number),
},
},
},
plugins: {
legend: {
display:
timeSeriesData &&
timeSeriesData.length >
1,
position:
'bottom' as const,
labels: {
usePointStyle: true,
boxWidth: 8,
padding: 12,
},
},
},
animations: {
x: { duration: 0 },
y: { duration: 0 },
},
}
}
cover={cover}
/>
</StyledChartContainer>
<ImpactMetricsChartPreview
selectedSeries={formData.selectedSeries}
selectedRange={formData.selectedRange}
selectedLabels={formData.selectedLabels}
beginAtZero={formData.beginAtZero}
/>
</StyledPreviewPanel>
</Box>
</DialogContent>

View File

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

View 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>
);
};

View File

@ -1,19 +1,29 @@
import type { FC } from 'react';
import { useMemo, useState } from 'react';
import { Box, Typography, Button } from '@mui/material';
import { useMemo, useState, useCallback } from 'react';
import { Typography, Button, Paper, styled } from '@mui/material';
import Add from '@mui/icons-material/Add';
import { PageHeader } from 'component/common/PageHeader/PageHeader.tsx';
import { useImpactMetricsMetadata } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata';
import { ChartConfigModal } from './ChartConfigModal.tsx';
import { ChartItem } from './ChartItem.tsx';
import { useUrlState } from './hooks/useUrlState.ts';
import type { ChartConfig } from './types.ts';
import { GridLayoutWrapper, type GridItem } from './GridLayoutWrapper.tsx';
import { useImpactMetricsState } from './hooks/useImpactMetricsState.ts';
import type { ChartConfig, LayoutItem } from './types.ts';
const StyledEmptyState = styled(Paper)(({ theme }) => ({
textAlign: 'center',
padding: theme.spacing(8),
backgroundColor: theme.palette.background.default,
borderRadius: theme.shape.borderRadius * 2,
border: `2px dashed ${theme.palette.divider}`,
}));
export const ImpactMetrics: FC = () => {
const [modalOpen, setModalOpen] = useState(false);
const [editingChart, setEditingChart] = useState<ChartConfig | undefined>();
const { charts, addChart, updateChart, deleteChart } = useUrlState();
const { charts, layout, addChart, updateChart, deleteChart, updateLayout } =
useImpactMetricsState();
const {
metadata,
@ -50,6 +60,41 @@ export const ImpactMetrics: FC = () => {
setModalOpen(false);
};
const handleLayoutChange = useCallback(
(layout: any[]) => {
updateLayout(layout as LayoutItem[]);
},
[updateLayout],
);
const gridItems: GridItem[] = useMemo(
() =>
charts.map((config, index) => {
const existingLayout = layout?.find(
(item) => item.i === config.id,
);
return {
id: config.id,
component: (
<ChartItem
config={config}
onEdit={handleEditChart}
onDelete={deleteChart}
/>
),
w: existingLayout?.w ?? 6,
h: existingLayout?.h ?? 4,
x: existingLayout?.x,
y: existingLayout?.y,
minW: 4,
minH: 2,
maxW: 12,
maxH: 8,
};
}),
[charts, layout, handleEditChart, deleteChart],
);
const hasError = metadataError;
return (
@ -62,73 +107,57 @@ export const ImpactMetrics: FC = () => {
</Typography>
}
actions={
charts.length > 0 ? (
<Button
variant='contained'
startIcon={<Add />}
onClick={handleAddChart}
disabled={metadataLoading || !!hasError}
>
Add Chart
</Button>
) : null
<Button
variant='contained'
startIcon={<Add />}
onClick={handleAddChart}
disabled={metadataLoading || !!hasError}
>
Add Chart
</Button>
}
/>
<Box
sx={(theme) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(2),
width: '100%',
})}
>
{charts.length === 0 && !metadataLoading && !hasError ? (
<Box
sx={(theme) => ({
textAlign: 'center',
py: theme.spacing(8),
})}
>
<Typography variant='h6' gutterBottom>
No charts configured
</Typography>
<Typography
variant='body2'
color='text.secondary'
sx={{ mb: 3 }}
>
Add your first impact metrics chart to start
tracking performance
</Typography>
<Button
variant='contained'
startIcon={<Add />}
onClick={handleAddChart}
disabled={metadataLoading || !!hasError}
>
Add Chart
</Button>
</Box>
) : (
charts.map((config) => (
<ChartItem
key={config.id}
config={config}
onEdit={handleEditChart}
onDelete={deleteChart}
/>
))
)}
<ChartConfigModal
open={modalOpen}
onClose={() => setModalOpen(false)}
onSave={handleSaveChart}
initialConfig={editingChart}
metricSeries={metricSeries}
loading={metadataLoading}
{charts.length === 0 && !metadataLoading && !hasError ? (
<StyledEmptyState>
<Typography variant='h6' gutterBottom>
No charts configured
</Typography>
<Typography
variant='body2'
color='text.secondary'
sx={{ mb: 3 }}
>
Add your first impact metrics chart to start tracking
performance with a beautiful drag-and-drop grid layout
</Typography>
<Button
variant='contained'
startIcon={<Add />}
onClick={handleAddChart}
disabled={metadataLoading || !!hasError}
>
Add Chart
</Button>
</StyledEmptyState>
) : charts.length > 0 ? (
<GridLayoutWrapper
items={gridItems}
onLayoutChange={handleLayoutChange}
rowHeight={180}
margin={[16, 16]}
cols={{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }}
/>
</Box>
) : null}
<ChartConfigModal
open={modalOpen}
onClose={() => setModalOpen(false)}
onSave={handleSaveChart}
initialConfig={editingChart}
metricSeries={metricSeries}
loading={metadataLoading}
/>
</>
);
};

View File

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

View File

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

View File

@ -1,29 +1,33 @@
import type { FC } from 'react';
import { Box, Typography } from '@mui/material';
import { Box, Typography, FormControlLabel, Checkbox } from '@mui/material';
import type { ImpactMetricsSeries } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata';
import type { ImpactMetricsLabels } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData';
import { SeriesSelector } from './components/SeriesSelector.tsx';
import { RangeSelector, type TimeRange } from './components/RangeSelector.tsx';
import { BeginAtZeroToggle } from './components/BeginAtZeroToggle.tsx';
import { RangeSelector } from './components/RangeSelector.tsx';
import { LabelsFilter } from './components/LabelsFilter.tsx';
import type { ChartFormState } from '../hooks/useChartFormState.ts';
export type ImpactMetricsControlsProps = {
selectedSeries: string;
onSeriesChange: (series: string) => void;
selectedRange: TimeRange;
onRangeChange: (range: TimeRange) => void;
beginAtZero: boolean;
onBeginAtZeroChange: (beginAtZero: boolean) => void;
formData: ChartFormState['formData'];
actions: Pick<
ChartFormState['actions'],
| 'handleSeriesChange'
| 'setSelectedRange'
| 'setBeginAtZero'
| 'setSelectedLabels'
>;
metricSeries: (ImpactMetricsSeries & { name: string })[];
loading?: boolean;
selectedLabels: Record<string, string[]>;
onLabelsChange: (labels: Record<string, string[]>) => void;
availableLabels?: ImpactMetricsLabels;
};
export const ImpactMetricsControls: FC<ImpactMetricsControlsProps> = (
props,
) => (
export const ImpactMetricsControls: FC<ImpactMetricsControlsProps> = ({
formData,
actions,
metricSeries,
loading,
availableLabels,
}) => (
<Box
sx={(theme) => ({
display: 'flex',
@ -39,27 +43,32 @@ export const ImpactMetricsControls: FC<ImpactMetricsControlsProps> = (
</Typography>
<SeriesSelector
value={props.selectedSeries}
onChange={props.onSeriesChange}
options={props.metricSeries}
loading={props.loading}
value={formData.selectedSeries}
onChange={actions.handleSeriesChange}
options={metricSeries}
loading={loading}
/>
<RangeSelector
value={props.selectedRange}
onChange={props.onRangeChange}
value={formData.selectedRange}
onChange={actions.setSelectedRange}
/>
<BeginAtZeroToggle
value={props.beginAtZero}
onChange={props.onBeginAtZeroChange}
<FormControlLabel
control={
<Checkbox
checked={formData.beginAtZero}
onChange={(e) => actions.setBeginAtZero(e.target.checked)}
/>
}
label='Begin at zero'
/>
{props.availableLabels && (
{availableLabels && (
<LabelsFilter
selectedLabels={props.selectedLabels}
onChange={props.onLabelsChange}
availableLabels={props.availableLabels}
selectedLabels={formData.selectedLabels}
onChange={actions.setSelectedLabels}
availableLabels={availableLabels}
/>
)}
</Box>

View File

@ -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'
/>
);

View File

@ -25,7 +25,7 @@ export const SeriesSelector: FC<SeriesSelectorProps> = ({
onChange={(_, newValue) => onChange(newValue?.name || '')}
disabled={loading}
renderOption={(props, option, { inputValue }) => (
<Box component='li' {...props}>
<Box component='li' {...props} key={option.name}>
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography variant='body2'>
<Highlighter search={inputValue}>

View File

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

View 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,
};
};

View File

@ -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');
});
});

View File

@ -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,
};
};

View File

@ -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,
};
};

View File

@ -7,6 +7,19 @@ export type ChartConfig = {
title?: string;
};
export type LayoutItem = {
i: string;
x: number;
y: number;
w: number;
h: number;
minW?: number;
minH?: number;
maxW?: number;
maxH?: number;
};
export type ImpactMetricsState = {
charts: ChartConfig[];
layout: LayoutItem[];
};

View File

@ -117,7 +117,7 @@ const LineChartComponent: FC<{
),
overrideOptions ?? {},
]),
[theme, locationSettings, overrideOptions, cover],
[theme, locationSettings, setTooltip, overrideOptions, cover],
);
return (

View File

@ -136,7 +136,14 @@ exports[`returns all baseRoutes 1`] = `
"type": "protected",
},
{
"component": [Function],
"component": {
"$$typeof": Symbol(react.lazy),
"_init": [Function],
"_payload": {
"_result": [Function],
"_status": -1,
},
},
"enterprise": true,
"flag": "impactMetrics",
"menu": {

View File

@ -42,7 +42,7 @@ import { ViewIntegration } from 'component/integrations/ViewIntegration/ViewInte
import { PaginatedApplicationList } from '../application/ApplicationList/PaginatedApplicationList.jsx';
import { AddonRedirect } from 'component/integrations/AddonRedirect/AddonRedirect';
import { Insights } from '../insights/Insights.jsx';
import { ImpactMetricsPage } from '../impact-metrics/ImpactMetricsPage.tsx';
import { LazyImpactMetricsPage } from '../impact-metrics/LazyImpactMetricsPage.tsx';
import { FeedbackList } from '../feedbackNew/FeedbackList.jsx';
import { Application } from 'component/application/Application';
import { Signals } from 'component/signals/Signals';
@ -164,7 +164,7 @@ export const routes: IRoute[] = [
{
path: '/impact-metrics',
title: 'Impact metrics',
component: ImpactMetricsPage,
component: LazyImpactMetricsPage,
type: 'protected',
menu: { primary: true },
enterprise: true,

View File

@ -162,3 +162,7 @@ input.hide-clear[type="search"]::-webkit-search-results-decoration {
.jse-message.jse-error {
display: none !important;
}
.react-grid-item.react-grid-placeholder {
background: #6c65e5 !important;
}

View File

@ -3256,6 +3256,15 @@ __metadata:
languageName: node
linkType: hard
"@types/react-grid-layout@npm:^1.3.5":
version: 1.3.5
resolution: "@types/react-grid-layout@npm:1.3.5"
dependencies:
"@types/react": "npm:*"
checksum: 10c0/abd2a1dda9625c753ff2571a10b69740b2fb9ed1d3141755d54d5814cc12a9701c7c5cd78e8797e945486b441303b82543be71043a32d6a988b57a14237f93c6
languageName: node
linkType: hard
"@types/react-router-dom@npm:5.3.3":
version: 5.3.3
resolution: "@types/react-router-dom@npm:5.3.3"
@ -5597,6 +5606,13 @@ __metadata:
languageName: node
linkType: hard
"fast-equals@npm:^4.0.3":
version: 4.0.3
resolution: "fast-equals@npm:4.0.3"
checksum: 10c0/87fd2609c945ee61e9ed4d041eb2a8f92723fc02884115f67e429dd858d880279e962334894f116b3e9b223f387d246e3db5424ae779287849015ddadbf5ff27
languageName: node
linkType: hard
"fast-glob@npm:^3.2.9":
version: 3.3.2
resolution: "fast-glob@npm:3.3.2"
@ -8479,7 +8495,7 @@ __metadata:
languageName: node
linkType: hard
"prop-types@npm:15.8.1, prop-types@npm:^15.0.0, prop-types@npm:^15.6.2, prop-types@npm:^15.8.1":
"prop-types@npm:15.8.1, prop-types@npm:15.x, prop-types@npm:^15.0.0, prop-types@npm:^15.6.2, prop-types@npm:^15.8.1":
version: 15.8.1
resolution: "prop-types@npm:15.8.1"
dependencies:
@ -8616,6 +8632,19 @@ __metadata:
languageName: node
linkType: hard
"react-draggable@npm:^4.0.3, react-draggable@npm:^4.4.6":
version: 4.5.0
resolution: "react-draggable@npm:4.5.0"
dependencies:
clsx: "npm:^2.1.1"
prop-types: "npm:^15.8.1"
peerDependencies:
react: ">= 16.3.0"
react-dom: ">= 16.3.0"
checksum: 10c0/6f7591fe450555218bf0d9e31984be02451bf3f678fb121f51ac0a0a645d01a1b5ea8248ef9afddcd24239028911fd88032194b9c00b30ad5ece76ea13397fc3
languageName: node
linkType: hard
"react-dropzone@npm:14.3.8":
version: 14.3.8
resolution: "react-dropzone@npm:14.3.8"
@ -8686,6 +8715,23 @@ __metadata:
languageName: node
linkType: hard
"react-grid-layout@npm:^1.5.2":
version: 1.5.2
resolution: "react-grid-layout@npm:1.5.2"
dependencies:
clsx: "npm:^2.1.1"
fast-equals: "npm:^4.0.3"
prop-types: "npm:^15.8.1"
react-draggable: "npm:^4.4.6"
react-resizable: "npm:^3.0.5"
resize-observer-polyfill: "npm:^1.5.1"
peerDependencies:
react: ">= 16.3.0"
react-dom: ">= 16.3.0"
checksum: 10c0/b6605d1435fe116c3720d168100a5a08da924c6905686fe8a486c33b82abbde8ccacbb59e5c6243fa52f5e808ad393a7bdf0c09a3446ebf76efe43f29d9f13ee
languageName: node
linkType: hard
"react-hooks-global-state@npm:2.1.0":
version: 2.1.0
resolution: "react-hooks-global-state@npm:2.1.0"
@ -8790,6 +8836,18 @@ __metadata:
languageName: node
linkType: hard
"react-resizable@npm:^3.0.5":
version: 3.0.5
resolution: "react-resizable@npm:3.0.5"
dependencies:
prop-types: "npm:15.x"
react-draggable: "npm:^4.0.3"
peerDependencies:
react: ">= 16.3"
checksum: 10c0/cfe50aa6efb79e0aa09bd681a5beab2fcd1186737c4952eb4c3974ed9395d5d263ccd1130961d06b8f5e24c8f544dd2967b5c740ce68719962d1771de7bdb350
languageName: node
linkType: hard
"react-router-dom@npm:6.16.0":
version: 6.16.0
resolution: "react-router-dom@npm:6.16.0"
@ -8984,6 +9042,13 @@ __metadata:
languageName: node
linkType: hard
"resize-observer-polyfill@npm:^1.5.1":
version: 1.5.1
resolution: "resize-observer-polyfill@npm:1.5.1"
checksum: 10c0/5e882475067f0b97dc07e0f37c3e335ac5bc3520d463f777cec7e894bb273eddbfecb857ae668e6fb6881fd6f6bb7148246967172139302da50fa12ea3a15d95
languageName: node
linkType: hard
"resolve-from@npm:^4.0.0":
version: 4.0.0
resolution: "resolve-from@npm:4.0.0"
@ -10376,6 +10441,7 @@ __metadata:
"@types/node": "npm:^22.0.0"
"@types/react": "npm:18.3.23"
"@types/react-dom": "npm:18.3.7"
"@types/react-grid-layout": "npm:^1.3.5"
"@types/react-router-dom": "npm:5.3.3"
"@types/react-table": "npm:7.7.20"
"@types/react-test-renderer": "npm:18.3.1"
@ -10425,6 +10491,7 @@ __metadata:
react-dropzone: "npm:14.3.8"
react-error-boundary: "npm:3.1.4"
react-github-calendar: "npm:^4.5.1"
react-grid-layout: "npm:^1.5.2"
react-hooks-global-state: "npm:2.1.0"
react-joyride: "npm:^2.5.3"
react-markdown: "npm:^8.0.4"