From 1e74a87b1d74e56457c3f25ed9005b33ff1aa959 Mon Sep 17 00:00:00 2001 From: Tymoteusz Czech <2625371+Tymek@users.noreply.github.com> Date: Tue, 1 Jul 2025 10:09:18 +0200 Subject: [PATCH] improved state managemant for impact metrics --- frontend/package.json | 2 + .../component/impact-metrics/ChartItem.tsx | 243 +++++++++++------- .../impact-metrics/GridLayoutWrapper.tsx | 173 +++++++++++++ .../impact-metrics/ImpactMetrics.tsx | 163 +++++++----- .../impact-metrics/LazyImpactMetricsPage.tsx | 5 + .../LazyImpactMetricsPageExport.tsx | 3 + .../impact-metrics/hooks/useUrlState.test.tsx | 120 +++++++++ .../impact-metrics/hooks/useUrlState.ts | 134 +++++----- .../src/component/impact-metrics/types.ts | 13 + .../LineChart/LineChartComponent.tsx | 2 +- frontend/src/component/menu/routes.ts | 4 +- frontend/src/themes/app.css | 4 + frontend/yarn.lock | 69 ++++- 13 files changed, 718 insertions(+), 217 deletions(-) create mode 100644 frontend/src/component/impact-metrics/GridLayoutWrapper.tsx create mode 100644 frontend/src/component/impact-metrics/LazyImpactMetricsPage.tsx create mode 100644 frontend/src/component/impact-metrics/LazyImpactMetricsPageExport.tsx create mode 100644 frontend/src/component/impact-metrics/hooks/useUrlState.test.tsx diff --git a/frontend/package.json b/frontend/package.json index f847c9c2b3..cf7ce8a605 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/component/impact-metrics/ChartItem.tsx b/frontend/src/component/impact-metrics/ChartItem.tsx index 0d8f305550..94acbb797d 100644 --- a/frontend/src/component/impact-metrics/ChartItem.tsx +++ b/frontend/src/component/impact-metrics/ChartItem.tsx @@ -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 = ({ config, onEdit, onDelete }) => { const { data: { start, end, series: timeSeriesData }, @@ -109,103 +152,127 @@ export const ChartItem: FC = ({ config, onEdit, onDelete }) => { return ( - - {config.title && ( - - {config.title} + + + + + + {config.title && ( + + {config.title} + + )} + + {getConfigDescription(config)} - )} - - {getConfigDescription(config)} - + - onEdit(config)} sx={{ mr: 1 }}> - + onEdit(config)} + size='small' + sx={{ mr: 1 }} + > + - onDelete(config.id)}> - + onDelete(config.id)} + size='small' + > + - - {hasError ? ( - - Failed to load impact metrics. Please check if - Prometheus is configured and the feature flag is - enabled. - - ) : null} - + + {hasError ? ( + + Failed to load impact metrics. Please check if + Prometheus is configured and the feature flag is + enabled. + + ) : null} + + + 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} - /> - + } + } + cover={cover} + /> + + + ); }; diff --git a/frontend/src/component/impact-metrics/GridLayoutWrapper.tsx b/frontend/src/component/impact-metrics/GridLayoutWrapper.tsx new file mode 100644 index 0000000000..1e0a6d8844 --- /dev/null +++ b/frontend/src/component/impact-metrics/GridLayoutWrapper.tsx @@ -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 = ({ + 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) =>
{item.component}
), + [items], + ); + + const handleLayoutChange = useCallback( + (layout: unknown[], layouts: unknown) => { + onLayoutChange?.(layout); + }, + [onLayoutChange], + ); + + return ( + + + {children} + + + ); +}; diff --git a/frontend/src/component/impact-metrics/ImpactMetrics.tsx b/frontend/src/component/impact-metrics/ImpactMetrics.tsx index a070ad2092..503746a3fa 100644 --- a/frontend/src/component/impact-metrics/ImpactMetrics.tsx +++ b/frontend/src/component/impact-metrics/ImpactMetrics.tsx @@ -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(); - 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: ( + + ), + 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 = () => { } actions={ - charts.length > 0 ? ( - - ) : null + } /> - ({ - display: 'flex', - flexDirection: 'column', - gap: theme.spacing(2), - width: '100%', - })} - > - {charts.length === 0 && !metadataLoading && !hasError ? ( - ({ - textAlign: 'center', - py: theme.spacing(8), - })} - > - - No charts configured - - - Add your first impact metrics chart to start - tracking performance - - - - ) : ( - charts.map((config) => ( - - )) - )} - setModalOpen(false)} - onSave={handleSaveChart} - initialConfig={editingChart} - metricSeries={metricSeries} - loading={metadataLoading} + {charts.length === 0 && !metadataLoading && !hasError ? ( + + + No charts configured + + + Add your first impact metrics chart to start tracking + performance with a beautiful drag-and-drop grid layout + + + + ) : charts.length > 0 ? ( + - + ) : null} + + setModalOpen(false)} + onSave={handleSaveChart} + initialConfig={editingChart} + metricSeries={metricSeries} + loading={metadataLoading} + /> ); }; diff --git a/frontend/src/component/impact-metrics/LazyImpactMetricsPage.tsx b/frontend/src/component/impact-metrics/LazyImpactMetricsPage.tsx new file mode 100644 index 0000000000..078c80812d --- /dev/null +++ b/frontend/src/component/impact-metrics/LazyImpactMetricsPage.tsx @@ -0,0 +1,5 @@ +import { lazy } from 'react'; + +export const LazyImpactMetricsPage = lazy( + () => import('./LazyImpactMetricsPageExport.tsx'), +); diff --git a/frontend/src/component/impact-metrics/LazyImpactMetricsPageExport.tsx b/frontend/src/component/impact-metrics/LazyImpactMetricsPageExport.tsx new file mode 100644 index 0000000000..fd5fd1e6c3 --- /dev/null +++ b/frontend/src/component/impact-metrics/LazyImpactMetricsPageExport.tsx @@ -0,0 +1,3 @@ +import { ImpactMetricsPage } from './ImpactMetricsPage.tsx'; + +export default ImpactMetricsPage; diff --git a/frontend/src/component/impact-metrics/hooks/useUrlState.test.tsx b/frontend/src/component/impact-metrics/hooks/useUrlState.test.tsx new file mode 100644 index 0000000000..2a9fb7b00b --- /dev/null +++ b/frontend/src/component/impact-metrics/hooks/useUrlState.test.tsx @@ -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 ( +
+ {charts.length} + {layout.length} +
+ ); +}; + +const TestWrapper = () => ( + + } /> + +); + +describe('useUrlState', () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it('loads state from localStorage to the URL after opening page without URL state', async () => { + const { setValue } = createLocalStorage( + '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(, { 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( + '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(, { + 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'); + }); +}); diff --git a/frontend/src/component/impact-metrics/hooks/useUrlState.ts b/frontend/src/component/impact-metrics/hooks/useUrlState.ts index 275c9458f2..f6a9051957 100644 --- a/frontend/src/component/impact-metrics/hooks/useUrlState.ts +++ b/frontend/src/component/impact-metrics/hooks/useUrlState.ts @@ -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 = () => ({ + 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(); +const LayoutParam = createArrayParam(); export const useUrlState = () => { - const [searchParams, setSearchParams] = useSearchParams(); - const [storedState, setStoredState] = - useLocalStorageState('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, }; }; diff --git a/frontend/src/component/impact-metrics/types.ts b/frontend/src/component/impact-metrics/types.ts index c319a3c463..177975ed78 100644 --- a/frontend/src/component/impact-metrics/types.ts +++ b/frontend/src/component/impact-metrics/types.ts @@ -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[]; }; diff --git a/frontend/src/component/insights/components/LineChart/LineChartComponent.tsx b/frontend/src/component/insights/components/LineChart/LineChartComponent.tsx index e83ac7c513..4f62e77d82 100644 --- a/frontend/src/component/insights/components/LineChart/LineChartComponent.tsx +++ b/frontend/src/component/insights/components/LineChart/LineChartComponent.tsx @@ -117,7 +117,7 @@ const LineChartComponent: FC<{ ), overrideOptions ?? {}, ]), - [theme, locationSettings, overrideOptions, cover], + [theme, locationSettings, setTooltip, overrideOptions, cover], ); return ( diff --git a/frontend/src/component/menu/routes.ts b/frontend/src/component/menu/routes.ts index e2dc1af1a6..6c9edf3b27 100644 --- a/frontend/src/component/menu/routes.ts +++ b/frontend/src/component/menu/routes.ts @@ -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, diff --git a/frontend/src/themes/app.css b/frontend/src/themes/app.css index ffea84e478..f5cd0b9b1d 100644 --- a/frontend/src/themes/app.css +++ b/frontend/src/themes/app.css @@ -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; +} diff --git a/frontend/yarn.lock b/frontend/yarn.lock index ed16b80d16..3450a566ad 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -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"