diff --git a/frontend/src/component/impact-metrics/ChartConfigModal.tsx b/frontend/src/component/impact-metrics/ChartConfigModal.tsx new file mode 100644 index 0000000000..82c33fe3ba --- /dev/null +++ b/frontend/src/component/impact-metrics/ChartConfigModal.tsx @@ -0,0 +1,334 @@ +import type { FC } from 'react'; +import { useState, useEffect, useMemo } from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + TextField, + Box, + Typography, + Alert, +} from '@mui/material'; +import { ImpactMetricsControls } from './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 type { ChartConfig } from './types.ts'; +import type { ImpactMetricsSeries } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata'; + +export interface ChartConfigModalProps { + open: boolean; + onClose: () => void; + onSave: (config: Omit) => void; + initialConfig?: ChartConfig; + metricSeries: (ImpactMetricsSeries & { name: string })[]; + loading?: boolean; +} + +export const ChartConfigModal: FC = ({ + open, + onClose, + onSave, + initialConfig, + 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 handleSave = () => { + if (!selectedSeries) return; + + onSave({ + title: title || undefined, + selectedSeries, + selectedRange, + beginAtZero, + selectedLabels, + }); + onClose(); + }; + + const handleSeriesChange = (series: string) => { + setSelectedSeries(series); + setSelectedLabels({}); + }; + + const isValid = selectedSeries.length > 0; + + return ( + + + {initialConfig ? 'Edit Chart' : 'Add New Chart'} + + + ({ + display: 'flex', + flexDirection: { xs: 'column', lg: 'row' }, + gap: theme.spacing(3), + pt: theme.spacing(1), + height: '100%', + })} + > + {/* Configuration Panel */} + ({ + flex: { xs: 'none', lg: '0 0 400px' }, + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(3), + })} + > + setTitle(e.target.value)} + fullWidth + variant='outlined' + size='small' + /> + + + + + {/* Preview Panel */} + ({ + flex: 1, + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(2), + minHeight: { xs: '300px', lg: '400px' }, + })} + > + + 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 new file mode 100644 index 0000000000..d59808a857 --- /dev/null +++ b/frontend/src/component/impact-metrics/ChartItem.tsx @@ -0,0 +1,210 @@ +import type { FC } from 'react'; +import { useMemo } from 'react'; +import { + Box, + Typography, + IconButton, + Alert, + styled, + Paper, +} from '@mui/material'; +import { Edit, Delete } from '@mui/icons-material'; +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 type { ChartConfig } from './types.ts'; + +export interface ChartItemProps { + config: ChartConfig; + onEdit: (config: ChartConfig) => void; + onDelete: (id: string) => void; +} + +const getConfigDescription = (config: ChartConfig): string => { + const parts: string[] = []; + + if (config.selectedSeries) { + parts.push(`Series: ${config.selectedSeries}`); + } + + parts.push(`Time range: last ${config.selectedRange}`); + + if (config.beginAtZero) { + parts.push('Begin at zero'); + } + + const labelCount = Object.keys(config.selectedLabels).length; + if (labelCount > 0) { + parts.push(`${labelCount} label 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 StyledWidget = styled(Paper)(({ theme }) => ({ + borderRadius: `${theme.shape.borderRadiusLarge}px`, + boxShadow: 'none', + display: 'flex', + flexDirection: 'column', +})); + +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 placeholderData = usePlaceholderData({ + fill: true, + type: 'constant', + }); + + const data = useChartData(timeSeriesData); + + 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 minTime = start + ? fromUnixTime(Number.parseInt(start, 10)) + : undefined; + const maxTime = end ? fromUnixTime(Number.parseInt(end, 10)) : undefined; + + const placeholder = ( + + ); + const cover = notEnoughData ? placeholder : isLoading; + + return ( + + + + {config.title && ( + + {config.title} + + )} + + {getConfigDescription(config)} + + + + onEdit(config)} sx={{ mr: 1 }}> + + + 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/ImpactMetrics.tsx b/frontend/src/component/impact-metrics/ImpactMetrics.tsx index c5043dee58..535ec519a7 100644 --- a/frontend/src/component/impact-metrics/ImpactMetrics.tsx +++ b/frontend/src/component/impact-metrics/ImpactMetrics.tsx @@ -1,64 +1,25 @@ import type { FC } from 'react'; import { useMemo, useState } from 'react'; -import { Box, Typography, Alert } from '@mui/material'; -import { - LineChart, - NotEnoughData, -} from '../insights/components/LineChart/LineChart.tsx'; -import { - StyledChartContainer, - StyledWidget, - StyledWidgetStats, -} from 'component/insights/InsightsCharts.styles'; +import { Box, Typography, Button } from '@mui/material'; +import { Add } from '@mui/icons-material'; +import { PageHeader } from 'component/common/PageHeader/PageHeader.tsx'; import { useImpactMetricsMetadata } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata'; -import { useImpactMetricsData } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData'; -import { usePlaceholderData } from '../insights/hooks/usePlaceholderData.js'; -import { ImpactMetricsControls } from './ImpactMetricsControls.tsx'; -import { getDisplayFormat, getTimeUnit, formatLargeNumbers } from './utils.ts'; -import { fromUnixTime } from 'date-fns'; -import { useChartData } from './hooks/useChartData.ts'; +import { ChartConfigModal } from './ChartConfigModal.tsx'; +import { ChartItem } from './ChartItem.tsx'; +import { useUrlState } from './hooks/useUrlState.ts'; +import type { ChartConfig } from './types.ts'; export const ImpactMetrics: FC = () => { - const [selectedSeries, setSelectedSeries] = useState(''); - const [selectedRange, setSelectedRange] = useState< - 'hour' | 'day' | 'week' | 'month' - >('day'); - const [beginAtZero, setBeginAtZero] = useState(false); - const [selectedLabels, setSelectedLabels] = useState< - Record - >({}); + const [modalOpen, setModalOpen] = useState(false); + const [editingChart, setEditingChart] = useState(); - const handleSeriesChange = (series: string) => { - setSelectedSeries(series); - setSelectedLabels({}); // labels are series-specific - }; + const { charts, addChart, updateChart, deleteChart } = useUrlState(); const { metadata, loading: metadataLoading, error: metadataError, } = useImpactMetricsMetadata(); - const { - data: { start, end, series: timeSeriesData, labels: availableLabels }, - 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 metricSeries = useMemo(() => { if (!metadata?.series) { @@ -70,138 +31,104 @@ export const ImpactMetrics: FC = () => { })); }, [metadata]); - const data = useChartData(timeSeriesData); + const handleAddChart = () => { + setEditingChart(undefined); + setModalOpen(true); + }; - const hasError = metadataError || dataError; - const isLoading = metadataLoading || 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 handleEditChart = (config: ChartConfig) => { + setEditingChart(config); + setModalOpen(true); + }; - const minTime = start - ? fromUnixTime(Number.parseInt(start, 10)) - : undefined; - const maxTime = end ? fromUnixTime(Number.parseInt(end, 10)) : undefined; + const handleSaveChart = (config: Omit) => { + if (editingChart) { + updateChart(editingChart.id, config); + } else { + addChart(config); + } + setModalOpen(false); + }; - const placeholder = selectedSeries ? ( - - ) : ( - - ); - const cover = notEnoughData ? placeholder : isLoading; + const hasError = metadataError; return ( - - - ({ - display: 'flex', - flexDirection: 'column', - gap: theme.spacing(2), - width: '100%', - })} - > - - - {!selectedSeries && !isLoading ? ( - - Select a metric series to view the chart + <> + + Impact Metrics + + } + 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 - ) : null} - - + + Add your first impact metrics chart to start + tracking performance + + + + ) : ( + charts.map((config) => ( + + )) + )} - - {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} + setModalOpen(false)} + onSave={handleSaveChart} + initialConfig={editingChart} + metricSeries={metricSeries} + loading={metadataLoading} /> - - + + ); }; diff --git a/frontend/src/component/impact-metrics/ImpactMetricsPage.tsx b/frontend/src/component/impact-metrics/ImpactMetricsPage.tsx index 9c03a8889b..902048694d 100644 --- a/frontend/src/component/impact-metrics/ImpactMetricsPage.tsx +++ b/frontend/src/component/impact-metrics/ImpactMetricsPage.tsx @@ -1,7 +1,6 @@ import type { FC } from 'react'; -import { styled, Typography } from '@mui/material'; +import { styled } from '@mui/material'; import { ImpactMetrics } from './ImpactMetrics.tsx'; -import { PageHeader } from 'component/common/PageHeader/PageHeader.tsx'; const StyledWrapper = styled('div')(({ theme }) => ({ paddingTop: theme.spacing(2), @@ -14,19 +13,9 @@ const StyledContainer = styled('div')(({ theme }) => ({ paddingBottom: theme.spacing(4), })); -const pageName = 'Impact Metrics'; - export const ImpactMetricsPage: FC = () => ( - - {pageName} - - } - /> diff --git a/frontend/src/component/impact-metrics/hooks/useUrlState.ts b/frontend/src/component/impact-metrics/hooks/useUrlState.ts new file mode 100644 index 0000000000..275c9458f2 --- /dev/null +++ b/frontend/src/component/impact-metrics/hooks/useUrlState.ts @@ -0,0 +1,108 @@ +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 new file mode 100644 index 0000000000..c319a3c463 --- /dev/null +++ b/frontend/src/component/impact-metrics/types.ts @@ -0,0 +1,12 @@ +export type ChartConfig = { + id: string; + selectedSeries: string; + selectedRange: 'hour' | 'day' | 'week' | 'month'; + beginAtZero: boolean; + selectedLabels: Record; + title?: string; +}; + +export type ImpactMetricsState = { + charts: ChartConfig[]; +};