1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-08-23 13:46:45 +02:00

improved state managemant for impact metrics

This commit is contained in:
Tymoteusz Czech 2025-07-01 10:09:18 +02:00
parent f6bbcf2218
commit 1e74a87b1d
No known key found for this signature in database
GPG Key ID: 133555230D88D75F
13 changed files with 718 additions and 217 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

@ -10,11 +10,11 @@ import {
} from '@mui/material';
import Edit from '@mui/icons-material/Edit';
import Delete from '@mui/icons-material/Delete';
import DragHandle from '@mui/icons-material/DragHandle';
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';
@ -49,11 +49,25 @@ const getConfigDescription = (config: ChartConfig): string => {
return parts.join(' • ');
};
const StyledHeader = styled(Typography)(({ theme }) => ({
const StyledHeader = styled(Box)(({ theme }) => ({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
alignItems: 'flex-start',
padding: theme.spacing(2, 3),
borderBottom: `1px solid ${theme.palette.divider}`,
}));
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 StyledWidget = styled(Paper)(({ theme }) => ({
@ -61,8 +75,37 @@ const StyledWidget = styled(Paper)(({ theme }) => ({
boxShadow: 'none',
display: 'flex',
flexDirection: 'column',
height: '100%',
overflow: 'hidden',
}));
const StyledChartContent = styled(Box)({
flex: 1,
display: 'flex',
flexDirection: 'column',
minHeight: 0,
});
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 StyledChartWrapper = styled(Box)({
height: '100%',
width: '100%',
'& > div': {
height: '100% !important',
width: '100% !important',
},
});
export const ChartItem: FC<ChartItemProps> = ({ config, onEdit, onDelete }) => {
const {
data: { start, end, series: timeSeriesData },
@ -109,103 +152,127 @@ export const ChartItem: FC<ChartItemProps> = ({ config, onEdit, onDelete }) => {
return (
<StyledWidget>
<StyledHeader>
<Box>
{config.title && (
<Typography variant='h6' gutterBottom>
{config.title}
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
<StyledDragHandle className='grid-item-drag-handle'>
<DragHandle fontSize='small' />
</StyledDragHandle>
<Box>
{config.title && (
<Typography variant='h6' gutterBottom>
{config.title}
</Typography>
)}
<Typography
variant='body2'
color='text.secondary'
sx={{ mb: 1 }}
>
{getConfigDescription(config)}
</Typography>
)}
<Typography
variant='body2'
color='text.secondary'
sx={{ mb: 1 }}
>
{getConfigDescription(config)}
</Typography>
</Box>
</Box>
<Box>
<IconButton onClick={() => onEdit(config)} sx={{ mr: 1 }}>
<Edit />
<IconButton
onClick={() => onEdit(config)}
size='small'
sx={{ mr: 1 }}
>
<Edit fontSize='small' />
</IconButton>
<IconButton onClick={() => onDelete(config.id)}>
<Delete />
<IconButton
onClick={() => onDelete(config.id)}
size='small'
>
<Delete fontSize='small' />
</IconButton>
</Box>
</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,
),
<StyledChartContent>
<StyledImpactChartContainer>
{hasError ? (
<Alert severity='error'>
Failed to load impact metrics. Please check if
Prometheus is configured and the feature flag is
enabled.
</Alert>
) : null}
<StyledChartWrapper>
<LineChart
data={
notEnoughData || isLoading
? placeholderData
: data
}
aspectRatio={1.5}
overrideOptions={
shouldShowPlaceholder
? { maintainAspectRatio: false }
: {
maintainAspectRatio: false,
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),
},
},
tooltipFormat: 'PPpp',
},
},
y: {
beginAtZero: config.beginAtZero,
title: {
display: false,
plugins: {
legend: {
display:
timeSeriesData &&
timeSeriesData.length > 1,
position: 'bottom' as const,
labels: {
usePointStyle: true,
boxWidth: 8,
padding: 12,
},
},
},
ticks: {
precision: 0,
callback: (
value: unknown,
): string | number =>
typeof value === 'number'
? formatLargeNumbers(
value,
)
: (value as number),
animations: {
x: { duration: 0 },
y: { duration: 0 },
},
},
},
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>
}
}
cover={cover}
/>
</StyledChartWrapper>
</StyledImpactChartContainer>
</StyledChartContent>
</StyledWidget>
);
};

View File

@ -0,0 +1,173 @@
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-layout': {
position: 'relative',
minHeight: '200px',
},
'& .react-grid-item': {
transition: 'all 200ms ease',
border: `1px solid ${theme.palette.divider}`,
borderRadius: theme.shape.borderRadius,
backgroundColor: theme.palette.background.paper,
overflow: 'hidden',
'&.react-grid-item--placeholder': {
backgroundColor: theme.palette.action.hover,
opacity: 0.6,
borderStyle: 'dashed',
borderWidth: '2px',
borderColor: theme.palette.primary.main,
},
'&:hover:not(.react-grid-item--dragging)': {
boxShadow: theme.shadows[4],
borderColor: theme.palette.primary.light,
},
'&.react-grid-item--dragging': {
opacity: 0.8,
zIndex: 1000,
transform: 'rotate(2deg)',
boxShadow: theme.shadows[8],
borderColor: theme.palette.primary.main,
},
'&.react-grid-item--resizing': {
opacity: 0.9,
zIndex: 999,
boxShadow: theme.shadows[6],
},
},
'& .react-resizable-handle': {
position: 'absolute',
width: '20px',
height: '20px',
bottom: '0px',
right: '0px',
cursor: 'se-resize',
backgroundImage: `url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHZpZXdCb3g9IjAgMCAyMCAyMCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTMgMTdMMTcgM00zIDEzTDEzIDNNNyAxN0wxNyA3IiBzdHJva2U9IiM5OTkiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIi8+Cjwvc3ZnPgo=')`,
backgroundRepeat: 'no-repeat',
backgroundPosition: 'center',
'&:hover': {
backgroundImage: `url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHZpZXdCb3g9IjAgMCAyMCAyMCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTMgMTdMMTcgM00zIDEzTDEzIDNNNyAxN0wxNyA3IiBzdHJva2U9IiMzMzMiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIi8+Cjwvc3ZnPgo=')`,
},
},
}));
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',
}) => {
// Memoize layouts to prevent unnecessary re-renders
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]);
// Memoize children to improve performance
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 { GridLayoutWrapper, type GridItem } from './GridLayoutWrapper.tsx';
import { useUrlState } from './hooks/useUrlState.ts';
import type { ChartConfig } from './types.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 } =
useUrlState();
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,5 @@
import { lazy } from 'react';
export const LazyImpactMetricsPage = lazy(
() => import('./LazyImpactMetricsPageExport.tsx'),
);

View File

@ -0,0 +1,3 @@
import { ImpactMetricsPage } from './ImpactMetricsPage.tsx';
export default ImpactMetricsPage;

View File

@ -0,0 +1,120 @@
import { render } from 'utils/testRenderer';
import { useUrlState } from './useUrlState.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 } = useUrlState();
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('useUrlState', () => {
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

@ -1,68 +1,52 @@
import { useCallback, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useLocalStorageState } from 'hooks/useLocalStorageState';
import type { ChartConfig, ImpactMetricsState } from '../types.ts';
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 encodeState = (
state: ImpactMetricsState | null | undefined,
): string | undefined =>
state && state.charts.length > 0 ? btoa(JSON.stringify(state)) : undefined;
const createArrayParam = <T>() => ({
encode: (items: T[]): string =>
items.length > 0 ? btoa(JSON.stringify(items)) : '',
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;
}
};
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 useUrlState = () => {
const [searchParams, setSearchParams] = useSearchParams();
const [storedState, setStoredState] =
useLocalStorageState<ImpactMetricsState>('impact-metrics-state', {
charts: [],
});
const stateConfig = {
charts: withDefault(ChartsParam, []),
layout: withDefault(LayoutParam, []),
};
const urlState = decodeState(searchParams.get('data'));
const currentState = urlState || storedState;
const [tableState, setTableState] = usePersistentTableState(
'impact-metrics-state',
stateConfig,
);
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 currentState: ImpactMetricsState = useMemo(
() => ({
charts: tableState.charts || [],
layout: tableState.layout || [],
}),
[tableState.charts, tableState.layout],
);
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 },
);
setTableState({
charts: newState.charts,
layout: newState.layout,
});
},
[setStoredState, setSearchParams],
[setTableState],
);
const addChart = useCallback(
@ -72,11 +56,31 @@ export const useUrlState = () => {
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;
const newLayoutItem: LayoutItem = {
i: newChart.id,
x: 0,
y: maxY,
w: 6,
h: 4,
minW: 4,
minH: 2,
maxW: 12,
maxH: 8,
};
updateState({
charts: [...currentState.charts, newChart],
layout: [...currentState.layout, newLayoutItem],
});
},
[currentState.charts, updateState],
[currentState.charts, currentState.layout, updateState],
);
const updateChart = useCallback(
@ -85,24 +89,38 @@ export const useUrlState = () => {
charts: currentState.charts.map((chart) =>
chart.id === id ? { ...chart, ...updates } : chart,
),
layout: currentState.layout,
});
},
[currentState.charts, updateState],
[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,
charts: currentState.charts || [],
layout: currentState.layout || [],
addChart,
updateChart,
deleteChart,
updateLayout,
};
};

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

@ -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"