mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	Feat: impact metrics grid layout (#10253)
This commit is contained in:
		
							parent
							
								
									f7fcd1c4df
								
							
						
					
					
						commit
						082a6fdb16
					
				| @ -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", | ||||
|  | ||||
| @ -1,5 +1,4 @@ | ||||
| import type { FC } from 'react'; | ||||
| import { useState, useEffect, useMemo } from 'react'; | ||||
| import { | ||||
|     Dialog, | ||||
|     DialogTitle, | ||||
| @ -8,21 +7,11 @@ import { | ||||
|     Button, | ||||
|     TextField, | ||||
|     Box, | ||||
|     Typography, | ||||
|     Alert, | ||||
|     styled, | ||||
| } from '@mui/material'; | ||||
| import { ImpactMetricsControls } from './ImpactMetricsControls/ImpactMetricsControls.tsx'; | ||||
| import { | ||||
|     LineChart, | ||||
|     NotEnoughData, | ||||
| } from '../insights/components/LineChart/LineChart.tsx'; | ||||
| import { StyledChartContainer } from 'component/insights/InsightsCharts.styles'; | ||||
| import { useImpactMetricsData } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData'; | ||||
| import { usePlaceholderData } from '../insights/hooks/usePlaceholderData.js'; | ||||
| import { getDisplayFormat, getTimeUnit, formatLargeNumbers } from './utils.ts'; | ||||
| import { fromUnixTime } from 'date-fns'; | ||||
| import { useChartData } from './hooks/useChartData.ts'; | ||||
| import { ImpactMetricsChartPreview } from './ImpactMetricsChartPreview.tsx'; | ||||
| import { useChartFormState } from './hooks/useChartFormState.ts'; | ||||
| import type { ChartConfig } from './types.ts'; | ||||
| import type { ImpactMetricsSeries } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata'; | ||||
| 
 | ||||
| @ -68,120 +57,19 @@ export const ChartConfigModal: FC<ChartConfigModalProps> = ({ | ||||
|     metricSeries, | ||||
|     loading = false, | ||||
| }) => { | ||||
|     const [title, setTitle] = useState(initialConfig?.title || ''); | ||||
|     const [selectedSeries, setSelectedSeries] = useState( | ||||
|         initialConfig?.selectedSeries || '', | ||||
|     ); | ||||
|     const [selectedRange, setSelectedRange] = useState< | ||||
|         'hour' | 'day' | 'week' | 'month' | ||||
|     >(initialConfig?.selectedRange || 'day'); | ||||
|     const [beginAtZero, setBeginAtZero] = useState( | ||||
|         initialConfig?.beginAtZero || false, | ||||
|     ); | ||||
|     const [selectedLabels, setSelectedLabels] = useState< | ||||
|         Record<string, string[]> | ||||
|     >(initialConfig?.selectedLabels || {}); | ||||
| 
 | ||||
|     // Data for preview
 | ||||
|     const { | ||||
|         data: { start, end, series: timeSeriesData }, | ||||
|         loading: dataLoading, | ||||
|         error: dataError, | ||||
|     } = useImpactMetricsData( | ||||
|         selectedSeries | ||||
|             ? { | ||||
|                   series: selectedSeries, | ||||
|                   range: selectedRange, | ||||
|                   labels: | ||||
|                       Object.keys(selectedLabels).length > 0 | ||||
|                           ? selectedLabels | ||||
|                           : undefined, | ||||
|               } | ||||
|             : undefined, | ||||
|     ); | ||||
| 
 | ||||
|     // Fetch available labels for the currently selected series
 | ||||
|     const { | ||||
|         data: { labels: currentAvailableLabels }, | ||||
|     } = useImpactMetricsData( | ||||
|         selectedSeries | ||||
|             ? { | ||||
|                   series: selectedSeries, | ||||
|                   range: selectedRange, | ||||
|               } | ||||
|             : undefined, | ||||
|     ); | ||||
| 
 | ||||
|     const placeholderData = usePlaceholderData({ | ||||
|         fill: true, | ||||
|         type: 'constant', | ||||
|     }); | ||||
| 
 | ||||
|     const data = useChartData(timeSeriesData); | ||||
| 
 | ||||
|     const hasError = !!dataError; | ||||
|     const isLoading = dataLoading; | ||||
|     const shouldShowPlaceholder = !selectedSeries || isLoading || hasError; | ||||
|     const notEnoughData = useMemo( | ||||
|         () => | ||||
|             !isLoading && | ||||
|             (!timeSeriesData || | ||||
|                 timeSeriesData.length === 0 || | ||||
|                 !data.datasets.some((d) => d.data.length > 1)), | ||||
|         [data, isLoading, timeSeriesData], | ||||
|     ); | ||||
| 
 | ||||
|     const minTime = start | ||||
|         ? fromUnixTime(Number.parseInt(start, 10)) | ||||
|         : undefined; | ||||
|     const maxTime = end ? fromUnixTime(Number.parseInt(end, 10)) : undefined; | ||||
| 
 | ||||
|     const placeholder = selectedSeries ? ( | ||||
|         <NotEnoughData description='Send impact metrics using Unleash SDK and select data series to view the chart.' /> | ||||
|     ) : ( | ||||
|         <NotEnoughData | ||||
|             title='Select a metric series to view the chart.' | ||||
|             description='' | ||||
|         /> | ||||
|     ); | ||||
|     const cover = notEnoughData ? placeholder : isLoading; | ||||
| 
 | ||||
|     useEffect(() => { | ||||
|         if (open && initialConfig) { | ||||
|             setTitle(initialConfig.title || ''); | ||||
|             setSelectedSeries(initialConfig.selectedSeries); | ||||
|             setSelectedRange(initialConfig.selectedRange); | ||||
|             setBeginAtZero(initialConfig.beginAtZero); | ||||
|             setSelectedLabels(initialConfig.selectedLabels); | ||||
|         } else if (open && !initialConfig) { | ||||
|             setTitle(''); | ||||
|             setSelectedSeries(''); | ||||
|             setSelectedRange('day'); | ||||
|             setBeginAtZero(false); | ||||
|             setSelectedLabels({}); | ||||
|         } | ||||
|     }, [open, initialConfig]); | ||||
|     const { formData, actions, isValid, currentAvailableLabels } = | ||||
|         useChartFormState({ | ||||
|             open, | ||||
|             initialConfig, | ||||
|         }); | ||||
| 
 | ||||
|     const handleSave = () => { | ||||
|         if (!selectedSeries) return; | ||||
|         if (!isValid) return; | ||||
| 
 | ||||
|         onSave({ | ||||
|             title: title || undefined, | ||||
|             selectedSeries, | ||||
|             selectedRange, | ||||
|             beginAtZero, | ||||
|             selectedLabels, | ||||
|         }); | ||||
|         onSave(actions.getConfigToSave()); | ||||
|         onClose(); | ||||
|     }; | ||||
| 
 | ||||
|     const handleSeriesChange = (series: string) => { | ||||
|         setSelectedSeries(series); | ||||
|         setSelectedLabels({}); | ||||
|     }; | ||||
| 
 | ||||
|     const isValid = selectedSeries.length > 0; | ||||
| 
 | ||||
|     return ( | ||||
|         <Dialog | ||||
|             open={open} | ||||
| @ -211,121 +99,28 @@ export const ChartConfigModal: FC<ChartConfigModalProps> = ({ | ||||
|                     <StyledConfigPanel> | ||||
|                         <TextField | ||||
|                             label='Chart Title (optional)' | ||||
|                             value={title} | ||||
|                             onChange={(e) => setTitle(e.target.value)} | ||||
|                             value={formData.title} | ||||
|                             onChange={(e) => actions.setTitle(e.target.value)} | ||||
|                             fullWidth | ||||
|                             variant='outlined' | ||||
|                             size='small' | ||||
|                         /> | ||||
| 
 | ||||
|                         <ImpactMetricsControls | ||||
|                             selectedSeries={selectedSeries} | ||||
|                             onSeriesChange={handleSeriesChange} | ||||
|                             selectedRange={selectedRange} | ||||
|                             onRangeChange={setSelectedRange} | ||||
|                             beginAtZero={beginAtZero} | ||||
|                             onBeginAtZeroChange={setBeginAtZero} | ||||
|                             formData={formData} | ||||
|                             actions={actions} | ||||
|                             metricSeries={metricSeries} | ||||
|                             loading={loading} | ||||
|                             selectedLabels={selectedLabels} | ||||
|                             onLabelsChange={setSelectedLabels} | ||||
|                             availableLabels={currentAvailableLabels} | ||||
|                         /> | ||||
|                     </StyledConfigPanel> | ||||
| 
 | ||||
|                     {/* Preview Panel */} | ||||
|                     <StyledPreviewPanel> | ||||
|                         <Typography variant='h6' color='text.secondary'> | ||||
|                             Preview | ||||
|                         </Typography> | ||||
| 
 | ||||
|                         {!selectedSeries && !isLoading ? ( | ||||
|                             <Typography variant='body2' color='text.secondary'> | ||||
|                                 Select a metric series to view the preview | ||||
|                             </Typography> | ||||
|                         ) : null} | ||||
| 
 | ||||
|                         <StyledChartContainer> | ||||
|                             {hasError ? ( | ||||
|                                 <Alert severity='error'> | ||||
|                                     Failed to load impact metrics. Please check | ||||
|                                     if Prometheus is configured and the feature | ||||
|                                     flag is enabled. | ||||
|                                 </Alert> | ||||
|                             ) : null} | ||||
|                             <LineChart | ||||
|                                 data={ | ||||
|                                     notEnoughData || isLoading | ||||
|                                         ? placeholderData | ||||
|                                         : data | ||||
|                                 } | ||||
|                                 overrideOptions={ | ||||
|                                     shouldShowPlaceholder | ||||
|                                         ? {} | ||||
|                                         : { | ||||
|                                               scales: { | ||||
|                                                   x: { | ||||
|                                                       type: 'time', | ||||
|                                                       min: minTime?.getTime(), | ||||
|                                                       max: maxTime?.getTime(), | ||||
|                                                       time: { | ||||
|                                                           unit: getTimeUnit( | ||||
|                                                               selectedRange, | ||||
|                                                           ), | ||||
|                                                           displayFormats: { | ||||
|                                                               [getTimeUnit( | ||||
|                                                                   selectedRange, | ||||
|                                                               )]: | ||||
|                                                                   getDisplayFormat( | ||||
|                                                                       selectedRange, | ||||
|                                                                   ), | ||||
|                                                           }, | ||||
|                                                           tooltipFormat: 'PPpp', | ||||
|                                                       }, | ||||
|                                                   }, | ||||
|                                                   y: { | ||||
|                                                       beginAtZero, | ||||
|                                                       title: { | ||||
|                                                           display: false, | ||||
|                                                       }, | ||||
|                                                       ticks: { | ||||
|                                                           precision: 0, | ||||
|                                                           callback: ( | ||||
|                                                               value: unknown, | ||||
|                                                           ): string | number => | ||||
|                                                               typeof value === | ||||
|                                                               'number' | ||||
|                                                                   ? formatLargeNumbers( | ||||
|                                                                         value, | ||||
|                                                                     ) | ||||
|                                                                   : (value as number), | ||||
|                                                       }, | ||||
|                                                   }, | ||||
|                                               }, | ||||
|                                               plugins: { | ||||
|                                                   legend: { | ||||
|                                                       display: | ||||
|                                                           timeSeriesData && | ||||
|                                                           timeSeriesData.length > | ||||
|                                                               1, | ||||
|                                                       position: | ||||
|                                                           'bottom' as const, | ||||
|                                                       labels: { | ||||
|                                                           usePointStyle: true, | ||||
|                                                           boxWidth: 8, | ||||
|                                                           padding: 12, | ||||
|                                                       }, | ||||
|                                                   }, | ||||
|                                               }, | ||||
|                                               animations: { | ||||
|                                                   x: { duration: 0 }, | ||||
|                                                   y: { duration: 0 }, | ||||
|                                               }, | ||||
|                                           } | ||||
|                                 } | ||||
|                                 cover={cover} | ||||
|                             /> | ||||
|                         </StyledChartContainer> | ||||
|                         <ImpactMetricsChartPreview | ||||
|                             selectedSeries={formData.selectedSeries} | ||||
|                             selectedRange={formData.selectedRange} | ||||
|                             selectedLabels={formData.selectedLabels} | ||||
|                             beginAtZero={formData.beginAtZero} | ||||
|                         /> | ||||
|                     </StyledPreviewPanel> | ||||
|                 </Box> | ||||
|             </DialogContent> | ||||
|  | ||||
| @ -1,25 +1,9 @@ | ||||
| import type { FC } from 'react'; | ||||
| import { useMemo } from 'react'; | ||||
| import { | ||||
|     Box, | ||||
|     Typography, | ||||
|     IconButton, | ||||
|     Alert, | ||||
|     styled, | ||||
|     Paper, | ||||
| } from '@mui/material'; | ||||
| import { Box, Typography, IconButton, styled, Paper } from '@mui/material'; | ||||
| import Edit from '@mui/icons-material/Edit'; | ||||
| import Delete from '@mui/icons-material/Delete'; | ||||
| import { | ||||
|     LineChart, | ||||
|     NotEnoughData, | ||||
| } from '../insights/components/LineChart/LineChart.tsx'; | ||||
| import { StyledChartContainer } from 'component/insights/InsightsCharts.styles'; | ||||
| import { useImpactMetricsData } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData'; | ||||
| import { usePlaceholderData } from '../insights/hooks/usePlaceholderData.js'; | ||||
| import { getDisplayFormat, getTimeUnit, formatLargeNumbers } from './utils.ts'; | ||||
| import { fromUnixTime } from 'date-fns'; | ||||
| import { useChartData } from './hooks/useChartData.ts'; | ||||
| import DragHandle from '@mui/icons-material/DragHandle'; | ||||
| import { ImpactMetricsChart } from './ImpactMetricsChart.tsx'; | ||||
| import type { ChartConfig } from './types.ts'; | ||||
| 
 | ||||
| export interface ChartItemProps { | ||||
| @ -32,180 +16,130 @@ const getConfigDescription = (config: ChartConfig): string => { | ||||
|     const parts: string[] = []; | ||||
| 
 | ||||
|     if (config.selectedSeries) { | ||||
|         parts.push(`Series: ${config.selectedSeries}`); | ||||
|         parts.push(`${config.selectedSeries}`); | ||||
|     } | ||||
| 
 | ||||
|     parts.push(`Time range: last ${config.selectedRange}`); | ||||
| 
 | ||||
|     if (config.beginAtZero) { | ||||
|         parts.push('Begin at zero'); | ||||
|     } | ||||
|     parts.push(`last ${config.selectedRange}`); | ||||
| 
 | ||||
|     const labelCount = Object.keys(config.selectedLabels).length; | ||||
|     if (labelCount > 0) { | ||||
|         parts.push(`${labelCount} label filter${labelCount > 1 ? 's' : ''}`); | ||||
|         parts.push(`${labelCount} filter${labelCount > 1 ? 's' : ''}`); | ||||
|     } | ||||
| 
 | ||||
|     return parts.join(' • '); | ||||
| }; | ||||
| 
 | ||||
| const StyledHeader = styled(Typography)(({ theme }) => ({ | ||||
|     display: 'flex', | ||||
|     justifyContent: 'space-between', | ||||
|     alignItems: 'center', | ||||
|     padding: theme.spacing(2, 3), | ||||
| })); | ||||
| const StyledChartWrapper = styled(Box)({ | ||||
|     height: '100%', | ||||
|     width: '100%', | ||||
|     '& > div': { | ||||
|         height: '100% !important', | ||||
|         width: '100% !important', | ||||
|     }, | ||||
| }); | ||||
| 
 | ||||
| const StyledWidget = styled(Paper)(({ theme }) => ({ | ||||
|     borderRadius: `${theme.shape.borderRadiusLarge}px`, | ||||
|     borderRadius: `${theme.shape.borderRadiusMedium}px`, | ||||
|     boxShadow: 'none', | ||||
|     display: 'flex', | ||||
|     flexDirection: 'column', | ||||
|     height: '100%', | ||||
|     overflow: 'hidden', | ||||
| })); | ||||
| 
 | ||||
| export const ChartItem: FC<ChartItemProps> = ({ config, onEdit, onDelete }) => { | ||||
|     const { | ||||
|         data: { start, end, series: timeSeriesData }, | ||||
|         loading: dataLoading, | ||||
|         error: dataError, | ||||
|     } = useImpactMetricsData({ | ||||
|         series: config.selectedSeries, | ||||
|         range: config.selectedRange, | ||||
|         labels: | ||||
|             Object.keys(config.selectedLabels).length > 0 | ||||
|                 ? config.selectedLabels | ||||
|                 : undefined, | ||||
|     }); | ||||
| const StyledChartContent = styled(Box)({ | ||||
|     flex: 1, | ||||
|     display: 'flex', | ||||
|     flexDirection: 'column', | ||||
|     minHeight: 0, | ||||
| }); | ||||
| 
 | ||||
|     const placeholderData = usePlaceholderData({ | ||||
|         fill: true, | ||||
|         type: 'constant', | ||||
|     }); | ||||
| const StyledImpactChartContainer = styled(Box)(({ theme }) => ({ | ||||
|     position: 'relative', | ||||
|     minWidth: 0, | ||||
|     flexGrow: 1, | ||||
|     height: '100%', | ||||
|     display: 'flex', | ||||
|     flexDirection: 'column', | ||||
|     margin: 'auto 0', | ||||
|     padding: theme.spacing(3), | ||||
| })); | ||||
| 
 | ||||
|     const data = useChartData(timeSeriesData); | ||||
| const StyledHeader = styled(Box)(({ theme }) => ({ | ||||
|     display: 'flex', | ||||
|     gap: theme.spacing(2), | ||||
|     alignItems: 'center', | ||||
|     padding: theme.spacing(1.5, 2), | ||||
|     borderBottom: `1px solid ${theme.palette.divider}`, | ||||
| })); | ||||
| 
 | ||||
|     const hasError = !!dataError; | ||||
|     const isLoading = dataLoading; | ||||
|     const shouldShowPlaceholder = isLoading || hasError; | ||||
|     const notEnoughData = useMemo( | ||||
|         () => | ||||
|             !isLoading && | ||||
|             (!timeSeriesData || | ||||
|                 timeSeriesData.length === 0 || | ||||
|                 !data.datasets.some((d) => d.data.length > 1)), | ||||
|         [data, isLoading, timeSeriesData], | ||||
|     ); | ||||
| const StyledDragHandle = styled(Box)(({ theme }) => ({ | ||||
|     display: 'flex', | ||||
|     alignItems: 'center', | ||||
|     cursor: 'move', | ||||
|     padding: theme.spacing(0.5), | ||||
|     borderRadius: theme.shape.borderRadius, | ||||
|     color: theme.palette.text.secondary, | ||||
|     '&:hover': { | ||||
|         backgroundColor: theme.palette.action.hover, | ||||
|         color: theme.palette.text.primary, | ||||
|     }, | ||||
| })); | ||||
| 
 | ||||
|     const minTime = start | ||||
|         ? fromUnixTime(Number.parseInt(start, 10)) | ||||
|         : undefined; | ||||
|     const maxTime = end ? fromUnixTime(Number.parseInt(end, 10)) : undefined; | ||||
| const StyledChartTitle = styled(Box)(({ theme }) => ({ | ||||
|     display: 'flex', | ||||
|     flexDirection: 'column', | ||||
|     justifyContent: 'flex-end', | ||||
|     flexGrow: 1, | ||||
|     overflow: 'hidden', | ||||
|     textOverflow: 'ellipsis', | ||||
| })); | ||||
| 
 | ||||
|     const placeholder = ( | ||||
|         <NotEnoughData description='Send impact metrics using Unleash SDK for this series to view the chart.' /> | ||||
|     ); | ||||
|     const cover = notEnoughData ? placeholder : isLoading; | ||||
| const StyledChartActions = styled(Box)(({ theme }) => ({ | ||||
|     marginLeft: 'auto', | ||||
|     display: 'flex', | ||||
|     alignItems: 'center', | ||||
|     gap: theme.spacing(0.5), | ||||
| })); | ||||
| 
 | ||||
|     return ( | ||||
|         <StyledWidget> | ||||
|             <StyledHeader> | ||||
|                 <Box> | ||||
|                     {config.title && ( | ||||
|                         <Typography variant='h6' gutterBottom> | ||||
|                             {config.title} | ||||
|                         </Typography> | ||||
|                     )} | ||||
|                     <Typography | ||||
|                         variant='body2' | ||||
|                         color='text.secondary' | ||||
|                         sx={{ mb: 1 }} | ||||
|                     > | ||||
|                         {getConfigDescription(config)} | ||||
|                     </Typography> | ||||
|                 </Box> | ||||
|                 <Box> | ||||
|                     <IconButton onClick={() => onEdit(config)} sx={{ mr: 1 }}> | ||||
|                         <Edit /> | ||||
|                     </IconButton> | ||||
|                     <IconButton onClick={() => onDelete(config.id)}> | ||||
|                         <Delete /> | ||||
|                     </IconButton> | ||||
|                 </Box> | ||||
|             </StyledHeader> | ||||
| export const ChartItem: FC<ChartItemProps> = ({ config, onEdit, onDelete }) => ( | ||||
|     <StyledWidget> | ||||
|         <StyledHeader> | ||||
|             <StyledDragHandle className='grid-item-drag-handle'> | ||||
|                 <DragHandle fontSize='small' /> | ||||
|             </StyledDragHandle> | ||||
|             <StyledChartTitle> | ||||
|                 {config.title && ( | ||||
|                     <Typography variant='h6'>{config.title}</Typography> | ||||
|                 )} | ||||
|                 <Typography variant='body2' color='text.secondary'> | ||||
|                     {getConfigDescription(config)} | ||||
|                 </Typography> | ||||
|             </StyledChartTitle> | ||||
|             <StyledChartActions> | ||||
|                 <IconButton onClick={() => onEdit(config)}> | ||||
|                     <Edit /> | ||||
|                 </IconButton> | ||||
|                 <IconButton onClick={() => onDelete(config.id)}> | ||||
|                     <Delete /> | ||||
|                 </IconButton> | ||||
|             </StyledChartActions> | ||||
|         </StyledHeader> | ||||
| 
 | ||||
|             <StyledChartContainer> | ||||
|                 {hasError ? ( | ||||
|                     <Alert severity='error'> | ||||
|                         Failed to load impact metrics. Please check if | ||||
|                         Prometheus is configured and the feature flag is | ||||
|                         enabled. | ||||
|                     </Alert> | ||||
|                 ) : null} | ||||
|                 <LineChart | ||||
|                     data={notEnoughData || isLoading ? placeholderData : data} | ||||
|                     overrideOptions={ | ||||
|                         shouldShowPlaceholder | ||||
|                             ? {} | ||||
|                             : { | ||||
|                                   scales: { | ||||
|                                       x: { | ||||
|                                           type: 'time', | ||||
|                                           min: minTime?.getTime(), | ||||
|                                           max: maxTime?.getTime(), | ||||
|                                           time: { | ||||
|                                               unit: getTimeUnit( | ||||
|                                                   config.selectedRange, | ||||
|                                               ), | ||||
|                                               displayFormats: { | ||||
|                                                   [getTimeUnit( | ||||
|                                                       config.selectedRange, | ||||
|                                                   )]: getDisplayFormat( | ||||
|                                                       config.selectedRange, | ||||
|                                                   ), | ||||
|                                               }, | ||||
|                                               tooltipFormat: 'PPpp', | ||||
|                                           }, | ||||
|                                       }, | ||||
|                                       y: { | ||||
|                                           beginAtZero: config.beginAtZero, | ||||
|                                           title: { | ||||
|                                               display: false, | ||||
|                                           }, | ||||
|                                           ticks: { | ||||
|                                               precision: 0, | ||||
|                                               callback: ( | ||||
|                                                   value: unknown, | ||||
|                                               ): string | number => | ||||
|                                                   typeof value === 'number' | ||||
|                                                       ? formatLargeNumbers( | ||||
|                                                             value, | ||||
|                                                         ) | ||||
|                                                       : (value as number), | ||||
|                                           }, | ||||
|                                       }, | ||||
|                                   }, | ||||
|                                   plugins: { | ||||
|                                       legend: { | ||||
|                                           display: | ||||
|                                               timeSeriesData && | ||||
|                                               timeSeriesData.length > 1, | ||||
|                                           position: 'bottom' as const, | ||||
|                                           labels: { | ||||
|                                               usePointStyle: true, | ||||
|                                               boxWidth: 8, | ||||
|                                               padding: 12, | ||||
|                                           }, | ||||
|                                       }, | ||||
|                                   }, | ||||
|                                   animations: { | ||||
|                                       x: { duration: 0 }, | ||||
|                                       y: { duration: 0 }, | ||||
|                                   }, | ||||
|                               } | ||||
|                     } | ||||
|                     cover={cover} | ||||
|                 /> | ||||
|             </StyledChartContainer> | ||||
|         </StyledWidget> | ||||
|     ); | ||||
| }; | ||||
|         <StyledChartContent> | ||||
|             <StyledImpactChartContainer> | ||||
|                 <StyledChartWrapper> | ||||
|                     <ImpactMetricsChart | ||||
|                         selectedSeries={config.selectedSeries} | ||||
|                         selectedRange={config.selectedRange} | ||||
|                         selectedLabels={config.selectedLabels} | ||||
|                         beginAtZero={config.beginAtZero} | ||||
|                         aspectRatio={1.5} | ||||
|                         overrideOptions={{ maintainAspectRatio: false }} | ||||
|                         emptyDataDescription='Send impact metrics using Unleash SDK for this series to view the chart.' | ||||
|                     /> | ||||
|                 </StyledChartWrapper> | ||||
|             </StyledImpactChartContainer> | ||||
|         </StyledChartContent> | ||||
|     </StyledWidget> | ||||
| ); | ||||
|  | ||||
							
								
								
									
										131
									
								
								frontend/src/component/impact-metrics/GridLayoutWrapper.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										131
									
								
								frontend/src/component/impact-metrics/GridLayoutWrapper.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,131 @@ | ||||
| import type { FC, ReactNode } from 'react'; | ||||
| import { useMemo, useCallback } from 'react'; | ||||
| import { Responsive, WidthProvider } from 'react-grid-layout'; | ||||
| import { styled } from '@mui/material'; | ||||
| import 'react-grid-layout/css/styles.css'; | ||||
| import 'react-resizable/css/styles.css'; | ||||
| 
 | ||||
| const ResponsiveGridLayout = WidthProvider(Responsive); | ||||
| 
 | ||||
| const StyledGridContainer = styled('div')(({ theme }) => ({ | ||||
|     '& .react-grid-item': { | ||||
|         borderRadius: `${theme.shape.borderRadiusMedium}px`, | ||||
|     }, | ||||
|     '& .react-resizable-handle': { | ||||
|         '&::after': { | ||||
|             opacity: 0.5, | ||||
|         }, | ||||
|     }, | ||||
| })); | ||||
| 
 | ||||
| export type GridItem = { | ||||
|     id: string; | ||||
|     component: ReactNode; | ||||
|     w?: number; | ||||
|     h?: number; | ||||
|     x?: number; | ||||
|     y?: number; | ||||
|     minW?: number; | ||||
|     minH?: number; | ||||
|     maxW?: number; | ||||
|     maxH?: number; | ||||
|     static?: boolean; | ||||
| }; | ||||
| 
 | ||||
| type GridLayoutWrapperProps = { | ||||
|     items: GridItem[]; | ||||
|     onLayoutChange?: (layout: unknown[]) => void; | ||||
|     cols?: { lg: number; md: number; sm: number; xs: number; xxs: number }; | ||||
|     rowHeight?: number; | ||||
|     margin?: [number, number]; | ||||
|     isDraggable?: boolean; | ||||
|     isResizable?: boolean; | ||||
|     compactType?: 'vertical' | 'horizontal' | null; | ||||
| }; | ||||
| 
 | ||||
| export const GridLayoutWrapper: FC<GridLayoutWrapperProps> = ({ | ||||
|     items, | ||||
|     onLayoutChange, | ||||
|     cols = { lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }, | ||||
|     rowHeight = 180, | ||||
|     margin = [16, 16], | ||||
|     isDraggable = true, | ||||
|     isResizable = true, | ||||
|     compactType = 'vertical', | ||||
| }) => { | ||||
|     const layouts = useMemo(() => { | ||||
|         const baseLayout = items.map((item, index) => ({ | ||||
|             i: item.id, | ||||
|             x: item.x ?? (index % cols.lg) * (item.w ?? 6), | ||||
|             y: item.y ?? Math.floor(index / cols.lg) * (item.h ?? 4), | ||||
|             w: item.w ?? 6, | ||||
|             h: item.h ?? 4, | ||||
|             minW: item.minW ?? 3, | ||||
|             minH: item.minH ?? 3, | ||||
|             maxW: item.maxW ?? 12, | ||||
|             maxH: item.maxH ?? 8, | ||||
|             static: item.static ?? false, | ||||
|         })); | ||||
| 
 | ||||
|         return { | ||||
|             lg: baseLayout, | ||||
|             md: baseLayout.map((item) => ({ | ||||
|                 ...item, | ||||
|                 w: Math.min(item.w, cols.md), | ||||
|                 x: Math.min(item.x, cols.md - item.w), | ||||
|             })), | ||||
|             sm: baseLayout.map((item) => ({ | ||||
|                 ...item, | ||||
|                 w: Math.min(item.w, cols.sm), | ||||
|                 x: Math.min(item.x, cols.sm - item.w), | ||||
|             })), | ||||
|             xs: baseLayout.map((item) => ({ | ||||
|                 ...item, | ||||
|                 w: Math.min(item.w, cols.xs), | ||||
|                 x: Math.min(item.x, cols.xs - item.w), | ||||
|             })), | ||||
|             xxs: baseLayout.map((item) => ({ | ||||
|                 ...item, | ||||
|                 w: Math.min(item.w, cols.xxs), | ||||
|                 x: Math.min(item.x, cols.xxs - item.w), | ||||
|             })), | ||||
|         }; | ||||
|     }, [items, cols]); | ||||
| 
 | ||||
|     const children = useMemo( | ||||
|         () => items.map((item) => <div key={item.id}>{item.component}</div>), | ||||
|         [items], | ||||
|     ); | ||||
| 
 | ||||
|     const handleLayoutChange = useCallback( | ||||
|         (layout: unknown[], layouts: unknown) => { | ||||
|             onLayoutChange?.(layout); | ||||
|         }, | ||||
|         [onLayoutChange], | ||||
|     ); | ||||
| 
 | ||||
|     return ( | ||||
|         <StyledGridContainer> | ||||
|             <ResponsiveGridLayout | ||||
|                 className='impact-metrics-grid' | ||||
|                 layouts={layouts} | ||||
|                 breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }} | ||||
|                 cols={cols} | ||||
|                 rowHeight={rowHeight} | ||||
|                 margin={margin} | ||||
|                 containerPadding={[0, 0]} | ||||
|                 isDraggable={isDraggable} | ||||
|                 isResizable={isResizable} | ||||
|                 onLayoutChange={handleLayoutChange} | ||||
|                 resizeHandles={['se']} | ||||
|                 draggableHandle='.grid-item-drag-handle' | ||||
|                 compactType={compactType} | ||||
|                 preventCollision={false} | ||||
|                 useCSSTransforms={true} | ||||
|                 autoSize={true} | ||||
|             > | ||||
|                 {children} | ||||
|             </ResponsiveGridLayout> | ||||
|         </StyledGridContainer> | ||||
|     ); | ||||
| }; | ||||
| @ -1,19 +1,29 @@ | ||||
| import type { FC } from 'react'; | ||||
| import { useMemo, useState } from 'react'; | ||||
| import { Box, Typography, Button } from '@mui/material'; | ||||
| import { useMemo, useState, useCallback } from 'react'; | ||||
| import { Typography, Button, Paper, styled } from '@mui/material'; | ||||
| import Add from '@mui/icons-material/Add'; | ||||
| import { PageHeader } from 'component/common/PageHeader/PageHeader.tsx'; | ||||
| import { useImpactMetricsMetadata } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata'; | ||||
| import { ChartConfigModal } from './ChartConfigModal.tsx'; | ||||
| import { ChartItem } from './ChartItem.tsx'; | ||||
| import { useUrlState } from './hooks/useUrlState.ts'; | ||||
| import type { ChartConfig } from './types.ts'; | ||||
| import { GridLayoutWrapper, type GridItem } from './GridLayoutWrapper.tsx'; | ||||
| import { useImpactMetricsState } from './hooks/useImpactMetricsState.ts'; | ||||
| import type { ChartConfig, LayoutItem } from './types.ts'; | ||||
| 
 | ||||
| const StyledEmptyState = styled(Paper)(({ theme }) => ({ | ||||
|     textAlign: 'center', | ||||
|     padding: theme.spacing(8), | ||||
|     backgroundColor: theme.palette.background.default, | ||||
|     borderRadius: theme.shape.borderRadius * 2, | ||||
|     border: `2px dashed ${theme.palette.divider}`, | ||||
| })); | ||||
| 
 | ||||
| export const ImpactMetrics: FC = () => { | ||||
|     const [modalOpen, setModalOpen] = useState(false); | ||||
|     const [editingChart, setEditingChart] = useState<ChartConfig | undefined>(); | ||||
| 
 | ||||
|     const { charts, addChart, updateChart, deleteChart } = useUrlState(); | ||||
|     const { charts, layout, addChart, updateChart, deleteChart, updateLayout } = | ||||
|         useImpactMetricsState(); | ||||
| 
 | ||||
|     const { | ||||
|         metadata, | ||||
| @ -50,6 +60,41 @@ export const ImpactMetrics: FC = () => { | ||||
|         setModalOpen(false); | ||||
|     }; | ||||
| 
 | ||||
|     const handleLayoutChange = useCallback( | ||||
|         (layout: any[]) => { | ||||
|             updateLayout(layout as LayoutItem[]); | ||||
|         }, | ||||
|         [updateLayout], | ||||
|     ); | ||||
| 
 | ||||
|     const gridItems: GridItem[] = useMemo( | ||||
|         () => | ||||
|             charts.map((config, index) => { | ||||
|                 const existingLayout = layout?.find( | ||||
|                     (item) => item.i === config.id, | ||||
|                 ); | ||||
|                 return { | ||||
|                     id: config.id, | ||||
|                     component: ( | ||||
|                         <ChartItem | ||||
|                             config={config} | ||||
|                             onEdit={handleEditChart} | ||||
|                             onDelete={deleteChart} | ||||
|                         /> | ||||
|                     ), | ||||
|                     w: existingLayout?.w ?? 6, | ||||
|                     h: existingLayout?.h ?? 4, | ||||
|                     x: existingLayout?.x, | ||||
|                     y: existingLayout?.y, | ||||
|                     minW: 4, | ||||
|                     minH: 2, | ||||
|                     maxW: 12, | ||||
|                     maxH: 8, | ||||
|                 }; | ||||
|             }), | ||||
|         [charts, layout, handleEditChart, deleteChart], | ||||
|     ); | ||||
| 
 | ||||
|     const hasError = metadataError; | ||||
| 
 | ||||
|     return ( | ||||
| @ -62,73 +107,57 @@ export const ImpactMetrics: FC = () => { | ||||
|                     </Typography> | ||||
|                 } | ||||
|                 actions={ | ||||
|                     charts.length > 0 ? ( | ||||
|                         <Button | ||||
|                             variant='contained' | ||||
|                             startIcon={<Add />} | ||||
|                             onClick={handleAddChart} | ||||
|                             disabled={metadataLoading || !!hasError} | ||||
|                         > | ||||
|                             Add Chart | ||||
|                         </Button> | ||||
|                     ) : null | ||||
|                     <Button | ||||
|                         variant='contained' | ||||
|                         startIcon={<Add />} | ||||
|                         onClick={handleAddChart} | ||||
|                         disabled={metadataLoading || !!hasError} | ||||
|                     > | ||||
|                         Add Chart | ||||
|                     </Button> | ||||
|                 } | ||||
|             /> | ||||
|             <Box | ||||
|                 sx={(theme) => ({ | ||||
|                     display: 'flex', | ||||
|                     flexDirection: 'column', | ||||
|                     gap: theme.spacing(2), | ||||
|                     width: '100%', | ||||
|                 })} | ||||
|             > | ||||
|                 {charts.length === 0 && !metadataLoading && !hasError ? ( | ||||
|                     <Box | ||||
|                         sx={(theme) => ({ | ||||
|                             textAlign: 'center', | ||||
|                             py: theme.spacing(8), | ||||
|                         })} | ||||
|                     > | ||||
|                         <Typography variant='h6' gutterBottom> | ||||
|                             No charts configured | ||||
|                         </Typography> | ||||
|                         <Typography | ||||
|                             variant='body2' | ||||
|                             color='text.secondary' | ||||
|                             sx={{ mb: 3 }} | ||||
|                         > | ||||
|                             Add your first impact metrics chart to start | ||||
|                             tracking performance | ||||
|                         </Typography> | ||||
|                         <Button | ||||
|                             variant='contained' | ||||
|                             startIcon={<Add />} | ||||
|                             onClick={handleAddChart} | ||||
|                             disabled={metadataLoading || !!hasError} | ||||
|                         > | ||||
|                             Add Chart | ||||
|                         </Button> | ||||
|                     </Box> | ||||
|                 ) : ( | ||||
|                     charts.map((config) => ( | ||||
|                         <ChartItem | ||||
|                             key={config.id} | ||||
|                             config={config} | ||||
|                             onEdit={handleEditChart} | ||||
|                             onDelete={deleteChart} | ||||
|                         /> | ||||
|                     )) | ||||
|                 )} | ||||
| 
 | ||||
|                 <ChartConfigModal | ||||
|                     open={modalOpen} | ||||
|                     onClose={() => setModalOpen(false)} | ||||
|                     onSave={handleSaveChart} | ||||
|                     initialConfig={editingChart} | ||||
|                     metricSeries={metricSeries} | ||||
|                     loading={metadataLoading} | ||||
|             {charts.length === 0 && !metadataLoading && !hasError ? ( | ||||
|                 <StyledEmptyState> | ||||
|                     <Typography variant='h6' gutterBottom> | ||||
|                         No charts configured | ||||
|                     </Typography> | ||||
|                     <Typography | ||||
|                         variant='body2' | ||||
|                         color='text.secondary' | ||||
|                         sx={{ mb: 3 }} | ||||
|                     > | ||||
|                         Add your first impact metrics chart to start tracking | ||||
|                         performance with a beautiful drag-and-drop grid layout | ||||
|                     </Typography> | ||||
|                     <Button | ||||
|                         variant='contained' | ||||
|                         startIcon={<Add />} | ||||
|                         onClick={handleAddChart} | ||||
|                         disabled={metadataLoading || !!hasError} | ||||
|                     > | ||||
|                         Add Chart | ||||
|                     </Button> | ||||
|                 </StyledEmptyState> | ||||
|             ) : charts.length > 0 ? ( | ||||
|                 <GridLayoutWrapper | ||||
|                     items={gridItems} | ||||
|                     onLayoutChange={handleLayoutChange} | ||||
|                     rowHeight={180} | ||||
|                     margin={[16, 16]} | ||||
|                     cols={{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }} | ||||
|                 /> | ||||
|             </Box> | ||||
|             ) : null} | ||||
| 
 | ||||
|             <ChartConfigModal | ||||
|                 open={modalOpen} | ||||
|                 onClose={() => setModalOpen(false)} | ||||
|                 onSave={handleSaveChart} | ||||
|                 initialConfig={editingChart} | ||||
|                 metricSeries={metricSeries} | ||||
|                 loading={metadataLoading} | ||||
|             /> | ||||
|         </> | ||||
|     ); | ||||
| }; | ||||
|  | ||||
							
								
								
									
										150
									
								
								frontend/src/component/impact-metrics/ImpactMetricsChart.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										150
									
								
								frontend/src/component/impact-metrics/ImpactMetricsChart.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,150 @@ | ||||
| import type { FC, ReactNode } from 'react'; | ||||
| import { useMemo } from 'react'; | ||||
| import { Alert } from '@mui/material'; | ||||
| import { | ||||
|     LineChart, | ||||
|     NotEnoughData, | ||||
| } from '../insights/components/LineChart/LineChart.tsx'; | ||||
| import { useImpactMetricsData } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData'; | ||||
| import { usePlaceholderData } from '../insights/hooks/usePlaceholderData.js'; | ||||
| import { getDisplayFormat, getTimeUnit, formatLargeNumbers } from './utils.ts'; | ||||
| import { fromUnixTime } from 'date-fns'; | ||||
| import { useChartData } from './hooks/useChartData.ts'; | ||||
| 
 | ||||
| type ImpactMetricsChartProps = { | ||||
|     selectedSeries: string; | ||||
|     selectedRange: 'hour' | 'day' | 'week' | 'month'; | ||||
|     selectedLabels: Record<string, string[]>; | ||||
|     beginAtZero: boolean; | ||||
|     aspectRatio?: number; | ||||
|     overrideOptions?: Record<string, unknown>; | ||||
|     errorTitle?: string; | ||||
|     emptyDataDescription?: string; | ||||
|     noSeriesPlaceholder?: ReactNode; | ||||
| }; | ||||
| 
 | ||||
| export const ImpactMetricsChart: FC<ImpactMetricsChartProps> = ({ | ||||
|     selectedSeries, | ||||
|     selectedRange, | ||||
|     selectedLabels, | ||||
|     beginAtZero, | ||||
|     aspectRatio, | ||||
|     overrideOptions = {}, | ||||
|     errorTitle = 'Failed to load impact metrics. Please check if Prometheus is configured and the feature flag is enabled.', | ||||
|     emptyDataDescription = 'Send impact metrics using Unleash SDK and select data series to view the chart.', | ||||
|     noSeriesPlaceholder, | ||||
| }) => { | ||||
|     const { | ||||
|         data: { start, end, series: timeSeriesData }, | ||||
|         loading: dataLoading, | ||||
|         error: dataError, | ||||
|     } = useImpactMetricsData( | ||||
|         selectedSeries | ||||
|             ? { | ||||
|                   series: selectedSeries, | ||||
|                   range: selectedRange, | ||||
|                   labels: | ||||
|                       Object.keys(selectedLabels).length > 0 | ||||
|                           ? selectedLabels | ||||
|                           : undefined, | ||||
|               } | ||||
|             : undefined, | ||||
|     ); | ||||
| 
 | ||||
|     const placeholderData = usePlaceholderData({ | ||||
|         fill: true, | ||||
|         type: 'constant', | ||||
|     }); | ||||
| 
 | ||||
|     const data = useChartData(timeSeriesData); | ||||
| 
 | ||||
|     const hasError = !!dataError; | ||||
|     const isLoading = dataLoading; | ||||
|     const shouldShowPlaceholder = !selectedSeries || isLoading || hasError; | ||||
|     const notEnoughData = useMemo( | ||||
|         () => | ||||
|             !isLoading && | ||||
|             (!timeSeriesData || | ||||
|                 timeSeriesData.length === 0 || | ||||
|                 !data.datasets.some((d) => d.data.length > 1)), | ||||
|         [data, isLoading, timeSeriesData], | ||||
|     ); | ||||
| 
 | ||||
|     const minTime = start | ||||
|         ? fromUnixTime(Number.parseInt(start, 10)) | ||||
|         : undefined; | ||||
|     const maxTime = end ? fromUnixTime(Number.parseInt(end, 10)) : undefined; | ||||
| 
 | ||||
|     const placeholder = selectedSeries ? ( | ||||
|         <NotEnoughData description={emptyDataDescription} /> | ||||
|     ) : noSeriesPlaceholder ? ( | ||||
|         noSeriesPlaceholder | ||||
|     ) : ( | ||||
|         <NotEnoughData | ||||
|             title='Select a metric series to view the chart.' | ||||
|             description='' | ||||
|         /> | ||||
|     ); | ||||
|     const cover = notEnoughData ? placeholder : isLoading; | ||||
| 
 | ||||
|     const chartOptions = shouldShowPlaceholder | ||||
|         ? overrideOptions | ||||
|         : { | ||||
|               ...overrideOptions, | ||||
|               scales: { | ||||
|                   x: { | ||||
|                       type: 'time', | ||||
|                       min: minTime?.getTime(), | ||||
|                       max: maxTime?.getTime(), | ||||
|                       time: { | ||||
|                           unit: getTimeUnit(selectedRange), | ||||
|                           displayFormats: { | ||||
|                               [getTimeUnit(selectedRange)]: | ||||
|                                   getDisplayFormat(selectedRange), | ||||
|                           }, | ||||
|                           tooltipFormat: 'PPpp', | ||||
|                       }, | ||||
|                   }, | ||||
|                   y: { | ||||
|                       beginAtZero, | ||||
|                       title: { | ||||
|                           display: false, | ||||
|                       }, | ||||
|                       ticks: { | ||||
|                           precision: 0, | ||||
|                           callback: (value: unknown): string | number => | ||||
|                               typeof value === 'number' | ||||
|                                   ? formatLargeNumbers(value) | ||||
|                                   : (value as number), | ||||
|                       }, | ||||
|                   }, | ||||
|               }, | ||||
|               plugins: { | ||||
|                   legend: { | ||||
|                       display: timeSeriesData && timeSeriesData.length > 1, | ||||
|                       position: 'bottom' as const, | ||||
|                       labels: { | ||||
|                           usePointStyle: true, | ||||
|                           boxWidth: 8, | ||||
|                           padding: 12, | ||||
|                       }, | ||||
|                   }, | ||||
|               }, | ||||
|               animations: { | ||||
|                   x: { duration: 0 }, | ||||
|                   y: { duration: 0 }, | ||||
|               }, | ||||
|           }; | ||||
| 
 | ||||
|     return ( | ||||
|         <> | ||||
|             {hasError ? <Alert severity='error'>{errorTitle}</Alert> : null} | ||||
|             <LineChart | ||||
|                 data={notEnoughData || isLoading ? placeholderData : data} | ||||
|                 aspectRatio={aspectRatio} | ||||
|                 overrideOptions={chartOptions} | ||||
|                 cover={cover} | ||||
|             /> | ||||
|         </> | ||||
|     ); | ||||
| }; | ||||
| @ -0,0 +1,39 @@ | ||||
| import type { FC } from 'react'; | ||||
| import { Typography } from '@mui/material'; | ||||
| import { StyledChartContainer } from 'component/insights/InsightsCharts.styles'; | ||||
| import { ImpactMetricsChart } from './ImpactMetricsChart.tsx'; | ||||
| 
 | ||||
| type ImpactMetricsChartPreviewProps = { | ||||
|     selectedSeries: string; | ||||
|     selectedRange: 'hour' | 'day' | 'week' | 'month'; | ||||
|     selectedLabels: Record<string, string[]>; | ||||
|     beginAtZero: boolean; | ||||
| }; | ||||
| 
 | ||||
| export const ImpactMetricsChartPreview: FC<ImpactMetricsChartPreviewProps> = ({ | ||||
|     selectedSeries, | ||||
|     selectedRange, | ||||
|     selectedLabels, | ||||
|     beginAtZero, | ||||
| }) => ( | ||||
|     <> | ||||
|         <Typography variant='h6' color='text.secondary'> | ||||
|             Preview | ||||
|         </Typography> | ||||
| 
 | ||||
|         {!selectedSeries ? ( | ||||
|             <Typography variant='body2' color='text.secondary'> | ||||
|                 Select a metric series to view the preview | ||||
|             </Typography> | ||||
|         ) : null} | ||||
| 
 | ||||
|         <StyledChartContainer> | ||||
|             <ImpactMetricsChart | ||||
|                 selectedSeries={selectedSeries} | ||||
|                 selectedRange={selectedRange} | ||||
|                 selectedLabels={selectedLabels} | ||||
|                 beginAtZero={beginAtZero} | ||||
|             /> | ||||
|         </StyledChartContainer> | ||||
|     </> | ||||
| ); | ||||
| @ -1,29 +1,33 @@ | ||||
| import type { FC } from 'react'; | ||||
| import { Box, Typography } from '@mui/material'; | ||||
| import { Box, Typography, FormControlLabel, Checkbox } from '@mui/material'; | ||||
| import type { ImpactMetricsSeries } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata'; | ||||
| import type { ImpactMetricsLabels } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData'; | ||||
| import { SeriesSelector } from './components/SeriesSelector.tsx'; | ||||
| import { RangeSelector, type TimeRange } from './components/RangeSelector.tsx'; | ||||
| import { BeginAtZeroToggle } from './components/BeginAtZeroToggle.tsx'; | ||||
| import { RangeSelector } from './components/RangeSelector.tsx'; | ||||
| import { LabelsFilter } from './components/LabelsFilter.tsx'; | ||||
| import type { ChartFormState } from '../hooks/useChartFormState.ts'; | ||||
| 
 | ||||
| export type ImpactMetricsControlsProps = { | ||||
|     selectedSeries: string; | ||||
|     onSeriesChange: (series: string) => void; | ||||
|     selectedRange: TimeRange; | ||||
|     onRangeChange: (range: TimeRange) => void; | ||||
|     beginAtZero: boolean; | ||||
|     onBeginAtZeroChange: (beginAtZero: boolean) => void; | ||||
|     formData: ChartFormState['formData']; | ||||
|     actions: Pick< | ||||
|         ChartFormState['actions'], | ||||
|         | 'handleSeriesChange' | ||||
|         | 'setSelectedRange' | ||||
|         | 'setBeginAtZero' | ||||
|         | 'setSelectedLabels' | ||||
|     >; | ||||
|     metricSeries: (ImpactMetricsSeries & { name: string })[]; | ||||
|     loading?: boolean; | ||||
|     selectedLabels: Record<string, string[]>; | ||||
|     onLabelsChange: (labels: Record<string, string[]>) => void; | ||||
|     availableLabels?: ImpactMetricsLabels; | ||||
| }; | ||||
| 
 | ||||
| export const ImpactMetricsControls: FC<ImpactMetricsControlsProps> = ( | ||||
|     props, | ||||
| ) => ( | ||||
| export const ImpactMetricsControls: FC<ImpactMetricsControlsProps> = ({ | ||||
|     formData, | ||||
|     actions, | ||||
|     metricSeries, | ||||
|     loading, | ||||
|     availableLabels, | ||||
| }) => ( | ||||
|     <Box | ||||
|         sx={(theme) => ({ | ||||
|             display: 'flex', | ||||
| @ -39,27 +43,32 @@ export const ImpactMetricsControls: FC<ImpactMetricsControlsProps> = ( | ||||
|         </Typography> | ||||
| 
 | ||||
|         <SeriesSelector | ||||
|             value={props.selectedSeries} | ||||
|             onChange={props.onSeriesChange} | ||||
|             options={props.metricSeries} | ||||
|             loading={props.loading} | ||||
|             value={formData.selectedSeries} | ||||
|             onChange={actions.handleSeriesChange} | ||||
|             options={metricSeries} | ||||
|             loading={loading} | ||||
|         /> | ||||
| 
 | ||||
|         <RangeSelector | ||||
|             value={props.selectedRange} | ||||
|             onChange={props.onRangeChange} | ||||
|             value={formData.selectedRange} | ||||
|             onChange={actions.setSelectedRange} | ||||
|         /> | ||||
| 
 | ||||
|         <BeginAtZeroToggle | ||||
|             value={props.beginAtZero} | ||||
|             onChange={props.onBeginAtZeroChange} | ||||
|         <FormControlLabel | ||||
|             control={ | ||||
|                 <Checkbox | ||||
|                     checked={formData.beginAtZero} | ||||
|                     onChange={(e) => actions.setBeginAtZero(e.target.checked)} | ||||
|                 /> | ||||
|             } | ||||
|             label='Begin at zero' | ||||
|         /> | ||||
| 
 | ||||
|         {props.availableLabels && ( | ||||
|         {availableLabels && ( | ||||
|             <LabelsFilter | ||||
|                 selectedLabels={props.selectedLabels} | ||||
|                 onChange={props.onLabelsChange} | ||||
|                 availableLabels={props.availableLabels} | ||||
|                 selectedLabels={formData.selectedLabels} | ||||
|                 onChange={actions.setSelectedLabels} | ||||
|                 availableLabels={availableLabels} | ||||
|             /> | ||||
|         )} | ||||
|     </Box> | ||||
|  | ||||
| @ -1,22 +0,0 @@ | ||||
| import type { FC } from 'react'; | ||||
| import { FormControlLabel, Checkbox } from '@mui/material'; | ||||
| 
 | ||||
| export type BeginAtZeroToggleProps = { | ||||
|     value: boolean; | ||||
|     onChange: (beginAtZero: boolean) => void; | ||||
| }; | ||||
| 
 | ||||
| export const BeginAtZeroToggle: FC<BeginAtZeroToggleProps> = ({ | ||||
|     value, | ||||
|     onChange, | ||||
| }) => ( | ||||
|     <FormControlLabel | ||||
|         control={ | ||||
|             <Checkbox | ||||
|                 checked={value} | ||||
|                 onChange={(e) => onChange(e.target.checked)} | ||||
|             /> | ||||
|         } | ||||
|         label='Begin at zero' | ||||
|     /> | ||||
| ); | ||||
| @ -25,7 +25,7 @@ export const SeriesSelector: FC<SeriesSelectorProps> = ({ | ||||
|         onChange={(_, newValue) => onChange(newValue?.name || '')} | ||||
|         disabled={loading} | ||||
|         renderOption={(props, option, { inputValue }) => ( | ||||
|             <Box component='li' {...props}> | ||||
|             <Box component='li' {...props} key={option.name}> | ||||
|                 <Box sx={{ display: 'flex', flexDirection: 'column' }}> | ||||
|                     <Typography variant='body2'> | ||||
|                         <Highlighter search={inputValue}> | ||||
|  | ||||
| @ -0,0 +1,7 @@ | ||||
| import { lazy } from 'react'; | ||||
| 
 | ||||
| export const LazyImpactMetricsPage = lazy(() => | ||||
|     import('./ImpactMetricsPage.tsx').then((module) => ({ | ||||
|         default: module.ImpactMetricsPage, | ||||
|     })), | ||||
| ); | ||||
							
								
								
									
										112
									
								
								frontend/src/component/impact-metrics/hooks/useChartFormState.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										112
									
								
								frontend/src/component/impact-metrics/hooks/useChartFormState.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,112 @@ | ||||
| import { useState, useEffect } from 'react'; | ||||
| import { useImpactMetricsData } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData'; | ||||
| import type { ChartConfig } from '../types.ts'; | ||||
| import type { ImpactMetricsLabels } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData'; | ||||
| 
 | ||||
| type UseChartConfigParams = { | ||||
|     open: boolean; | ||||
|     initialConfig?: ChartConfig; | ||||
| }; | ||||
| 
 | ||||
| export type ChartFormState = { | ||||
|     formData: { | ||||
|         title: string; | ||||
|         selectedSeries: string; | ||||
|         selectedRange: 'hour' | 'day' | 'week' | 'month'; | ||||
|         beginAtZero: boolean; | ||||
|         selectedLabels: Record<string, string[]>; | ||||
|     }; | ||||
|     actions: { | ||||
|         setTitle: (title: string) => void; | ||||
|         setSelectedSeries: (series: string) => void; | ||||
|         setSelectedRange: (range: 'hour' | 'day' | 'week' | 'month') => void; | ||||
|         setBeginAtZero: (beginAtZero: boolean) => void; | ||||
|         setSelectedLabels: (labels: Record<string, string[]>) => void; | ||||
|         handleSeriesChange: (series: string) => void; | ||||
|         getConfigToSave: () => Omit<ChartConfig, 'id'>; | ||||
|     }; | ||||
|     isValid: boolean; | ||||
|     currentAvailableLabels: ImpactMetricsLabels | undefined; | ||||
| }; | ||||
| 
 | ||||
| export const useChartFormState = ({ | ||||
|     open, | ||||
|     initialConfig, | ||||
| }: UseChartConfigParams): ChartFormState => { | ||||
|     const [title, setTitle] = useState(initialConfig?.title || ''); | ||||
|     const [selectedSeries, setSelectedSeries] = useState( | ||||
|         initialConfig?.selectedSeries || '', | ||||
|     ); | ||||
|     const [selectedRange, setSelectedRange] = useState< | ||||
|         'hour' | 'day' | 'week' | 'month' | ||||
|     >(initialConfig?.selectedRange || 'day'); | ||||
|     const [beginAtZero, setBeginAtZero] = useState( | ||||
|         initialConfig?.beginAtZero || false, | ||||
|     ); | ||||
|     const [selectedLabels, setSelectedLabels] = useState< | ||||
|         Record<string, string[]> | ||||
|     >(initialConfig?.selectedLabels || {}); | ||||
| 
 | ||||
|     const { | ||||
|         data: { labels: currentAvailableLabels }, | ||||
|     } = useImpactMetricsData( | ||||
|         selectedSeries | ||||
|             ? { | ||||
|                   series: selectedSeries, | ||||
|                   range: selectedRange, | ||||
|               } | ||||
|             : undefined, | ||||
|     ); | ||||
| 
 | ||||
|     useEffect(() => { | ||||
|         if (open && initialConfig) { | ||||
|             setTitle(initialConfig.title || ''); | ||||
|             setSelectedSeries(initialConfig.selectedSeries); | ||||
|             setSelectedRange(initialConfig.selectedRange); | ||||
|             setBeginAtZero(initialConfig.beginAtZero); | ||||
|             setSelectedLabels(initialConfig.selectedLabels); | ||||
|         } else if (open && !initialConfig) { | ||||
|             setTitle(''); | ||||
|             setSelectedSeries(''); | ||||
|             setSelectedRange('day'); | ||||
|             setBeginAtZero(false); | ||||
|             setSelectedLabels({}); | ||||
|         } | ||||
|     }, [open, initialConfig]); | ||||
| 
 | ||||
|     const handleSeriesChange = (series: string) => { | ||||
|         setSelectedSeries(series); | ||||
|         setSelectedLabels({}); | ||||
|     }; | ||||
| 
 | ||||
|     const getConfigToSave = (): Omit<ChartConfig, 'id'> => ({ | ||||
|         title: title || undefined, | ||||
|         selectedSeries, | ||||
|         selectedRange, | ||||
|         beginAtZero, | ||||
|         selectedLabels, | ||||
|     }); | ||||
| 
 | ||||
|     const isValid = selectedSeries.length > 0; | ||||
| 
 | ||||
|     return { | ||||
|         formData: { | ||||
|             title, | ||||
|             selectedSeries, | ||||
|             selectedRange, | ||||
|             beginAtZero, | ||||
|             selectedLabels, | ||||
|         }, | ||||
|         actions: { | ||||
|             setTitle, | ||||
|             setSelectedSeries, | ||||
|             setSelectedRange, | ||||
|             setBeginAtZero, | ||||
|             setSelectedLabels, | ||||
|             handleSeriesChange, | ||||
|             getConfigToSave, | ||||
|         }, | ||||
|         isValid, | ||||
|         currentAvailableLabels, | ||||
|     }; | ||||
| }; | ||||
| @ -0,0 +1,120 @@ | ||||
| import { render } from 'utils/testRenderer'; | ||||
| import { useImpactMetricsState } from './useImpactMetricsState.ts'; | ||||
| import { Route, Routes } from 'react-router-dom'; | ||||
| import { createLocalStorage } from '../../../utils/createLocalStorage.ts'; | ||||
| import type { FC } from 'react'; | ||||
| import type { ImpactMetricsState } from '../types.ts'; | ||||
| 
 | ||||
| const TestComponent: FC = () => { | ||||
|     const { charts, layout } = useImpactMetricsState(); | ||||
| 
 | ||||
|     return ( | ||||
|         <div> | ||||
|             <span data-testid='charts-count'>{charts.length}</span> | ||||
|             <span data-testid='layout-count'>{layout.length}</span> | ||||
|         </div> | ||||
|     ); | ||||
| }; | ||||
| 
 | ||||
| const TestWrapper = () => ( | ||||
|     <Routes> | ||||
|         <Route path='/impact-metrics' element={<TestComponent />} /> | ||||
|     </Routes> | ||||
| ); | ||||
| 
 | ||||
| describe('useImpactMetricsState', () => { | ||||
|     beforeEach(() => { | ||||
|         window.localStorage.clear(); | ||||
|     }); | ||||
| 
 | ||||
|     it('loads state from localStorage to the URL after opening page without URL state', async () => { | ||||
|         const { setValue } = createLocalStorage<ImpactMetricsState>( | ||||
|             'impact-metrics-state', | ||||
|             { | ||||
|                 charts: [], | ||||
|                 layout: [], | ||||
|             }, | ||||
|         ); | ||||
| 
 | ||||
|         setValue({ | ||||
|             charts: [ | ||||
|                 { | ||||
|                     id: 'test-chart', | ||||
|                     selectedSeries: 'test-series', | ||||
|                     selectedRange: 'day' as const, | ||||
|                     beginAtZero: true, | ||||
|                     selectedLabels: {}, | ||||
|                     title: 'Test Chart', | ||||
|                 }, | ||||
|             ], | ||||
|             layout: [ | ||||
|                 { | ||||
|                     i: 'test-chart', | ||||
|                     x: 0, | ||||
|                     y: 0, | ||||
|                     w: 6, | ||||
|                     h: 4, | ||||
|                     minW: 4, | ||||
|                     minH: 2, | ||||
|                     maxW: 12, | ||||
|                     maxH: 8, | ||||
|                 }, | ||||
|             ], | ||||
|         }); | ||||
| 
 | ||||
|         render(<TestWrapper />, { route: '/impact-metrics' }); | ||||
| 
 | ||||
|         expect(window.location.href).toContain('charts='); | ||||
|         expect(window.location.href).toContain('layout='); | ||||
|     }); | ||||
| 
 | ||||
|     it('does not modify URL when URL already contains data', async () => { | ||||
|         const { setValue } = createLocalStorage<ImpactMetricsState>( | ||||
|             'impact-metrics-state', | ||||
|             { | ||||
|                 charts: [], | ||||
|                 layout: [], | ||||
|             }, | ||||
|         ); | ||||
| 
 | ||||
|         setValue({ | ||||
|             charts: [ | ||||
|                 { | ||||
|                     id: 'old-chart', | ||||
|                     selectedSeries: 'old-series', | ||||
|                     selectedRange: 'day' as const, | ||||
|                     beginAtZero: true, | ||||
|                     selectedLabels: {}, | ||||
|                     title: 'Old Chart', | ||||
|                 }, | ||||
|             ], | ||||
|             layout: [], | ||||
|         }); | ||||
| 
 | ||||
|         const urlCharts = btoa( | ||||
|             JSON.stringify([ | ||||
|                 { | ||||
|                     id: 'url-chart', | ||||
|                     selectedSeries: 'url-series', | ||||
|                     selectedRange: 'day', | ||||
|                     beginAtZero: true, | ||||
|                     selectedLabels: {}, | ||||
|                     title: 'URL Chart', | ||||
|                 }, | ||||
|             ]), | ||||
|         ); | ||||
| 
 | ||||
|         render(<TestWrapper />, { | ||||
|             route: `/impact-metrics?charts=${encodeURIComponent(urlCharts)}`, | ||||
|         }); | ||||
| 
 | ||||
|         const urlParams = new URLSearchParams(window.location.search); | ||||
|         const chartsParam = urlParams.get('charts'); | ||||
| 
 | ||||
|         expect(chartsParam).toBeTruthy(); | ||||
| 
 | ||||
|         const decodedCharts = JSON.parse(atob(chartsParam!)); | ||||
|         expect(decodedCharts[0].id).toBe('url-chart'); | ||||
|         expect(decodedCharts[0].id).not.toBe('old-chart'); | ||||
|     }); | ||||
| }); | ||||
| @ -0,0 +1,127 @@ | ||||
| import { useCallback, useMemo } from 'react'; | ||||
| import { withDefault } from 'use-query-params'; | ||||
| import { usePersistentTableState } from 'hooks/usePersistentTableState'; | ||||
| import type { ChartConfig, ImpactMetricsState, LayoutItem } from '../types.ts'; | ||||
| 
 | ||||
| const createArrayParam = <T>() => ({ | ||||
|     encode: (items: T[]): string => | ||||
|         items.length > 0 ? btoa(JSON.stringify(items)) : '', | ||||
| 
 | ||||
|     decode: (value: string | (string | null)[] | null | undefined): T[] => { | ||||
|         if (typeof value !== 'string' || !value) return []; | ||||
|         try { | ||||
|             return JSON.parse(atob(value)); | ||||
|         } catch { | ||||
|             return []; | ||||
|         } | ||||
|     }, | ||||
| }); | ||||
| 
 | ||||
| const ChartsParam = createArrayParam<ChartConfig>(); | ||||
| const LayoutParam = createArrayParam<LayoutItem>(); | ||||
| 
 | ||||
| export const useImpactMetricsState = () => { | ||||
|     const stateConfig = { | ||||
|         charts: withDefault(ChartsParam, []), | ||||
|         layout: withDefault(LayoutParam, []), | ||||
|     }; | ||||
| 
 | ||||
|     const [tableState, setTableState] = usePersistentTableState( | ||||
|         'impact-metrics-state', | ||||
|         stateConfig, | ||||
|     ); | ||||
| 
 | ||||
|     const currentState: ImpactMetricsState = useMemo( | ||||
|         () => ({ | ||||
|             charts: tableState.charts || [], | ||||
|             layout: tableState.layout || [], | ||||
|         }), | ||||
|         [tableState.charts, tableState.layout], | ||||
|     ); | ||||
| 
 | ||||
|     const updateState = useCallback( | ||||
|         (newState: ImpactMetricsState) => { | ||||
|             setTableState({ | ||||
|                 charts: newState.charts, | ||||
|                 layout: newState.layout, | ||||
|             }); | ||||
|         }, | ||||
|         [setTableState], | ||||
|     ); | ||||
| 
 | ||||
|     const addChart = useCallback( | ||||
|         (config: Omit<ChartConfig, 'id'>) => { | ||||
|             const newChart: ChartConfig = { | ||||
|                 ...config, | ||||
|                 id: `chart-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, | ||||
|             }; | ||||
| 
 | ||||
|             const maxY = | ||||
|                 currentState.layout.length > 0 | ||||
|                     ? Math.max( | ||||
|                           ...currentState.layout.map((item) => item.y + item.h), | ||||
|                       ) | ||||
|                     : 0; | ||||
| 
 | ||||
|             updateState({ | ||||
|                 charts: [...currentState.charts, newChart], | ||||
|                 layout: [ | ||||
|                     ...currentState.layout, | ||||
|                     { | ||||
|                         i: newChart.id, | ||||
|                         x: 0, | ||||
|                         y: maxY, | ||||
|                         w: 6, | ||||
|                         h: 4, | ||||
|                         minW: 4, | ||||
|                         minH: 2, | ||||
|                         maxW: 12, | ||||
|                         maxH: 8, | ||||
|                     }, | ||||
|                 ], | ||||
|             }); | ||||
|         }, | ||||
|         [currentState.charts, currentState.layout, updateState], | ||||
|     ); | ||||
| 
 | ||||
|     const updateChart = useCallback( | ||||
|         (id: string, updates: Partial<ChartConfig>) => { | ||||
|             updateState({ | ||||
|                 charts: currentState.charts.map((chart) => | ||||
|                     chart.id === id ? { ...chart, ...updates } : chart, | ||||
|                 ), | ||||
|                 layout: currentState.layout, | ||||
|             }); | ||||
|         }, | ||||
|         [currentState.charts, currentState.layout, updateState], | ||||
|     ); | ||||
| 
 | ||||
|     const deleteChart = useCallback( | ||||
|         (id: string) => { | ||||
|             updateState({ | ||||
|                 charts: currentState.charts.filter((chart) => chart.id !== id), | ||||
|                 layout: currentState.layout.filter((item) => item.i !== id), | ||||
|             }); | ||||
|         }, | ||||
|         [currentState.charts, currentState.layout, updateState], | ||||
|     ); | ||||
| 
 | ||||
|     const updateLayout = useCallback( | ||||
|         (newLayout: LayoutItem[]) => { | ||||
|             updateState({ | ||||
|                 charts: currentState.charts, | ||||
|                 layout: newLayout, | ||||
|             }); | ||||
|         }, | ||||
|         [currentState.charts, updateState], | ||||
|     ); | ||||
| 
 | ||||
|     return { | ||||
|         charts: currentState.charts || [], | ||||
|         layout: currentState.layout || [], | ||||
|         addChart, | ||||
|         updateChart, | ||||
|         deleteChart, | ||||
|         updateLayout, | ||||
|     }; | ||||
| }; | ||||
| @ -1,108 +0,0 @@ | ||||
| import { useCallback, useEffect } from 'react'; | ||||
| import { useSearchParams } from 'react-router-dom'; | ||||
| import { useLocalStorageState } from 'hooks/useLocalStorageState'; | ||||
| import type { ChartConfig, ImpactMetricsState } from '../types.ts'; | ||||
| 
 | ||||
| const encodeState = ( | ||||
|     state: ImpactMetricsState | null | undefined, | ||||
| ): string | undefined => | ||||
|     state && state.charts.length > 0 ? btoa(JSON.stringify(state)) : undefined; | ||||
| 
 | ||||
| const decodeState = ( | ||||
|     value: string | (string | null)[] | null | undefined, | ||||
| ): ImpactMetricsState | null => { | ||||
|     if (typeof value !== 'string') return null; | ||||
|     try { | ||||
|         return JSON.parse(atob(value)); | ||||
|     } catch { | ||||
|         return null; | ||||
|     } | ||||
| }; | ||||
| 
 | ||||
| export const useUrlState = () => { | ||||
|     const [searchParams, setSearchParams] = useSearchParams(); | ||||
|     const [storedState, setStoredState] = | ||||
|         useLocalStorageState<ImpactMetricsState>('impact-metrics-state', { | ||||
|             charts: [], | ||||
|         }); | ||||
| 
 | ||||
|     const urlState = decodeState(searchParams.get('data')); | ||||
|     const currentState = urlState || storedState; | ||||
| 
 | ||||
|     useEffect(() => { | ||||
|         if (urlState) { | ||||
|             setStoredState(urlState); | ||||
|         } else if (storedState.charts.length > 0) { | ||||
|             const encoded = encodeState(storedState); | ||||
|             if (encoded) { | ||||
|                 setSearchParams( | ||||
|                     (prev) => { | ||||
|                         prev.set('data', encoded); | ||||
|                         return prev; | ||||
|                     }, | ||||
|                     { replace: true }, | ||||
|                 ); | ||||
|             } | ||||
|         } | ||||
|     }, [urlState, storedState.charts.length, setStoredState, setSearchParams]); | ||||
| 
 | ||||
|     const updateState = useCallback( | ||||
|         (newState: ImpactMetricsState) => { | ||||
|             setStoredState(newState); | ||||
|             setSearchParams( | ||||
|                 (prev) => { | ||||
|                     const encoded = encodeState(newState); | ||||
|                     if (encoded) { | ||||
|                         prev.set('data', encoded); | ||||
|                     } else { | ||||
|                         prev.delete('data'); | ||||
|                     } | ||||
|                     return prev; | ||||
|                 }, | ||||
|                 { replace: true }, | ||||
|             ); | ||||
|         }, | ||||
|         [setStoredState, setSearchParams], | ||||
|     ); | ||||
| 
 | ||||
|     const addChart = useCallback( | ||||
|         (config: Omit<ChartConfig, 'id'>) => { | ||||
|             const newChart: ChartConfig = { | ||||
|                 ...config, | ||||
|                 id: `chart-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, | ||||
|             }; | ||||
| 
 | ||||
|             updateState({ | ||||
|                 charts: [...currentState.charts, newChart], | ||||
|             }); | ||||
|         }, | ||||
|         [currentState.charts, updateState], | ||||
|     ); | ||||
| 
 | ||||
|     const updateChart = useCallback( | ||||
|         (id: string, updates: Partial<ChartConfig>) => { | ||||
|             updateState({ | ||||
|                 charts: currentState.charts.map((chart) => | ||||
|                     chart.id === id ? { ...chart, ...updates } : chart, | ||||
|                 ), | ||||
|             }); | ||||
|         }, | ||||
|         [currentState.charts, updateState], | ||||
|     ); | ||||
| 
 | ||||
|     const deleteChart = useCallback( | ||||
|         (id: string) => { | ||||
|             updateState({ | ||||
|                 charts: currentState.charts.filter((chart) => chart.id !== id), | ||||
|             }); | ||||
|         }, | ||||
|         [currentState.charts, updateState], | ||||
|     ); | ||||
| 
 | ||||
|     return { | ||||
|         charts: currentState.charts, | ||||
|         addChart, | ||||
|         updateChart, | ||||
|         deleteChart, | ||||
|     }; | ||||
| }; | ||||
| @ -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[]; | ||||
| }; | ||||
|  | ||||
| @ -117,7 +117,7 @@ const LineChartComponent: FC<{ | ||||
|                 ), | ||||
|                 overrideOptions ?? {}, | ||||
|             ]), | ||||
|         [theme, locationSettings, overrideOptions, cover], | ||||
|         [theme, locationSettings, setTooltip, overrideOptions, cover], | ||||
|     ); | ||||
| 
 | ||||
|     return ( | ||||
|  | ||||
| @ -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": { | ||||
|  | ||||
| @ -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, | ||||
|  | ||||
| @ -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; | ||||
| } | ||||
|  | ||||
| @ -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" | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user