mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	feat: add lifecycle trend graphs (#10077)
Adds lifecycle trend graphs to the insights page. The graphs are each placed within their own boxes. The boxes do not have any more information in them yet. Also, because the data returned from the API is still all zeroes, I've used mock data that matches the sketches. Finally, the chart configuration and how it's split into a LifecycleChart that lazy loads a LifecycleChartComponent is based on the LineChart and LineChartComponent that we use elsewhere on the insights page. Light mode: <img width="1562" alt="image" src="https://github.com/user-attachments/assets/6dd11168-be24-42d4-aa97-a7a55651fa0e" /> We might want to tweak some colors in dark mode, but maybe not? 🤷🏼 
This commit is contained in:
		
							parent
							
								
									ae47771290
								
							
						
					
					
						commit
						16df33b078
					
				| @ -159,6 +159,7 @@ | |||||||
|   }, |   }, | ||||||
|   "packageManager": "yarn@4.9.1", |   "packageManager": "yarn@4.9.1", | ||||||
|   "dependencies": { |   "dependencies": { | ||||||
|  |     "chartjs-plugin-datalabels": "^2.2.0", | ||||||
|     "json-2-csv": "^5.5.5" |     "json-2-csv": "^5.5.5" | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  | |||||||
| @ -0,0 +1,5 @@ | |||||||
|  | import { lazy } from 'react'; | ||||||
|  | 
 | ||||||
|  | export const LifecycleChart = lazy( | ||||||
|  |     () => import('./LifecycleChartComponent.tsx'), | ||||||
|  | ); | ||||||
| @ -0,0 +1,121 @@ | |||||||
|  | import { type FC, useMemo } from 'react'; | ||||||
|  | import { | ||||||
|  |     CategoryScale, | ||||||
|  |     LinearScale, | ||||||
|  |     PointElement, | ||||||
|  |     Tooltip, | ||||||
|  |     Legend, | ||||||
|  |     TimeScale, | ||||||
|  |     Chart, | ||||||
|  |     Filler, | ||||||
|  |     type ChartData, | ||||||
|  |     type ChartOptions, | ||||||
|  |     BarElement, | ||||||
|  | } from 'chart.js'; | ||||||
|  | import { Bar } from 'react-chartjs-2'; | ||||||
|  | import 'chartjs-adapter-date-fns'; | ||||||
|  | import { type Theme, useTheme } from '@mui/material'; | ||||||
|  | import { useLocationSettings } from 'hooks/useLocationSettings'; | ||||||
|  | import merge from 'deepmerge'; | ||||||
|  | import ChartDataLabels from 'chartjs-plugin-datalabels'; | ||||||
|  | 
 | ||||||
|  | export const createOptions = (theme: Theme): ChartOptions<'bar'> => { | ||||||
|  |     const fontSize = 10; | ||||||
|  |     return { | ||||||
|  |         plugins: { | ||||||
|  |             legend: { | ||||||
|  |                 position: 'right', | ||||||
|  |                 maxWidth: 150, | ||||||
|  |                 align: 'start', | ||||||
|  |                 labels: { | ||||||
|  |                     color: theme.palette.text.secondary, | ||||||
|  |                     usePointStyle: true, | ||||||
|  |                     padding: 21, | ||||||
|  |                     boxHeight: 8, | ||||||
|  |                     font: { | ||||||
|  |                         size: fontSize, | ||||||
|  |                     }, | ||||||
|  |                 }, | ||||||
|  |             }, | ||||||
|  |             datalabels: { | ||||||
|  |                 color: theme.palette.text.primary, | ||||||
|  |                 font: { | ||||||
|  |                     weight: 'bold', | ||||||
|  |                     size: fontSize, | ||||||
|  |                 }, | ||||||
|  |                 anchor: 'end', | ||||||
|  |                 align: 'top', | ||||||
|  |                 offset: -6, | ||||||
|  |             }, | ||||||
|  |         }, | ||||||
|  |         aspectRatio: 2 / 1, | ||||||
|  |         responsive: true, | ||||||
|  |         color: theme.palette.text.secondary, | ||||||
|  |         scales: { | ||||||
|  |             y: { | ||||||
|  |                 beginAtZero: true, | ||||||
|  |                 grid: { | ||||||
|  |                     color: theme.palette.divider, | ||||||
|  |                     borderColor: theme.palette.divider, | ||||||
|  |                     drawBorder: false, | ||||||
|  |                 }, | ||||||
|  |                 ticks: { | ||||||
|  |                     stepSize: 1, | ||||||
|  |                     color: theme.palette.text.disabled, | ||||||
|  |                     font: { | ||||||
|  |                         size: fontSize, | ||||||
|  |                     }, | ||||||
|  |                 }, | ||||||
|  |             }, | ||||||
|  |             x: { | ||||||
|  |                 grid: { | ||||||
|  |                     display: false, | ||||||
|  |                 }, | ||||||
|  |                 ticks: { | ||||||
|  |                     color: theme.palette.text.primary, | ||||||
|  |                     font: { | ||||||
|  |                         size: fontSize, | ||||||
|  |                     }, | ||||||
|  |                 }, | ||||||
|  |             }, | ||||||
|  |         }, | ||||||
|  |     } as const; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | function mergeAll<T>(objects: Partial<T>[]): T { | ||||||
|  |     return merge.all<T>(objects.filter((i) => i)); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | const LifecycleChartComponent: FC<{ | ||||||
|  |     data: ChartData<'bar', unknown>; | ||||||
|  |     overrideOptions?: ChartOptions<'bar'>; | ||||||
|  | }> = ({ data, overrideOptions }) => { | ||||||
|  |     const theme = useTheme(); | ||||||
|  |     const { locationSettings } = useLocationSettings(); | ||||||
|  | 
 | ||||||
|  |     const options = useMemo( | ||||||
|  |         () => mergeAll([createOptions(theme)]), | ||||||
|  |         [theme, locationSettings, overrideOptions], | ||||||
|  |     ); | ||||||
|  | 
 | ||||||
|  |     return ( | ||||||
|  |         <> | ||||||
|  |             <Bar options={options} data={data} plugins={[ChartDataLabels]} /> | ||||||
|  |             {/* todo: implement fallback for screen readers */} | ||||||
|  |         </> | ||||||
|  |     ); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | Chart.register( | ||||||
|  |     CategoryScale, | ||||||
|  |     LinearScale, | ||||||
|  |     PointElement, | ||||||
|  |     BarElement, | ||||||
|  |     TimeScale, | ||||||
|  |     Tooltip, | ||||||
|  |     Legend, | ||||||
|  |     Filler, | ||||||
|  | ); | ||||||
|  | 
 | ||||||
|  | // for lazy-loading
 | ||||||
|  | export default LifecycleChartComponent; | ||||||
| @ -3,13 +3,73 @@ import type { FC } from 'react'; | |||||||
| import { FilterItemParam } from 'utils/serializeQueryParams'; | import { FilterItemParam } from 'utils/serializeQueryParams'; | ||||||
| import { InsightsSection } from 'component/insights/sections/InsightsSection'; | import { InsightsSection } from 'component/insights/sections/InsightsSection'; | ||||||
| import { InsightsFilters } from 'component/insights/InsightsFilters'; | import { InsightsFilters } from 'component/insights/InsightsFilters'; | ||||||
|  | import { allOption } from 'component/common/ProjectSelect/ProjectSelect'; | ||||||
|  | import { useInsights } from 'hooks/api/getters/useInsights/useInsights'; | ||||||
|  | import { LifecycleChart } from '../components/LifecycleChart/LifecycleChart.tsx'; | ||||||
|  | import { styled, useTheme } from '@mui/material'; | ||||||
|  | 
 | ||||||
|  | type LifecycleTrend = { | ||||||
|  |     totalFlags: number; | ||||||
|  |     averageTimeInStageDays: number; | ||||||
|  |     categories: { | ||||||
|  |         experimental: { | ||||||
|  |             flagsOlderThanWeek: number; | ||||||
|  |             newFlagsThisWeek: number; | ||||||
|  |         }; | ||||||
|  |         release: { | ||||||
|  |             flagsOlderThanWeek: number; | ||||||
|  |             newFlagsThisWeek: number; | ||||||
|  |         }; | ||||||
|  |         permanent: { | ||||||
|  |             flagsOlderThanWeek: number; | ||||||
|  |             newFlagsThisWeek: number; | ||||||
|  |         }; | ||||||
|  |     }; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | type LifecycleInsights = { | ||||||
|  |     develop: LifecycleTrend; | ||||||
|  |     production: LifecycleTrend; | ||||||
|  |     cleanup: LifecycleTrend; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | const useChartColors = () => { | ||||||
|  |     const theme = useTheme(); | ||||||
|  |     return { | ||||||
|  |         olderThanWeek: theme.palette.primary.light, | ||||||
|  |         newThisWeek: theme.palette.success.border, | ||||||
|  |     }; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | const ChartRow = styled('div')(({ theme }) => ({ | ||||||
|  |     display: 'grid', | ||||||
|  |     gridTemplateColumns: 'repeat(3, 1fr)', | ||||||
|  |     gap: theme.spacing(2), | ||||||
|  | })); | ||||||
|  | 
 | ||||||
|  | const ChartContainer = styled('article')(({ theme }) => ({ | ||||||
|  |     background: theme.palette.background.default, | ||||||
|  |     borderRadius: theme.shape.borderRadiusLarge, | ||||||
|  |     padding: theme.spacing(2), | ||||||
|  |     minWidth: 0, | ||||||
|  | })); | ||||||
| 
 | 
 | ||||||
| export const LifecycleInsights: FC = () => { | export const LifecycleInsights: FC = () => { | ||||||
|     const statePrefix = 'lifecycle-'; |     const statePrefix = 'lifecycle-'; | ||||||
|     const stateConfig = { |     const stateConfig = { | ||||||
|         [`${statePrefix}project`]: FilterItemParam, |         [`${statePrefix}project`]: FilterItemParam, | ||||||
|     }; |     }; | ||||||
|     const [state, setState] = usePersistentTableState('insights', stateConfig); |     const [state, setState] = usePersistentTableState( | ||||||
|  |         'insights-lifecycle', | ||||||
|  |         stateConfig, | ||||||
|  |     ); | ||||||
|  | 
 | ||||||
|  |     // todo: use data from the actual endpoint when we have something useful to return
 | ||||||
|  |     const projects = state[`${statePrefix}project`]?.values ?? [allOption.id]; | ||||||
|  |     const { insights, loading } = useInsights(); | ||||||
|  | 
 | ||||||
|  |     // @ts-expect-error (lifecycleMetrics): The schema hasn't been updated yet.
 | ||||||
|  |     const { lifecycleTrends } = insights; | ||||||
| 
 | 
 | ||||||
|     return ( |     return ( | ||||||
|         <InsightsSection |         <InsightsSection | ||||||
| @ -21,6 +81,144 @@ export const LifecycleInsights: FC = () => { | |||||||
|                     filterNamePrefix={statePrefix} |                     filterNamePrefix={statePrefix} | ||||||
|                 /> |                 /> | ||||||
|             } |             } | ||||||
|  |         > | ||||||
|  |             <ChartRow> | ||||||
|  |                 {Object.entries(mockData).map(([stage, data]) => { | ||||||
|  |                     return ( | ||||||
|  |                         <ChartContainer key={stage}> | ||||||
|  |                             <Chart data={data} /> | ||||||
|  |                         </ChartContainer> | ||||||
|  |                     ); | ||||||
|  |                 })} | ||||||
|  |             </ChartRow> | ||||||
|  |         </InsightsSection> | ||||||
|  |     ); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | const Chart: React.FC<{ data: LifecycleTrend }> = ({ data }) => { | ||||||
|  |     const chartColors = useChartColors(); | ||||||
|  |     const oldData = [ | ||||||
|  |         data.categories.experimental.flagsOlderThanWeek, | ||||||
|  |         data.categories.release.flagsOlderThanWeek, | ||||||
|  |         data.categories.permanent.flagsOlderThanWeek, | ||||||
|  |     ]; | ||||||
|  |     return ( | ||||||
|  |         <LifecycleChart | ||||||
|  |             data={{ | ||||||
|  |                 labels: [`Experimental`, `Release`, `Other flags`], | ||||||
|  |                 datasets: [ | ||||||
|  |                     { | ||||||
|  |                         label: 'Flags > 1 week old', | ||||||
|  |                         data: oldData, | ||||||
|  |                         stack: '1', | ||||||
|  |                         backgroundColor: chartColors.olderThanWeek, | ||||||
|  |                         borderRadius: 4, | ||||||
|  |                         datalabels: { | ||||||
|  |                             labels: { | ||||||
|  |                                 value: { | ||||||
|  |                                     formatter: (value, context) => { | ||||||
|  |                                         // todo (lifecycleMetrics): use a nice
 | ||||||
|  |                                         // formatter here, so that 1,000,000
 | ||||||
|  |                                         // flags are instead formatted as 1M
 | ||||||
|  |                                         if ( | ||||||
|  |                                             context.chart.legend | ||||||
|  |                                                 ?.legendItems?.[1].hidden | ||||||
|  |                                         ) { | ||||||
|  |                                             return value; | ||||||
|  |                                         } | ||||||
|  |                                         return ''; | ||||||
|  |                                     }, | ||||||
|  |                                 }, | ||||||
|  |                             }, | ||||||
|  |                         }, | ||||||
|  |                     }, | ||||||
|  |                     { | ||||||
|  |                         label: 'New flags this week', | ||||||
|  |                         data: [ | ||||||
|  |                             data.categories.experimental.newFlagsThisWeek, | ||||||
|  |                             data.categories.release.newFlagsThisWeek, | ||||||
|  |                             data.categories.permanent.newFlagsThisWeek, | ||||||
|  |                         ], | ||||||
|  |                         stack: '1', | ||||||
|  |                         backgroundColor: chartColors.newThisWeek, | ||||||
|  |                         borderRadius: 4, | ||||||
|  |                         datalabels: { | ||||||
|  |                             labels: { | ||||||
|  |                                 value: { | ||||||
|  |                                     formatter: (value, context) => { | ||||||
|  |                                         if ( | ||||||
|  |                                             context.chart.legend | ||||||
|  |                                                 ?.legendItems?.[0].hidden | ||||||
|  |                                         ) { | ||||||
|  |                                             return value; | ||||||
|  |                                         } | ||||||
|  |                                         return ( | ||||||
|  |                                             value + oldData[context.dataIndex] | ||||||
|  |                                         ); | ||||||
|  |                                     }, | ||||||
|  |                                 }, | ||||||
|  |                             }, | ||||||
|  |                         }, | ||||||
|  |                     }, | ||||||
|  |                 ], | ||||||
|  |             }} | ||||||
|         /> |         /> | ||||||
|     ); |     ); | ||||||
| }; | }; | ||||||
|  | 
 | ||||||
|  | const mockData: LifecycleInsights = { | ||||||
|  |     develop: { | ||||||
|  |         totalFlags: 35, | ||||||
|  |         averageTimeInStageDays: 28, | ||||||
|  |         categories: { | ||||||
|  |             experimental: { | ||||||
|  |                 flagsOlderThanWeek: 11, | ||||||
|  |                 newFlagsThisWeek: 4, | ||||||
|  |             }, | ||||||
|  |             release: { | ||||||
|  |                 flagsOlderThanWeek: 12, | ||||||
|  |                 newFlagsThisWeek: 1, | ||||||
|  |             }, | ||||||
|  |             permanent: { | ||||||
|  |                 flagsOlderThanWeek: 7, | ||||||
|  |                 newFlagsThisWeek: 0, | ||||||
|  |             }, | ||||||
|  |         }, | ||||||
|  |     }, | ||||||
|  |     production: { | ||||||
|  |         totalFlags: 10, | ||||||
|  |         averageTimeInStageDays: 14, | ||||||
|  |         categories: { | ||||||
|  |             experimental: { | ||||||
|  |                 flagsOlderThanWeek: 2, | ||||||
|  |                 newFlagsThisWeek: 3, | ||||||
|  |             }, | ||||||
|  |             release: { | ||||||
|  |                 flagsOlderThanWeek: 1, | ||||||
|  |                 newFlagsThisWeek: 1, | ||||||
|  |             }, | ||||||
|  |             permanent: { | ||||||
|  |                 flagsOlderThanWeek: 3, | ||||||
|  |                 newFlagsThisWeek: 0, | ||||||
|  |             }, | ||||||
|  |         }, | ||||||
|  |     }, | ||||||
|  |     cleanup: { | ||||||
|  |         totalFlags: 5, | ||||||
|  |         averageTimeInStageDays: 16, | ||||||
|  |         categories: { | ||||||
|  |             experimental: { | ||||||
|  |                 flagsOlderThanWeek: 0, | ||||||
|  |                 newFlagsThisWeek: 3, | ||||||
|  |             }, | ||||||
|  |             release: { | ||||||
|  |                 flagsOlderThanWeek: 0, | ||||||
|  |                 newFlagsThisWeek: 1, | ||||||
|  |             }, | ||||||
|  |             permanent: { | ||||||
|  |                 flagsOlderThanWeek: 1, | ||||||
|  |                 newFlagsThisWeek: 0, | ||||||
|  |             }, | ||||||
|  |         }, | ||||||
|  |     }, | ||||||
|  | }; | ||||||
|  | |||||||
| @ -4106,6 +4106,15 @@ __metadata: | |||||||
|   languageName: node |   languageName: node | ||||||
|   linkType: hard |   linkType: hard | ||||||
| 
 | 
 | ||||||
|  | "chartjs-plugin-datalabels@npm:^2.2.0": | ||||||
|  |   version: 2.2.0 | ||||||
|  |   resolution: "chartjs-plugin-datalabels@npm:2.2.0" | ||||||
|  |   peerDependencies: | ||||||
|  |     chart.js: ">=3.0.0" | ||||||
|  |   checksum: 10c0/de4855a795e4eef34869a16db1a8a0f905b6dfed0258c733338f472625361eb56fb899214b18651c1c1064cd343a78285ba576576693a40ec51285a84f022ea0 | ||||||
|  |   languageName: node | ||||||
|  |   linkType: hard | ||||||
|  | 
 | ||||||
| "check-error@npm:^2.1.1": | "check-error@npm:^2.1.1": | ||||||
|   version: 2.1.1 |   version: 2.1.1 | ||||||
|   resolution: "check-error@npm:2.1.1" |   resolution: "check-error@npm:2.1.1" | ||||||
| @ -10138,6 +10147,7 @@ __metadata: | |||||||
|     chart.js: "npm:3.9.1" |     chart.js: "npm:3.9.1" | ||||||
|     chartjs-adapter-date-fns: "npm:3.0.0" |     chartjs-adapter-date-fns: "npm:3.0.0" | ||||||
|     chartjs-plugin-annotation: "npm:2.2.1" |     chartjs-plugin-annotation: "npm:2.2.1" | ||||||
|  |     chartjs-plugin-datalabels: "npm:^2.2.0" | ||||||
|     classnames: "npm:2.5.1" |     classnames: "npm:2.5.1" | ||||||
|     copy-to-clipboard: "npm:3.3.3" |     copy-to-clipboard: "npm:3.3.3" | ||||||
|     countries-and-timezones: "npm:^3.4.0" |     countries-and-timezones: "npm:^3.4.0" | ||||||
|  | |||||||
		Loading…
	
		Reference in New Issue
	
	Block a user