From 082a6fdb160996ffdd4788ee4457980a794bfe2c Mon Sep 17 00:00:00 2001 From: Tymoteusz Czech <2625371+Tymek@users.noreply.github.com> Date: Thu, 3 Jul 2025 11:09:03 +0200 Subject: [PATCH] Feat: impact metrics grid layout (#10253) --- frontend/package.json | 2 + .../impact-metrics/ChartConfigModal.tsx | 243 ++------------- .../component/impact-metrics/ChartItem.tsx | 278 +++++++----------- .../impact-metrics/GridLayoutWrapper.tsx | 131 +++++++++ .../impact-metrics/ImpactMetrics.tsx | 165 ++++++----- .../impact-metrics/ImpactMetricsChart.tsx | 150 ++++++++++ .../ImpactMetricsChartPreview.tsx | 39 +++ .../ImpactMetricsControls.tsx | 63 ++-- .../components/BeginAtZeroToggle.tsx | 22 -- .../components/SeriesSelector.tsx | 2 +- .../impact-metrics/LazyImpactMetricsPage.tsx | 7 + .../impact-metrics/hooks/useChartFormState.ts | 112 +++++++ .../hooks/useImpactMetricsState.test.tsx | 120 ++++++++ .../hooks/useImpactMetricsState.ts | 127 ++++++++ .../impact-metrics/hooks/useUrlState.ts | 108 ------- .../src/component/impact-metrics/types.ts | 13 + .../LineChart/LineChartComponent.tsx | 2 +- .../__snapshots__/routes.test.tsx.snap | 9 +- frontend/src/component/menu/routes.ts | 4 +- frontend/src/themes/app.css | 4 + frontend/yarn.lock | 69 ++++- 21 files changed, 1043 insertions(+), 627 deletions(-) create mode 100644 frontend/src/component/impact-metrics/GridLayoutWrapper.tsx create mode 100644 frontend/src/component/impact-metrics/ImpactMetricsChart.tsx create mode 100644 frontend/src/component/impact-metrics/ImpactMetricsChartPreview.tsx delete mode 100644 frontend/src/component/impact-metrics/ImpactMetricsControls/components/BeginAtZeroToggle.tsx create mode 100644 frontend/src/component/impact-metrics/LazyImpactMetricsPage.tsx create mode 100644 frontend/src/component/impact-metrics/hooks/useChartFormState.ts create mode 100644 frontend/src/component/impact-metrics/hooks/useImpactMetricsState.test.tsx create mode 100644 frontend/src/component/impact-metrics/hooks/useImpactMetricsState.ts delete mode 100644 frontend/src/component/impact-metrics/hooks/useUrlState.ts 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/ChartConfigModal.tsx b/frontend/src/component/impact-metrics/ChartConfigModal.tsx index 0acbefe985..40a480aa77 100644 --- a/frontend/src/component/impact-metrics/ChartConfigModal.tsx +++ b/frontend/src/component/impact-metrics/ChartConfigModal.tsx @@ -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 = ({ 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 - >(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 ? ( - - ) : ( - - ); - 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 ( = ({ setTitle(e.target.value)} + value={formData.title} + onChange={(e) => actions.setTitle(e.target.value)} fullWidth variant='outlined' size='small' /> - - {/* Preview Panel */} - - Preview - - - {!selectedSeries && !isLoading ? ( - - Select a metric series to view the preview - - ) : 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), - }, - }, - }, - 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} - /> - + diff --git a/frontend/src/component/impact-metrics/ChartItem.tsx b/frontend/src/component/impact-metrics/ChartItem.tsx index 0d8f305550..a8d7daa51a 100644 --- a/frontend/src/component/impact-metrics/ChartItem.tsx +++ b/frontend/src/component/impact-metrics/ChartItem.tsx @@ -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 = ({ 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 = ( - - ); - const cover = notEnoughData ? placeholder : isLoading; +const StyledChartActions = styled(Box)(({ theme }) => ({ + marginLeft: 'auto', + display: 'flex', + alignItems: 'center', + gap: theme.spacing(0.5), +})); - return ( - - - - {config.title && ( - - {config.title} - - )} - - {getConfigDescription(config)} - - - - onEdit(config)} sx={{ mr: 1 }}> - - - onDelete(config.id)}> - - - - +export const ChartItem: FC = ({ config, onEdit, onDelete }) => ( + + + + + + + {config.title && ( + {config.title} + )} + + {getConfigDescription(config)} + + + + onEdit(config)}> + + + onDelete(config.id)}> + + + + - - {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), - }, - }, - }, - 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} - /> - - - ); -}; + + + + + + + + +); diff --git a/frontend/src/component/impact-metrics/GridLayoutWrapper.tsx b/frontend/src/component/impact-metrics/GridLayoutWrapper.tsx new file mode 100644 index 0000000000..dd6eec4eed --- /dev/null +++ b/frontend/src/component/impact-metrics/GridLayoutWrapper.tsx @@ -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 = ({ + 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) =>
{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..0d54641acc 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 { 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(); - 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: ( + + ), + 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/ImpactMetricsChart.tsx b/frontend/src/component/impact-metrics/ImpactMetricsChart.tsx new file mode 100644 index 0000000000..a28ec3060f --- /dev/null +++ b/frontend/src/component/impact-metrics/ImpactMetricsChart.tsx @@ -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; + beginAtZero: boolean; + aspectRatio?: number; + overrideOptions?: Record; + errorTitle?: string; + emptyDataDescription?: string; + noSeriesPlaceholder?: ReactNode; +}; + +export const ImpactMetricsChart: FC = ({ + 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 ? ( + + ) : noSeriesPlaceholder ? ( + noSeriesPlaceholder + ) : ( + + ); + 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 ? {errorTitle} : null} + + + ); +}; diff --git a/frontend/src/component/impact-metrics/ImpactMetricsChartPreview.tsx b/frontend/src/component/impact-metrics/ImpactMetricsChartPreview.tsx new file mode 100644 index 0000000000..087c9b80ee --- /dev/null +++ b/frontend/src/component/impact-metrics/ImpactMetricsChartPreview.tsx @@ -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; + beginAtZero: boolean; +}; + +export const ImpactMetricsChartPreview: FC = ({ + selectedSeries, + selectedRange, + selectedLabels, + beginAtZero, +}) => ( + <> + + Preview + + + {!selectedSeries ? ( + + Select a metric series to view the preview + + ) : null} + + + + + +); diff --git a/frontend/src/component/impact-metrics/ImpactMetricsControls/ImpactMetricsControls.tsx b/frontend/src/component/impact-metrics/ImpactMetricsControls/ImpactMetricsControls.tsx index 206efe8ca7..1c166e9be2 100644 --- a/frontend/src/component/impact-metrics/ImpactMetricsControls/ImpactMetricsControls.tsx +++ b/frontend/src/component/impact-metrics/ImpactMetricsControls/ImpactMetricsControls.tsx @@ -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; - onLabelsChange: (labels: Record) => void; availableLabels?: ImpactMetricsLabels; }; -export const ImpactMetricsControls: FC = ( - props, -) => ( +export const ImpactMetricsControls: FC = ({ + formData, + actions, + metricSeries, + loading, + availableLabels, +}) => ( ({ display: 'flex', @@ -39,27 +43,32 @@ export const ImpactMetricsControls: FC = ( - actions.setBeginAtZero(e.target.checked)} + /> + } + label='Begin at zero' /> - {props.availableLabels && ( + {availableLabels && ( )} diff --git a/frontend/src/component/impact-metrics/ImpactMetricsControls/components/BeginAtZeroToggle.tsx b/frontend/src/component/impact-metrics/ImpactMetricsControls/components/BeginAtZeroToggle.tsx deleted file mode 100644 index 7fb884f4ba..0000000000 --- a/frontend/src/component/impact-metrics/ImpactMetricsControls/components/BeginAtZeroToggle.tsx +++ /dev/null @@ -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 = ({ - value, - onChange, -}) => ( - onChange(e.target.checked)} - /> - } - label='Begin at zero' - /> -); diff --git a/frontend/src/component/impact-metrics/ImpactMetricsControls/components/SeriesSelector.tsx b/frontend/src/component/impact-metrics/ImpactMetricsControls/components/SeriesSelector.tsx index cb7bebed9f..9b6f0fd6bc 100644 --- a/frontend/src/component/impact-metrics/ImpactMetricsControls/components/SeriesSelector.tsx +++ b/frontend/src/component/impact-metrics/ImpactMetricsControls/components/SeriesSelector.tsx @@ -25,7 +25,7 @@ export const SeriesSelector: FC = ({ onChange={(_, newValue) => onChange(newValue?.name || '')} disabled={loading} renderOption={(props, option, { inputValue }) => ( - + diff --git a/frontend/src/component/impact-metrics/LazyImpactMetricsPage.tsx b/frontend/src/component/impact-metrics/LazyImpactMetricsPage.tsx new file mode 100644 index 0000000000..6d9b9d3422 --- /dev/null +++ b/frontend/src/component/impact-metrics/LazyImpactMetricsPage.tsx @@ -0,0 +1,7 @@ +import { lazy } from 'react'; + +export const LazyImpactMetricsPage = lazy(() => + import('./ImpactMetricsPage.tsx').then((module) => ({ + default: module.ImpactMetricsPage, + })), +); diff --git a/frontend/src/component/impact-metrics/hooks/useChartFormState.ts b/frontend/src/component/impact-metrics/hooks/useChartFormState.ts new file mode 100644 index 0000000000..662c5003fe --- /dev/null +++ b/frontend/src/component/impact-metrics/hooks/useChartFormState.ts @@ -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; + }; + actions: { + setTitle: (title: string) => void; + setSelectedSeries: (series: string) => void; + setSelectedRange: (range: 'hour' | 'day' | 'week' | 'month') => void; + setBeginAtZero: (beginAtZero: boolean) => void; + setSelectedLabels: (labels: Record) => void; + handleSeriesChange: (series: string) => void; + getConfigToSave: () => Omit; + }; + 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 + >(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 => ({ + 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, + }; +}; diff --git a/frontend/src/component/impact-metrics/hooks/useImpactMetricsState.test.tsx b/frontend/src/component/impact-metrics/hooks/useImpactMetricsState.test.tsx new file mode 100644 index 0000000000..3ac0899420 --- /dev/null +++ b/frontend/src/component/impact-metrics/hooks/useImpactMetricsState.test.tsx @@ -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 ( +
+ {charts.length} + {layout.length} +
+ ); +}; + +const TestWrapper = () => ( + + } /> + +); + +describe('useImpactMetricsState', () => { + 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/useImpactMetricsState.ts b/frontend/src/component/impact-metrics/hooks/useImpactMetricsState.ts new file mode 100644 index 0000000000..58069dba09 --- /dev/null +++ b/frontend/src/component/impact-metrics/hooks/useImpactMetricsState.ts @@ -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 = () => ({ + 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(); +const LayoutParam = createArrayParam(); + +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) => { + 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) => { + 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, + }; +}; diff --git a/frontend/src/component/impact-metrics/hooks/useUrlState.ts b/frontend/src/component/impact-metrics/hooks/useUrlState.ts deleted file mode 100644 index 275c9458f2..0000000000 --- a/frontend/src/component/impact-metrics/hooks/useUrlState.ts +++ /dev/null @@ -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('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) => { - 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) => { - 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, - }; -}; 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/__tests__/__snapshots__/routes.test.tsx.snap b/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap index ada4e802f2..510aea1231 100644 --- a/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap +++ b/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap @@ -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": { 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"