mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	Merge branch 'main' into feat/simplify-imports
This commit is contained in:
		
						commit
						aeaea9602c
					
				| @ -129,7 +129,7 @@ describe('feature toggle', () => { | ||||
|     }); | ||||
| 
 | ||||
|     it('Can add a gradual rollout strategy to the development environment', () => { | ||||
|         cy.wait(500); | ||||
|         cy.wait(1000); | ||||
|         cy.visit(`/projects/default/features/${featureToggleName}/strategies`); | ||||
|         cy.get('[data-test=ADD_NEW_STRATEGY_ID]').click(); | ||||
|         cy.get('[data-test=ADD_NEW_STRATEGY_CARD_BUTTON_ID-2').click(); | ||||
| @ -172,7 +172,7 @@ describe('feature toggle', () => { | ||||
|     }); | ||||
| 
 | ||||
|     it('can update a strategy in the development environment', () => { | ||||
|         cy.wait(500); | ||||
|         cy.wait(1000); | ||||
|         cy.visit(`/projects/default/features/${featureToggleName}/strategies`); | ||||
|         cy.get('[data-test=STRATEGY_ACCORDION_ID-flexibleRollout').click(); | ||||
| 
 | ||||
| @ -219,7 +219,7 @@ describe('feature toggle', () => { | ||||
|     }); | ||||
| 
 | ||||
|     it('can delete a strategy in the development environment', () => { | ||||
|         cy.wait(500); | ||||
|         cy.wait(1000); | ||||
|         cy.visit(`/projects/default/features/${featureToggleName}/strategies`); | ||||
| 
 | ||||
|         cy.intercept( | ||||
| @ -238,7 +238,7 @@ describe('feature toggle', () => { | ||||
|     }); | ||||
| 
 | ||||
|     it('Can add a userid  strategy to the development environment', () => { | ||||
|         cy.wait(500); | ||||
|         cy.wait(1000); | ||||
|         cy.visit(`/projects/default/features/${featureToggleName}/strategies`); | ||||
|         cy.get('[data-test=ADD_NEW_STRATEGY_ID]').click(); | ||||
|         cy.get('[data-test=ADD_NEW_STRATEGY_CARD_BUTTON_ID-3').click(); | ||||
| @ -285,7 +285,7 @@ describe('feature toggle', () => { | ||||
|     it('Can add two variant to the feature', () => { | ||||
|         const variantName = 'my-new-variant'; | ||||
|         const secondVariantName = 'my-second-variant'; | ||||
|         cy.wait(500); | ||||
|         cy.wait(1000); | ||||
|         cy.visit(`/projects/default/features/${featureToggleName}/variants`); | ||||
|         cy.intercept( | ||||
|             'PATCH', | ||||
| @ -315,7 +315,7 @@ describe('feature toggle', () => { | ||||
|         cy.wait('@variantcreation'); | ||||
|     }); | ||||
|     it('Can set weight to fixed value for one of the variants', () => { | ||||
|         cy.wait(500); | ||||
|         cy.wait(1000); | ||||
| 
 | ||||
|         cy.visit(`/projects/default/features/${featureToggleName}/variants`); | ||||
|         cy.get('[data-test=VARIANT_EDIT_BUTTON]').first().click(); | ||||
| @ -352,7 +352,7 @@ describe('feature toggle', () => { | ||||
| 
 | ||||
|     it(`can delete variant`, () => { | ||||
|         const variantName = 'to-be-deleted'; | ||||
|         cy.wait(500); | ||||
|         cy.wait(1000); | ||||
|         cy.visit(`/projects/default/features/${featureToggleName}/variants`); | ||||
|         cy.get('[data-test=ADD_VARIANT_BUTTON]').click(); | ||||
|         cy.get('[data-test=VARIANT_NAME_INPUT]').type(variantName); | ||||
|  | ||||
| @ -1,7 +1,7 @@ | ||||
| { | ||||
|   "name": "unleash-frontend", | ||||
|   "description": "unleash your features", | ||||
|   "version": "4.8.0-beta.5", | ||||
|   "version": "4.8.0-beta.7", | ||||
|   "keywords": [ | ||||
|     "unleash", | ||||
|     "feature toggle", | ||||
| @ -48,6 +48,7 @@ | ||||
|     "@types/debounce": "1.2.1", | ||||
|     "@types/deep-diff": "1.0.1", | ||||
|     "@types/jest": "27.4.0", | ||||
|     "@types/lodash.clonedeep": "4.5.6", | ||||
|     "@types/node": "14.18.12", | ||||
|     "@types/react": "17.0.39", | ||||
|     "@types/react-dom": "17.0.11", | ||||
| @ -56,6 +57,8 @@ | ||||
|     "@types/react-test-renderer": "17.0.1", | ||||
|     "@types/react-timeago": "4.1.3", | ||||
|     "@welldone-software/why-did-you-render": "6.2.3", | ||||
|     "chart.js": "3.7.1", | ||||
|     "chartjs-adapter-date-fns": "2.0.0", | ||||
|     "classnames": "2.3.1", | ||||
|     "copy-to-clipboard": "3.3.1", | ||||
|     "craco": "0.0.3", | ||||
| @ -66,12 +69,12 @@ | ||||
|     "deep-diff": "1.0.2", | ||||
|     "fast-json-patch": "3.1.0", | ||||
|     "http-proxy-middleware": "2.0.3", | ||||
|     "@types/lodash.clonedeep": "4.5.6", | ||||
|     "lodash.clonedeep": "4.5.0", | ||||
|     "lodash.flow": "3.5.0", | ||||
|     "prettier": "2.5.1", | ||||
|     "prop-types": "15.8.1", | ||||
|     "react": "17.0.2", | ||||
|     "react-chartjs-2": "4.0.1", | ||||
|     "react-dnd": "14.0.5", | ||||
|     "react-dnd-html5-backend": "14.1.0", | ||||
|     "react-dom": "17.0.2", | ||||
| @ -82,7 +85,7 @@ | ||||
|     "react-test-renderer": "16.14.0", | ||||
|     "react-timeago": "6.2.1", | ||||
|     "sass": "1.49.8", | ||||
|     "swr": "1.2.1", | ||||
|     "swr": "1.2.2", | ||||
|     "typescript": "4.5.5", | ||||
|     "web-vitals": "2.1.4" | ||||
|   }, | ||||
|  | ||||
| @ -133,6 +133,10 @@ p { | ||||
|     font-size: var(--p-size); | ||||
| } | ||||
| 
 | ||||
| [hidden] { | ||||
|     display: none !important; | ||||
| } | ||||
| 
 | ||||
| @media screen and (max-width: 1024px) { | ||||
|     :root { | ||||
|         --drawer-padding: 0.75rem 1.25rem; | ||||
|  | ||||
| @ -7,6 +7,7 @@ export interface ISelectOption { | ||||
|     title?: string; | ||||
|     label?: string; | ||||
| } | ||||
| 
 | ||||
| export interface ISelectMenuProps { | ||||
|     name: string; | ||||
|     id: string; | ||||
| @ -14,16 +15,19 @@ export interface ISelectMenuProps { | ||||
|     label?: string; | ||||
|     options: ISelectOption[]; | ||||
|     style?: object; | ||||
|     onChange?: ( | ||||
|         event: React.ChangeEvent<{ name?: string; value: unknown }>, | ||||
|         child: React.ReactNode | ||||
|     ) => void; | ||||
|     onChange?: OnGeneralSelectChange; | ||||
|     disabled?: boolean; | ||||
|     fullWidth?: boolean; | ||||
|     className?: string; | ||||
|     classes?: any; | ||||
|     defaultValue?: string; | ||||
| } | ||||
| 
 | ||||
| export type OnGeneralSelectChange = ( | ||||
|     event: React.ChangeEvent<{ name?: string; value: unknown }>, | ||||
|     child: React.ReactNode | ||||
| ) => void; | ||||
| 
 | ||||
| const GeneralSelect: React.FC<ISelectMenuProps> = ({ | ||||
|     name, | ||||
|     value = '', | ||||
| @ -35,6 +39,7 @@ const GeneralSelect: React.FC<ISelectMenuProps> = ({ | ||||
|     disabled = false, | ||||
|     className, | ||||
|     classes, | ||||
|     fullWidth, | ||||
|     ...rest | ||||
| }) => { | ||||
|     const renderSelectItems = () => | ||||
| @ -50,10 +55,13 @@ const GeneralSelect: React.FC<ISelectMenuProps> = ({ | ||||
|         )); | ||||
| 
 | ||||
|     return ( | ||||
|         <FormControl variant="outlined" size="small" classes={classes}> | ||||
|             <InputLabel htmlFor={id} id={id}> | ||||
|                 {label} | ||||
|             </InputLabel> | ||||
|         <FormControl | ||||
|             variant="outlined" | ||||
|             size="small" | ||||
|             classes={classes} | ||||
|             fullWidth={fullWidth} | ||||
|         > | ||||
|             <InputLabel htmlFor={id}>{label}</InputLabel> | ||||
|             <Select | ||||
|                 defaultValue={defaultValue} | ||||
|                 name={name} | ||||
|  | ||||
| @ -3,7 +3,7 @@ import { useStyles } from './StatusChip.styles'; | ||||
| 
 | ||||
| interface IStatusChip { | ||||
|     stale: boolean; | ||||
|     showActive: true; | ||||
|     showActive?: true; | ||||
| } | ||||
| 
 | ||||
| const StatusChip = ({ stale, showActive = true }: IStatusChip) => { | ||||
|  | ||||
| @ -16,6 +16,11 @@ const dateOptions = { | ||||
|     year: 'numeric', | ||||
| }; | ||||
| 
 | ||||
| const timeOptions = { | ||||
|     hour: '2-digit', | ||||
|     minute: '2-digit', | ||||
| }; | ||||
| 
 | ||||
| export const filterByFlags = flags => r => { | ||||
|     if (r.flag && !flags[r.flag]) { | ||||
|         return false; | ||||
| @ -41,6 +46,13 @@ export const formatDateWithLocale = (v, locale, tz) => { | ||||
|     return new Date(v).toLocaleString(locale, dateOptions); | ||||
| }; | ||||
| 
 | ||||
| export const formatTimeWithLocale = (v, locale, tz) => { | ||||
|     if (tz) { | ||||
|         dateTimeOptions.timeZone = tz; | ||||
|     } | ||||
|     return new Date(v).toLocaleString(locale, timeOptions); | ||||
| }; | ||||
| 
 | ||||
| export const trim = value => { | ||||
|     if (value && value.trim) { | ||||
|         return value.trim(); | ||||
|  | ||||
| @ -1,26 +1,9 @@ | ||||
| import { makeStyles } from '@material-ui/core/styles'; | ||||
| 
 | ||||
| export const useStyles = makeStyles(theme => ({ | ||||
|     environmentContainer: { | ||||
|         display: 'flex', | ||||
|         marginBottom: '1rem', | ||||
|         flexWrap: 'wrap', | ||||
|         width: '100%', | ||||
|         position: 'relative', | ||||
|         justifyContent: 'center', | ||||
|     }, | ||||
|     environmentMetrics: { | ||||
|         border: `1px solid ${theme.palette.grey[300]}`, | ||||
|         margin: '0.5rem', | ||||
|         width: '30%', | ||||
|     }, | ||||
|     [theme.breakpoints.down(1000)]: { | ||||
|         environmentMetrics: { width: '50%' }, | ||||
|     }, | ||||
|     [theme.breakpoints.down(750)]: { | ||||
|         environmentMetrics: { width: '100%' }, | ||||
|     }, | ||||
|     secondaryContent: { | ||||
|         marginTop: '1rem', | ||||
|     mobileMarginTop: { | ||||
|         [theme.breakpoints.down('sm')]: { | ||||
|             marginTop: theme.spacing(2), | ||||
|         }, | ||||
|     }, | ||||
| })); | ||||
|  | ||||
| @ -1,50 +1,138 @@ | ||||
| import { useParams } from 'react-router'; | ||||
| import useFeature from '../../../../hooks/api/getters/useFeature/useFeature'; | ||||
| import { IFeatureViewParams } from '../../../../interfaces/params'; | ||||
| import useFeatureMetrics from '../../../../hooks/api/getters/useFeatureMetrics/useFeatureMetrics'; | ||||
| import FeatureEnvironmentMetrics from '../FeatureOverview/FeatureEnvironmentMetrics/FeatureEnvironmentMetrics'; | ||||
| import FeatureSeenApplications from '../FeatureSeenApplications/FeatureSeenApplications'; | ||||
| import { useFeatureMetricsRaw } from '../../../../hooks/api/getters/useFeatureMetricsRaw/useFeatureMetricsRaw'; | ||||
| import PageContent from '../../../common/PageContent'; | ||||
| 
 | ||||
| import { useEffect, useMemo, useState } from 'react'; | ||||
| import { | ||||
|     FEATURE_METRIC_HOURS_BACK_MAX, | ||||
|     FeatureMetricsHours, | ||||
| } from './FeatureMetricsHours/FeatureMetricsHours'; | ||||
| import { IFeatureMetricsRaw } from '../../../../interfaces/featureToggle'; | ||||
| import { Grid } from '@material-ui/core'; | ||||
| import { FeatureMetricsContent } from './FeatureMetricsContent/FeatureMetricsContent'; | ||||
| import { useQueryStringNumberState } from '../../../../hooks/useQueryStringNumberState'; | ||||
| import { useQueryStringState } from '../../../../hooks/useQueryStringState'; | ||||
| import { FeatureMetricsChips } from './FeatureMetricsChips/FeatureMetricsChips'; | ||||
| import useFeature from '../../../../hooks/api/getters/useFeature/useFeature'; | ||||
| import ConditionallyRender from '../../../common/ConditionallyRender'; | ||||
| import { useStyles } from './FeatureMetrics.styles'; | ||||
| import { getFeatureMetrics } from '../../../../utils/get-feature-metrics'; | ||||
| 
 | ||||
| const FeatureMetrics = () => { | ||||
| export const FeatureMetrics = () => { | ||||
|     const { projectId, featureId } = useParams<IFeatureViewParams>(); | ||||
|     const { feature } = useFeature(projectId, featureId); | ||||
|     const { metrics } = useFeatureMetrics(projectId, featureId); | ||||
|     const environments = useFeatureMetricsEnvironments(projectId, featureId); | ||||
|     const applications = useFeatureMetricsApplications(featureId); | ||||
|     const styles = useStyles(); | ||||
| 
 | ||||
|     const featureMetrics = getFeatureMetrics(feature?.environments, metrics); | ||||
|     const [hoursBack = FEATURE_METRIC_HOURS_BACK_MAX, setHoursBack] = | ||||
|         useQueryStringNumberState('hoursBack'); | ||||
|     const { featureMetrics } = useFeatureMetricsRaw(featureId, hoursBack); | ||||
| 
 | ||||
|     const metricComponents = featureMetrics.map(metric => { | ||||
|         return ( | ||||
|             <FeatureEnvironmentMetrics | ||||
|                 key={metric.environment} | ||||
|                 className={styles.environmentMetrics} | ||||
|                 metric={metric} | ||||
|             /> | ||||
|         ); | ||||
|     }); | ||||
|     // Keep a cache of the fetched metrics so that we can
 | ||||
|     // show the cached result while fetching new metrics.
 | ||||
|     const [cachedMetrics, setCachedMetrics] = useState< | ||||
|         Readonly<IFeatureMetricsRaw[]> | undefined | ||||
|     >(featureMetrics); | ||||
| 
 | ||||
|     useEffect(() => { | ||||
|         featureMetrics && setCachedMetrics(featureMetrics); | ||||
|     }, [featureMetrics]); | ||||
| 
 | ||||
|     const defaultEnvironment = Array.from(environments)[0]; | ||||
|     const defaultApplication = Array.from(applications)[0]; | ||||
|     const [environment = defaultEnvironment, setEnvironment] = | ||||
|         useQueryStringState('environment'); | ||||
|     const [application = defaultApplication, setApplication] = | ||||
|         useQueryStringState('application'); | ||||
| 
 | ||||
|     const filteredMetrics = useMemo(() => { | ||||
|         return cachedMetrics | ||||
|             ?.filter(metric => metric.environment === environment) | ||||
|             .filter(metric => metric.appName === application); | ||||
|     }, [cachedMetrics, environment, application]); | ||||
| 
 | ||||
|     if (!filteredMetrics) { | ||||
|         return null; | ||||
|     } | ||||
| 
 | ||||
|     return ( | ||||
|         <> | ||||
|             <PageContent headerContent="Environment metrics"> | ||||
|                 <div className={styles.environmentContainer}> | ||||
|                     {metricComponents} | ||||
|                 </div> | ||||
|             </PageContent> | ||||
| 
 | ||||
|             <PageContent | ||||
|                 headerContent="Applications" | ||||
|                 className={styles.secondaryContent} | ||||
|         <PageContent headerContent=""> | ||||
|             <Grid | ||||
|                 container | ||||
|                 component="header" | ||||
|                 spacing={2} | ||||
|                 alignItems="flex-end" | ||||
|             > | ||||
|                 <div className={styles.applicationsContainer}> | ||||
|                     <FeatureSeenApplications /> | ||||
|                 </div> | ||||
|             </PageContent> | ||||
|         </> | ||||
|                 <Grid item xs={12} md={5}> | ||||
|                     <ConditionallyRender | ||||
|                         condition={environments.size > 0} | ||||
|                         show={ | ||||
|                             <FeatureMetricsChips | ||||
|                                 title="Environments" | ||||
|                                 values={environments} | ||||
|                                 value={environment} | ||||
|                                 setValue={setEnvironment} | ||||
|                             /> | ||||
|                         } | ||||
|                     /> | ||||
|                 </Grid> | ||||
|                 <Grid item xs={12} md={5}> | ||||
|                     <ConditionallyRender | ||||
|                         condition={applications.size > 0} | ||||
|                         show={ | ||||
|                             <FeatureMetricsChips | ||||
|                                 title="Applications" | ||||
|                                 values={applications} | ||||
|                                 value={application} | ||||
|                                 setValue={setApplication} | ||||
|                             /> | ||||
|                         } | ||||
|                     /> | ||||
|                 </Grid> | ||||
|                 <Grid item xs={12} md={2}> | ||||
|                     <div className={styles.mobileMarginTop}> | ||||
|                         <FeatureMetricsHours | ||||
|                             hoursBack={hoursBack} | ||||
|                             setHoursBack={setHoursBack} | ||||
|                         /> | ||||
|                     </div> | ||||
|                 </Grid> | ||||
|             </Grid> | ||||
|             <FeatureMetricsContent | ||||
|                 metrics={filteredMetrics} | ||||
|                 hoursBack={hoursBack} | ||||
|             /> | ||||
|         </PageContent> | ||||
|     ); | ||||
| }; | ||||
| 
 | ||||
| // Get all the environment names for a feature,
 | ||||
| // not just the one's we have metrics for.
 | ||||
| const useFeatureMetricsEnvironments = ( | ||||
|     projectId: string, | ||||
|     featureId: string | ||||
| ): Set<string> => { | ||||
|     const { feature } = useFeature(projectId, featureId); | ||||
| 
 | ||||
|     const environments = feature.environments.map(environment => { | ||||
|         return environment.name; | ||||
|     }); | ||||
| 
 | ||||
|     return new Set(environments); | ||||
| }; | ||||
| 
 | ||||
| // Get all application names for a feature. Fetch apps for the max time range
 | ||||
| // so that the list of apps doesn't change when selecting a shorter range.
 | ||||
| const useFeatureMetricsApplications = (featureId: string): Set<string> => { | ||||
|     const { featureMetrics = [] } = useFeatureMetricsRaw( | ||||
|         featureId, | ||||
|         FEATURE_METRIC_HOURS_BACK_MAX | ||||
|     ); | ||||
| 
 | ||||
|     const applications = featureMetrics.map(metric => { | ||||
|         return metric.appName; | ||||
|     }); | ||||
| 
 | ||||
|     return new Set(applications); | ||||
| }; | ||||
| 
 | ||||
| export default FeatureMetrics; | ||||
|  | ||||
| @ -0,0 +1,68 @@ | ||||
| import { IFeatureMetricsRaw } from '../../../../../interfaces/featureToggle'; | ||||
| import React, { useMemo } from 'react'; | ||||
| import { Line } from 'react-chartjs-2'; | ||||
| import { | ||||
|     CategoryScale, | ||||
|     Chart as ChartJS, | ||||
|     Legend, | ||||
|     LinearScale, | ||||
|     LineElement, | ||||
|     PointElement, | ||||
|     TimeScale, | ||||
|     Title, | ||||
|     Tooltip, | ||||
| } from 'chart.js'; | ||||
| import { useLocationSettings } from '../../../../../hooks/useLocationSettings'; | ||||
| import { FEATURE_METRICS_TABLE_ID } from '../FeatureMetricsTable/FeatureMetricsTable'; | ||||
| import 'chartjs-adapter-date-fns'; | ||||
| import { createChartData } from './createChartData'; | ||||
| import { createChartOptions } from './createChartOptions'; | ||||
| 
 | ||||
| interface IFeatureMetricsChartProps { | ||||
|     metrics: IFeatureMetricsRaw[]; | ||||
|     hoursBack: number; | ||||
| } | ||||
| 
 | ||||
| export const FeatureMetricsChart = ({ | ||||
|     metrics, | ||||
|     hoursBack, | ||||
| }: IFeatureMetricsChartProps) => { | ||||
|     const { locationSettings } = useLocationSettings(); | ||||
| 
 | ||||
|     const sortedMetrics = useMemo(() => { | ||||
|         return [...metrics].sort((metricA, metricB) => { | ||||
|             return metricA.timestamp.localeCompare(metricB.timestamp); | ||||
|         }); | ||||
|     }, [metrics]); | ||||
| 
 | ||||
|     const options = useMemo(() => { | ||||
|         return createChartOptions(sortedMetrics, hoursBack, locationSettings); | ||||
|     }, [sortedMetrics, hoursBack, locationSettings]); | ||||
| 
 | ||||
|     const data = useMemo(() => { | ||||
|         return createChartData(sortedMetrics, locationSettings); | ||||
|     }, [sortedMetrics, locationSettings]); | ||||
| 
 | ||||
|     return ( | ||||
|         <div style={{ height: 400 }}> | ||||
|             <Line | ||||
|                 options={options} | ||||
|                 data={data} | ||||
|                 aria-label="A line chart with series for all requests, positive requests, and negative requests." | ||||
|                 aria-describedby={FEATURE_METRICS_TABLE_ID} | ||||
|             /> | ||||
|         </div> | ||||
|     ); | ||||
| }; | ||||
| 
 | ||||
| // Register dependencies that we need to draw the chart.
 | ||||
| ChartJS.register( | ||||
|     CategoryScale, | ||||
|     LinearScale, | ||||
|     PointElement, | ||||
|     LineElement, | ||||
|     TimeScale, | ||||
|     Legend, | ||||
|     Tooltip, | ||||
|     Title | ||||
| ); | ||||
| @ -0,0 +1,51 @@ | ||||
| import { IFeatureMetricsRaw } from '../../../../../interfaces/featureToggle'; | ||||
| import { ChartData } from 'chart.js'; | ||||
| import { ILocationSettings } from '../../../../../hooks/useLocationSettings'; | ||||
| import theme from '../../../../../themes/main-theme'; | ||||
| import 'chartjs-adapter-date-fns'; | ||||
| 
 | ||||
| interface IPoint { | ||||
|     x: string; | ||||
|     y: number; | ||||
| } | ||||
| 
 | ||||
| export const createChartData = ( | ||||
|     metrics: IFeatureMetricsRaw[], | ||||
|     locationSettings: ILocationSettings | ||||
| ): ChartData<'line', IPoint[], string> => { | ||||
|     const requestsSeries = { | ||||
|         label: 'total requests', | ||||
|         borderColor: theme.palette.primary.light, | ||||
|         backgroundColor: theme.palette.primary.light, | ||||
|         data: createChartPoints(metrics, locationSettings, m => m.yes + m.no), | ||||
|     }; | ||||
| 
 | ||||
|     const yesSeries = { | ||||
|         label: 'exposed', | ||||
|         borderColor: theme.palette.success.light, | ||||
|         backgroundColor: theme.palette.success.light, | ||||
|         data: createChartPoints(metrics, locationSettings, m => m.yes), | ||||
|     }; | ||||
| 
 | ||||
|     const noSeries = { | ||||
|         label: 'not exposed', | ||||
|         borderColor: theme.palette.error.light, | ||||
|         backgroundColor: theme.palette.error.light, | ||||
|         data: createChartPoints(metrics, locationSettings, m => m.no), | ||||
|     }; | ||||
| 
 | ||||
|     return { datasets: [yesSeries, noSeries, requestsSeries] }; | ||||
| }; | ||||
| 
 | ||||
| const createChartPoints = ( | ||||
|     metrics: IFeatureMetricsRaw[], | ||||
|     locationSettings: ILocationSettings, | ||||
|     y: (m: IFeatureMetricsRaw) => number | ||||
| ): IPoint[] => { | ||||
|     const points = metrics.map(metric => ({ | ||||
|         x: metric.timestamp, | ||||
|         y: y(metric), | ||||
|     })); | ||||
| 
 | ||||
|     return points.filter(point => point.y > 0); | ||||
| }; | ||||
| @ -0,0 +1,106 @@ | ||||
| import { ILocationSettings } from '../../../../../hooks/useLocationSettings'; | ||||
| import 'chartjs-adapter-date-fns'; | ||||
| import { ChartOptions, defaults } from 'chart.js'; | ||||
| import { formatTimeWithLocale } from '../../../../common/util'; | ||||
| import { IFeatureMetricsRaw } from '../../../../../interfaces/featureToggle'; | ||||
| import theme from '../../../../../themes/main-theme'; | ||||
| 
 | ||||
| export const createChartOptions = ( | ||||
|     metrics: IFeatureMetricsRaw[], | ||||
|     hoursBack: number, | ||||
|     locationSettings: ILocationSettings | ||||
| ): ChartOptions<'line'> => { | ||||
|     return { | ||||
|         locale: locationSettings.locale, | ||||
|         responsive: true, | ||||
|         maintainAspectRatio: false, | ||||
|         interaction: { | ||||
|             mode: 'index', | ||||
|             intersect: false, | ||||
|         }, | ||||
|         plugins: { | ||||
|             tooltip: { | ||||
|                 backgroundColor: 'white', | ||||
|                 bodyColor: theme.palette.text.primary, | ||||
|                 titleColor: theme.palette.grey[700], | ||||
|                 borderColor: theme.palette.primary.main, | ||||
|                 borderWidth: 1, | ||||
|                 padding: 10, | ||||
|                 boxPadding: 5, | ||||
|                 usePointStyle: true, | ||||
|                 callbacks: { | ||||
|                     title: items => | ||||
|                         formatTimeWithLocale( | ||||
|                             items[0].parsed.x, | ||||
|                             locationSettings.locale | ||||
|                         ), | ||||
|                 }, | ||||
|                 // Sort tooltip items in the same order as the lines in the chart.
 | ||||
|                 itemSort: (a, b) => b.parsed.y - a.parsed.y, | ||||
|             }, | ||||
|             legend: { | ||||
|                 position: 'top', | ||||
|                 align: 'end', | ||||
|                 labels: { | ||||
|                     boxWidth: 10, | ||||
|                     boxHeight: 10, | ||||
|                     usePointStyle: true, | ||||
|                 }, | ||||
|             }, | ||||
|             title: { | ||||
|                 text: formatChartLabel(hoursBack), | ||||
|                 position: 'top', | ||||
|                 align: 'start', | ||||
|                 display: true, | ||||
|                 font: { | ||||
|                     size: 16, | ||||
|                     weight: '400', | ||||
|                 }, | ||||
|             }, | ||||
|         }, | ||||
|         scales: { | ||||
|             y: { | ||||
|                 type: 'linear', | ||||
|                 title: { | ||||
|                     display: true, | ||||
|                     text: 'Number of requests', | ||||
|                 }, | ||||
|                 ticks: { precision: 0 }, | ||||
|             }, | ||||
|             x: { | ||||
|                 type: 'time', | ||||
|                 time: { unit: 'hour' }, | ||||
|                 grid: { display: false }, | ||||
|                 ticks: { | ||||
|                     callback: (_, i, data) => | ||||
|                         formatTimeWithLocale( | ||||
|                             data[i].value, | ||||
|                             locationSettings.locale | ||||
|                         ), | ||||
|                 }, | ||||
|             }, | ||||
|         }, | ||||
|         elements: { | ||||
|             point: { | ||||
|                 // If we only have one point, always show a dot (since there's no line).
 | ||||
|                 // If we have multiple points, only show dots on hover (looks better).
 | ||||
|                 radius: metrics.length === 1 ? 6 : 0, | ||||
|                 hoverRadius: 6, | ||||
|             }, | ||||
|         }, | ||||
|     }; | ||||
| }; | ||||
| 
 | ||||
| const formatChartLabel = (hoursBack: number): string => { | ||||
|     return hoursBack === 1 | ||||
|         ? 'Requests in the last hour' | ||||
|         : `Requests in the last ${hoursBack} hours`; | ||||
| }; | ||||
| 
 | ||||
| // Set the default font for ticks, legends, tooltips, etc.
 | ||||
| defaults.font = { | ||||
|     ...defaults.font, | ||||
|     family: 'Sen', | ||||
|     size: 13, | ||||
|     weight: '400', | ||||
| }; | ||||
| @ -0,0 +1,25 @@ | ||||
| import { makeStyles } from '@material-ui/core/styles'; | ||||
| 
 | ||||
| export const useStyles = makeStyles(theme => ({ | ||||
|     title: { | ||||
|         margin: 0, | ||||
|         marginBottom: '.5rem', | ||||
|         fontSize: theme.fontSizes.smallerBody, | ||||
|         fontWeight: theme.fontWeight.thin, | ||||
|         color: theme.palette.grey[600], | ||||
|     }, | ||||
|     list: { | ||||
|         display: 'flex', | ||||
|         flexWrap: 'wrap', | ||||
|         gap: '.5rem', | ||||
|         listStyleType: 'none', | ||||
|         padding: 0, | ||||
|         minHeight: '100%', | ||||
|     }, | ||||
|     item: { | ||||
|         '& > [aria-pressed=true]': { | ||||
|             backgroundColor: theme.palette.primary.main, | ||||
|             color: theme.palette.primary.contrastText, | ||||
|         }, | ||||
|     }, | ||||
| })); | ||||
| @ -0,0 +1,48 @@ | ||||
| import { Chip } from '@material-ui/core'; | ||||
| import { useMemo } from 'react'; | ||||
| import { useStyles } from './FeatureMetricsChips.styles'; | ||||
| 
 | ||||
| interface IFeatureMetricsChipsProps { | ||||
|     title: string; | ||||
|     values: Set<string>; | ||||
|     value?: string; | ||||
|     setValue: (value: string) => void; | ||||
| } | ||||
| 
 | ||||
| export const FeatureMetricsChips = ({ | ||||
|     title, | ||||
|     values, | ||||
|     value, | ||||
|     setValue, | ||||
| }: IFeatureMetricsChipsProps) => { | ||||
|     const styles = useStyles(); | ||||
| 
 | ||||
|     const onClick = (value: string) => () => { | ||||
|         if (values.has(value)) { | ||||
|             setValue(value); | ||||
|         } | ||||
|     }; | ||||
| 
 | ||||
|     const sortedValues = useMemo(() => { | ||||
|         return Array.from(values).sort((valueA, valueB) => { | ||||
|             return valueA.localeCompare(valueB); | ||||
|         }); | ||||
|     }, [values]); | ||||
| 
 | ||||
|     return ( | ||||
|         <div> | ||||
|             <h3 className={styles.title}>{title}</h3> | ||||
|             <ul className={styles.list}> | ||||
|                 {sortedValues.map(val => ( | ||||
|                     <li key={val} className={styles.item}> | ||||
|                         <Chip | ||||
|                             label={val} | ||||
|                             onClick={onClick(val)} | ||||
|                             aria-pressed={val === value} | ||||
|                         /> | ||||
|                     </li> | ||||
|                 ))} | ||||
|             </ul> | ||||
|         </div> | ||||
|     ); | ||||
| }; | ||||
| @ -0,0 +1,47 @@ | ||||
| import { FeatureMetricsTable } from '../FeatureMetricsTable/FeatureMetricsTable'; | ||||
| import { IFeatureMetricsRaw } from '../../../../../interfaces/featureToggle'; | ||||
| import { FeatureMetricsStatsRaw } from '../FeatureMetricsStats/FeatureMetricsStatsRaw'; | ||||
| import { FeatureMetricsChart } from '../FeatureMetricsChart/FeatureMetricsChart'; | ||||
| import { FeatureMetricsEmpty } from '../FeatureMetricsEmpty/FeatureMetricsEmpty'; | ||||
| import { Box } from '@material-ui/core'; | ||||
| import theme from '../../../../../themes/main-theme'; | ||||
| 
 | ||||
| interface IFeatureMetricsContentProps { | ||||
|     metrics: IFeatureMetricsRaw[]; | ||||
|     hoursBack: number; | ||||
| } | ||||
| 
 | ||||
| export const FeatureMetricsContent = ({ | ||||
|     metrics, | ||||
|     hoursBack, | ||||
| }: IFeatureMetricsContentProps) => { | ||||
|     if (metrics.length === 0) { | ||||
|         return ( | ||||
|             <Box mt={6}> | ||||
|                 <FeatureMetricsEmpty /> | ||||
|             </Box> | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     return ( | ||||
|         <> | ||||
|             <Box | ||||
|                 borderTop={1} | ||||
|                 pt={2} | ||||
|                 mt={3} | ||||
|                 borderColor={theme.palette.grey[200]} | ||||
|             > | ||||
|                 <FeatureMetricsChart metrics={metrics} hoursBack={hoursBack} /> | ||||
|             </Box> | ||||
|             <Box mt={4}> | ||||
|                 <FeatureMetricsStatsRaw | ||||
|                     metrics={metrics} | ||||
|                     hoursBack={hoursBack} | ||||
|                 /> | ||||
|             </Box> | ||||
|             <Box mt={4}> | ||||
|                 <FeatureMetricsTable metrics={metrics} /> | ||||
|             </Box> | ||||
|         </> | ||||
|     ); | ||||
| }; | ||||
| @ -0,0 +1,16 @@ | ||||
| import { Typography } from '@material-ui/core'; | ||||
| 
 | ||||
| export const FeatureMetricsEmpty = () => { | ||||
|     return ( | ||||
|         <Typography variant="body1"> | ||||
|             <Typography paragraph> | ||||
|                 We have yet to receive any metrics for this feature toggle in | ||||
|                 the selected time period. | ||||
|             </Typography> | ||||
|             <Typography paragraph> | ||||
|                 Please note that, since the SDKs send metrics on an interval, it | ||||
|                 might take some time before metrics appear. | ||||
|             </Typography> | ||||
|         </Typography> | ||||
|     ); | ||||
| }; | ||||
| @ -0,0 +1,57 @@ | ||||
| import GeneralSelect, { | ||||
|     OnGeneralSelectChange, | ||||
| } from '../../../../common/GeneralSelect/GeneralSelect'; | ||||
| 
 | ||||
| interface IFeatureMetricsHoursProps { | ||||
|     hoursBack: number; | ||||
|     setHoursBack: (value: number) => void; | ||||
| } | ||||
| 
 | ||||
| export const FEATURE_METRIC_HOURS_BACK_MAX = 48; | ||||
| 
 | ||||
| export const FeatureMetricsHours = ({ | ||||
|     hoursBack, | ||||
|     setHoursBack, | ||||
| }: IFeatureMetricsHoursProps) => { | ||||
|     const onChange: OnGeneralSelectChange = event => { | ||||
|         setHoursBack(parseFeatureMetricsHour(event.target.value)); | ||||
|     }; | ||||
| 
 | ||||
|     return ( | ||||
|         <GeneralSelect | ||||
|             label="Period" | ||||
|             name="feature-metrics-period" | ||||
|             id="feature-metrics-period" | ||||
|             options={hourOptions} | ||||
|             value={String(hoursBack)} | ||||
|             onChange={onChange} | ||||
|             fullWidth | ||||
|         /> | ||||
|     ); | ||||
| }; | ||||
| 
 | ||||
| const parseFeatureMetricsHour = (value: unknown) => { | ||||
|     switch (value) { | ||||
|         case '1': | ||||
|             return 1; | ||||
|         case '24': | ||||
|             return 24; | ||||
|         default: | ||||
|             return FEATURE_METRIC_HOURS_BACK_MAX; | ||||
|     } | ||||
| }; | ||||
| 
 | ||||
| const hourOptions: { key: `${number}`; label: string }[] = [ | ||||
|     { | ||||
|         key: '1', | ||||
|         label: 'Last hour', | ||||
|     }, | ||||
|     { | ||||
|         key: '24', | ||||
|         label: 'Last 24 hours', | ||||
|     }, | ||||
|     { | ||||
|         key: `${FEATURE_METRIC_HOURS_BACK_MAX}`, | ||||
|         label: `Last ${FEATURE_METRIC_HOURS_BACK_MAX} hours`, | ||||
|     }, | ||||
| ]; | ||||
| @ -0,0 +1,32 @@ | ||||
| import { makeStyles } from '@material-ui/core/styles'; | ||||
| 
 | ||||
| export const useStyles = makeStyles(theme => ({ | ||||
|     item: { | ||||
|         padding: theme.spacing(2), | ||||
|         background: theme.palette.grey[100], | ||||
|         borderRadius: theme.spacing(2), | ||||
|         textAlign: 'center', | ||||
|         [theme.breakpoints.up('md')]: { | ||||
|             padding: theme.spacing(4), | ||||
|         }, | ||||
|     }, | ||||
|     title: { | ||||
|         margin: 0, | ||||
|         fontSize: theme.fontSizes.bodySize, | ||||
|         fontWeight: theme.fontWeight.thin, | ||||
|     }, | ||||
|     value: { | ||||
|         fontSize: '2.25rem', | ||||
|         fontWeight: theme.fontWeight.bold, | ||||
|         color: theme.palette.primary.main, | ||||
|     }, | ||||
|     text: { | ||||
|         margin: '.5rem 0 0 0', | ||||
|         padding: '1rem 0 0 0', | ||||
|         borderTopWidth: 1, | ||||
|         borderTopStyle: 'solid', | ||||
|         borderTopColor: theme.palette.grey[300], | ||||
|         fontSize: theme.fontSizes.smallerBody, | ||||
|         color: theme.palette.grey[700], | ||||
|     }, | ||||
| })); | ||||
| @ -0,0 +1,57 @@ | ||||
| import { calculatePercentage } from '../../../../../utils/calculate-percentage'; | ||||
| import { useStyles } from './FeatureMetricsStats.styles'; | ||||
| import { Grid } from '@material-ui/core'; | ||||
| 
 | ||||
| interface IFeatureMetricsStatsProps { | ||||
|     totalYes: number; | ||||
|     totalNo: number; | ||||
|     hoursBack: number; | ||||
| } | ||||
| 
 | ||||
| export const FeatureMetricsStats = ({ | ||||
|     totalYes, | ||||
|     totalNo, | ||||
|     hoursBack, | ||||
| }: IFeatureMetricsStatsProps) => { | ||||
|     const styles = useStyles(); | ||||
| 
 | ||||
|     const hoursSuffix = | ||||
|         hoursBack === 1 ? 'in the last hour' : `in the last ${hoursBack} hours`; | ||||
| 
 | ||||
|     return ( | ||||
|         <Grid container spacing={2}> | ||||
|             <Grid item xs={12} sm={4}> | ||||
|                 <article className={styles.item}> | ||||
|                     <h3 className={styles.title}>Exposure</h3> | ||||
|                     <p className={styles.value}>{totalYes}</p> | ||||
|                     <p className={styles.text}> | ||||
|                         Total exposure of the feature in the environment{' '} | ||||
|                         {hoursSuffix}. | ||||
|                     </p> | ||||
|                 </article> | ||||
|             </Grid> | ||||
|             <Grid item xs={12} sm={4}> | ||||
|                 <article className={styles.item}> | ||||
|                     <h3 className={styles.title}>Exposure %</h3> | ||||
|                     <p className={styles.value}> | ||||
|                         {calculatePercentage(totalYes + totalNo, totalYes)}% | ||||
|                     </p> | ||||
|                     <p className={styles.text}> | ||||
|                         % total exposure of the feature in the environment{' '} | ||||
|                         {hoursSuffix}. | ||||
|                     </p> | ||||
|                 </article> | ||||
|             </Grid> | ||||
|             <Grid item xs={12} sm={4}> | ||||
|                 <article className={styles.item}> | ||||
|                     <h3 className={styles.title}>Requests</h3> | ||||
|                     <p className={styles.value}>{totalYes + totalNo}</p> | ||||
|                     <p className={styles.text}> | ||||
|                         Total requests for the feature in the environment{' '} | ||||
|                         {hoursSuffix}. | ||||
|                     </p> | ||||
|                 </article> | ||||
|             </Grid> | ||||
|         </Grid> | ||||
|     ); | ||||
| }; | ||||
| @ -0,0 +1,29 @@ | ||||
| import { IFeatureMetricsRaw } from '../../../../../interfaces/featureToggle'; | ||||
| import { useMemo } from 'react'; | ||||
| import { FeatureMetricsStats } from './FeatureMetricsStats'; | ||||
| 
 | ||||
| interface IFeatureMetricsStatsRawProps { | ||||
|     metrics: IFeatureMetricsRaw[]; | ||||
|     hoursBack: number; | ||||
| } | ||||
| 
 | ||||
| export const FeatureMetricsStatsRaw = ({ | ||||
|     metrics, | ||||
|     hoursBack, | ||||
| }: IFeatureMetricsStatsRawProps) => { | ||||
|     const totalYes = useMemo(() => { | ||||
|         return metrics.reduce((acc, m) => acc + m.yes, 0); | ||||
|     }, [metrics]); | ||||
| 
 | ||||
|     const totalNo = useMemo(() => { | ||||
|         return metrics.reduce((acc, m) => acc + m.no, 0); | ||||
|     }, [metrics]); | ||||
| 
 | ||||
|     return ( | ||||
|         <FeatureMetricsStats | ||||
|             totalYes={totalYes} | ||||
|             totalNo={totalNo} | ||||
|             hoursBack={hoursBack} | ||||
|         /> | ||||
|     ); | ||||
| }; | ||||
| @ -0,0 +1,71 @@ | ||||
| import { IFeatureMetricsRaw } from '../../../../../interfaces/featureToggle'; | ||||
| import { | ||||
|     Table, | ||||
|     TableBody, | ||||
|     TableCell, | ||||
|     TableHead, | ||||
|     TableRow, | ||||
|     useMediaQuery, | ||||
|     useTheme, | ||||
| } from '@material-ui/core'; | ||||
| import { useLocationSettings } from '../../../../../hooks/useLocationSettings'; | ||||
| import { formatFullDateTimeWithLocale } from '../../../../common/util'; | ||||
| import { useMemo } from 'react'; | ||||
| 
 | ||||
| export const FEATURE_METRICS_TABLE_ID = 'feature-metrics-table-id'; | ||||
| 
 | ||||
| interface IFeatureMetricsTableProps { | ||||
|     metrics: IFeatureMetricsRaw[]; | ||||
| } | ||||
| 
 | ||||
| export const FeatureMetricsTable = ({ metrics }: IFeatureMetricsTableProps) => { | ||||
|     const theme = useTheme(); | ||||
|     const smallScreen = useMediaQuery(theme.breakpoints.down('sm')); | ||||
|     const { locationSettings } = useLocationSettings(); | ||||
| 
 | ||||
|     const sortedMetrics = useMemo(() => { | ||||
|         return [...metrics].sort((metricA, metricB) => { | ||||
|             return metricB.timestamp.localeCompare(metricA.timestamp); | ||||
|         }); | ||||
|     }, [metrics]); | ||||
| 
 | ||||
|     if (sortedMetrics.length === 0) { | ||||
|         return null; | ||||
|     } | ||||
| 
 | ||||
|     return ( | ||||
|         <Table id={FEATURE_METRICS_TABLE_ID}> | ||||
|             <TableHead> | ||||
|                 <TableRow> | ||||
|                     <TableCell>Time</TableCell> | ||||
|                     <TableCell hidden={smallScreen}>Application</TableCell> | ||||
|                     <TableCell hidden={smallScreen}>Environment</TableCell> | ||||
|                     <TableCell align="right">Requested</TableCell> | ||||
|                     <TableCell align="right">Exposed</TableCell> | ||||
|                 </TableRow> | ||||
|             </TableHead> | ||||
|             <TableBody> | ||||
|                 {sortedMetrics.map(metric => ( | ||||
|                     <TableRow key={metric.timestamp}> | ||||
|                         <TableCell> | ||||
|                             {formatFullDateTimeWithLocale( | ||||
|                                 metric.timestamp, | ||||
|                                 locationSettings.locale | ||||
|                             )} | ||||
|                         </TableCell> | ||||
|                         <TableCell hidden={smallScreen}> | ||||
|                             {metric.appName} | ||||
|                         </TableCell> | ||||
|                         <TableCell hidden={smallScreen}> | ||||
|                             {metric.environment} | ||||
|                         </TableCell> | ||||
|                         <TableCell align="right"> | ||||
|                             {metric.yes + metric.no} | ||||
|                         </TableCell> | ||||
|                         <TableCell align="right">{metric.yes}</TableCell> | ||||
|                     </TableRow> | ||||
|                 ))} | ||||
|             </TableBody> | ||||
|         </Table> | ||||
|     ); | ||||
| }; | ||||
| @ -1,79 +0,0 @@ | ||||
| import { makeStyles } from '@material-ui/core/styles'; | ||||
| 
 | ||||
| export const useStyles = makeStyles(theme => ({ | ||||
|     container: { | ||||
|         display: 'flex', | ||||
|         borderRadius: '10px', | ||||
|         backgroundColor: '#fff', | ||||
|         padding: '2rem 2rem 2rem 2rem', | ||||
|         marginBottom: '1rem', | ||||
|         flexDirection: 'column', | ||||
|         width: '50%', | ||||
|         position: 'relative', | ||||
|     }, | ||||
|     [theme.breakpoints.down(1000)]: { | ||||
|         container: { | ||||
|             width: '100%', | ||||
|         }, | ||||
|     }, | ||||
|     headerContainer: { | ||||
|         display: 'flex', | ||||
|         width: '100%', | ||||
|         alignItems: 'center', | ||||
|         justifyContent: 'space-between', | ||||
|     }, | ||||
|     title: { | ||||
|         fontSize: theme.fontSizes.subHeader, | ||||
|         fontWeight: 'normal', | ||||
|         margin: 0, | ||||
|     }, | ||||
|     truncator: { | ||||
|         verticalAlign: 'bottom', | ||||
|     }, | ||||
|     bodyContainer: { | ||||
|         display: 'flex', | ||||
|         align: 'items', | ||||
|         marginTop: '1rem', | ||||
|         height: '100%', | ||||
|     }, | ||||
|     trueCountContainer: { | ||||
|         marginBottom: '0.5rem', | ||||
|         display: 'flex', | ||||
|         alignItems: 'center', | ||||
|     }, | ||||
|     trueCount: { | ||||
|         width: '10px', | ||||
|         height: '10px', | ||||
|         borderRadius: '50%', | ||||
|         marginRight: '0.75rem', | ||||
|         backgroundColor: theme.palette.primary.main, | ||||
|     }, | ||||
|     falseCount: { | ||||
|         width: '10px', | ||||
|         height: '10px', | ||||
|         borderRadius: '50%', | ||||
|         marginRight: '0.75rem', | ||||
|         backgroundColor: theme.palette.grey[300], | ||||
|     }, | ||||
|     paragraph: { | ||||
|         fontSize: theme.fontSizes.smallBody, | ||||
|     }, | ||||
|     textContainer: { | ||||
|         marginRight: '1rem', | ||||
|         maxWidth: '150px', | ||||
|     }, | ||||
|     primaryMetric: { | ||||
|         width: '100%', | ||||
|     }, | ||||
|     icon: { | ||||
|         fill: theme.palette.grey[300], | ||||
|         height: '75px', | ||||
|         width: '75px', | ||||
|     }, | ||||
|     chartContainer: { | ||||
|         width: '100%', | ||||
|         display: 'flex', | ||||
|         justifyContent: 'center', | ||||
|         alignItems: 'center', | ||||
|     }, | ||||
| })); | ||||
| @ -1,141 +0,0 @@ | ||||
| import classNames from 'classnames'; | ||||
| import PercentageCircle from '../../../../common/PercentageCircle/PercentageCircle'; | ||||
| import { useStyles } from './FeatureEnvironmentMetrics.styles'; | ||||
| import { FiberManualRecord } from '@material-ui/icons'; | ||||
| import { useMediaQuery } from '@material-ui/core'; | ||||
| import { IFeatureEnvironmentMetrics } from '../../../../../interfaces/featureToggle'; | ||||
| import { parseISO } from 'date-fns'; | ||||
| import { calculatePercentage } from '../../../../../utils/calculate-percentage'; | ||||
| import StringTruncator from '../../../../common/StringTruncator/StringTruncator'; | ||||
| 
 | ||||
| interface IFeatureEnvironmentProps { | ||||
|     className?: string; | ||||
|     primaryMetric?: boolean; | ||||
|     metric?: IFeatureEnvironmentMetrics; | ||||
| } | ||||
| 
 | ||||
| const FeatureEnvironmentMetrics = ({ | ||||
|     className, | ||||
|     primaryMetric, | ||||
|     metric, | ||||
| }: IFeatureEnvironmentProps) => { | ||||
|     const styles = useStyles(); | ||||
|     const smallScreen = useMediaQuery(`(max-width:1000px)`); | ||||
| 
 | ||||
|     if (!metric) return null; | ||||
| 
 | ||||
|     const containerClasses = classNames(styles.container, className, { | ||||
|         [styles.primaryMetric]: primaryMetric, | ||||
|     }); | ||||
|     let hour = ''; | ||||
|     if (metric?.timestamp) { | ||||
|         const metricTime = parseISO(metric.timestamp); | ||||
|         hour = `since ${metricTime.getHours()}:00`; | ||||
|     } | ||||
| 
 | ||||
|     const total = metric.yes + metric.no; | ||||
| 
 | ||||
|     let primaryStyles = {}; | ||||
| 
 | ||||
|     if (primaryMetric) { | ||||
|         if (smallScreen) { | ||||
|             primaryStyles = { | ||||
|                 width: '60px', | ||||
|                 height: '60px', | ||||
|             }; | ||||
|         } else { | ||||
|             primaryStyles = { | ||||
|                 width: '90px', | ||||
|                 height: '90px', | ||||
|             }; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     if (metric.yes === 0 && metric.no === 0) { | ||||
|         return ( | ||||
|             <div className={containerClasses}> | ||||
|                 <div className={styles.headerContainer}> | ||||
|                     <h2 data-loading className={styles.title}> | ||||
|                         Traffic in  | ||||
|                         <StringTruncator | ||||
|                             text={metric.environment} | ||||
|                             className={styles.truncator} | ||||
|                             maxWidth="200" | ||||
|                         /> | ||||
|                           | ||||
|                         {hour} | ||||
|                     </h2> | ||||
|                 </div> | ||||
| 
 | ||||
|                 <div className={styles.bodyContainer}> | ||||
|                     <div className={styles.textContainer}> | ||||
|                         <p className={styles.paragraph} data-loading> | ||||
|                             No metrics available for this environment. | ||||
|                         </p> | ||||
|                     </div> | ||||
| 
 | ||||
|                     <div className={styles.chartContainer}> | ||||
|                         <FiberManualRecord | ||||
|                             style={{ transform: 'scale(1.4)' }} | ||||
|                             className={styles.icon} | ||||
|                             data-loading | ||||
|                         /> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </div> | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     return ( | ||||
|         <div className={containerClasses}> | ||||
|             <div className={styles.headerContainer}> | ||||
|                 <h2 data-loading className={styles.title}> | ||||
|                     Traffic in  | ||||
|                     <StringTruncator | ||||
|                         text={metric.environment} | ||||
|                         maxWidth="150" | ||||
|                         className={styles.truncator} | ||||
|                     /> | ||||
|                       | ||||
|                     {hour} | ||||
|                 </h2> | ||||
|             </div> | ||||
| 
 | ||||
|             <div className={styles.bodyContainer}> | ||||
|                 <div className={styles.textContainer}> | ||||
|                     <div className={styles.trueCountContainer}> | ||||
|                         <div> | ||||
|                             <div className={styles.trueCount} data-loading /> | ||||
|                         </div> | ||||
|                         <p className={styles.paragraph} data-loading> | ||||
|                             {metric.yes} users received this feature | ||||
|                         </p> | ||||
|                     </div> | ||||
| 
 | ||||
|                     <div className={styles.trueCountContainer}> | ||||
|                         <div> | ||||
|                             <div className={styles.falseCount} data-loading /> | ||||
|                         </div> | ||||
|                         <p className={styles.paragraph} data-loading> | ||||
|                             {metric.no} users did not receive this feature | ||||
|                         </p> | ||||
|                     </div> | ||||
|                 </div> | ||||
|                 <div className={styles.chartContainer} data-loading> | ||||
|                     <PercentageCircle | ||||
|                         percentage={calculatePercentage(total, metric.yes)} | ||||
|                         styles={{ | ||||
|                             height: '60px', | ||||
|                             width: '60px', | ||||
|                             marginLeft: '1rem', | ||||
|                             ...primaryStyles, | ||||
|                             transform: 'scale(1.6)', | ||||
|                         }} | ||||
|                     /> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </div> | ||||
|     ); | ||||
| }; | ||||
| 
 | ||||
| export default FeatureEnvironmentMetrics; | ||||
| @ -68,77 +68,17 @@ export const useStyles = makeStyles(theme => ({ | ||||
|         backgroundColor: theme.palette.grey[300], | ||||
|         width: '90%', | ||||
|     }, | ||||
|     accordionBodyFooter: { | ||||
|         position: 'relative', | ||||
|         padding: '1rem', | ||||
|         display: 'flex', | ||||
|         justifyContent: 'center', | ||||
|         alignItems: 'center', | ||||
|     }, | ||||
|     percentageContainer: { | ||||
|         width: '90px', | ||||
|         height: '90px', | ||||
|         border: `2px solid ${theme.palette.primary.light}`, | ||||
|         borderRadius: '50%', | ||||
|         display: 'flex', | ||||
|         justifyContent: 'center', | ||||
|         alignItems: 'center', | ||||
|         fontWeight: 'bold', | ||||
|         color: theme.palette.primary.light, | ||||
|         marginTop: '1rem', | ||||
|     }, | ||||
|     requestContainer: { | ||||
|         padding: '2rem', | ||||
|         border: `2px solid ${theme.palette.primary.light}`, | ||||
|         borderRadius: '5px', | ||||
|         display: 'flex', | ||||
|         flexDirection: 'column', | ||||
|         maxWidth: '300px', | ||||
|         justifyContent: 'center', | ||||
|         minWidth: '200px', | ||||
|         alignItems: 'center', | ||||
|     }, | ||||
|     requestText: { | ||||
|         textAlign: 'center', | ||||
|         marginTop: '1rem', | ||||
|         fontSize: theme.fontSizes.smallBody, | ||||
|     }, | ||||
|     linkContainer: { | ||||
|         display: 'flex', | ||||
|         justifyContent: 'flex-end', | ||||
|         marginBottom: '1rem', | ||||
|     }, | ||||
|     disabledInfo: { | ||||
|         maxWidth: '300px', | ||||
|         marginRight: '1.5rem', | ||||
|         display: 'flex', | ||||
|         flexDirection: 'column', | ||||
|         alignItems: 'center', | ||||
|     }, | ||||
|     disabledIcon: { | ||||
|         height: '50px', | ||||
|         width: '50px', | ||||
|         fill: theme.palette.grey[400], | ||||
|         marginBottom: '1rem', | ||||
|     }, | ||||
|     strategiesText: { | ||||
|         fontSize: '14px', | ||||
|         color: theme.palette.grey[700], | ||||
|     }, | ||||
|     stratigiesInfoContainer: { | ||||
|         display: 'flex', | ||||
|     }, | ||||
|     noStratigiesInfoContainer: { | ||||
|         top: '1px', | ||||
|         position: 'relative', | ||||
|     }, | ||||
|     strategyIconContainer: { | ||||
|         minWidth: '50px', | ||||
|         marginRight: '5px', | ||||
|         display: 'flex', | ||||
|         justifyContent: 'center', | ||||
|     }, | ||||
| 
 | ||||
|     strategiesIconsContainer: { | ||||
|         transform: 'scale(0.8)', | ||||
|         display: 'flex', | ||||
| @ -150,14 +90,6 @@ export const useStyles = makeStyles(theme => ({ | ||||
|             top: '5px', | ||||
|         }, | ||||
|     }, | ||||
|     [theme.breakpoints.down(750)]: { | ||||
|         accordionBodyFooter: { | ||||
|             flexDirection: 'column', | ||||
|         }, | ||||
|         requestContainer: { | ||||
|             marginTop: '1rem', | ||||
|         }, | ||||
|     }, | ||||
|     [theme.breakpoints.down(560)]: { | ||||
|         disabledIndicatorPos: { | ||||
|             top: '13px', | ||||
| @ -171,15 +103,11 @@ export const useStyles = makeStyles(theme => ({ | ||||
|         truncator: { | ||||
|             textAlign: 'center', | ||||
|         }, | ||||
|         resultContainer: { | ||||
|             flexWrap: 'wrap', | ||||
|         }, | ||||
|     }, | ||||
|     [theme.breakpoints.down(400)]: { | ||||
|         accordionHeader: { | ||||
|             padding: '0.5rem 1rem', | ||||
|         }, | ||||
| 
 | ||||
|         accordionBodyInnerContainer: { | ||||
|             padding: '0.5rem', | ||||
|         }, | ||||
| @ -223,20 +151,4 @@ export const useStyles = makeStyles(theme => ({ | ||||
|             display: 'none', | ||||
|         }, | ||||
|     }, | ||||
|     resultContainer: { | ||||
|         display: 'flex', | ||||
|         width: '100%', | ||||
|         justifyContent: 'space-around', | ||||
|     }, | ||||
|     dataContainer: { | ||||
|         display: 'flex', | ||||
|         flexDirection: 'column', | ||||
|         justifyContent: 'center', | ||||
|         alignContent: 'center', | ||||
|         alignItems: 'center', | ||||
|         padding: '0px 15px', | ||||
|     }, | ||||
|     resultTitle: { | ||||
|         color: theme.palette.primary.main, | ||||
|     }, | ||||
| })); | ||||
|  | ||||
| @ -1,23 +1,19 @@ | ||||
| import { | ||||
|     IFeatureEnvironment, | ||||
|     IFeatureEnvironmentMetrics, | ||||
| } from '../../../../../../../interfaces/featureToggle'; | ||||
| import { calculatePercentage } from '../../../../../../../utils/calculate-percentage'; | ||||
| import { IFeatureEnvironmentMetrics } from '../../../../../../../interfaces/featureToggle'; | ||||
| import { useStyles } from '../FeatureOverviewEnvironment.styles'; | ||||
| import { FeatureMetricsStats } from '../../../../FeatureMetrics/FeatureMetricsStats/FeatureMetricsStats'; | ||||
| 
 | ||||
| interface IFeatureOverviewEnvironmentFooterProps { | ||||
|     env: IFeatureEnvironment; | ||||
|     environmentMetric?: IFeatureEnvironmentMetrics; | ||||
| } | ||||
| 
 | ||||
| const FeatureOverviewEnvironmentFooter = ({ | ||||
|     env, | ||||
|     environmentMetric, | ||||
| }: IFeatureOverviewEnvironmentFooterProps) => { | ||||
|     const styles = useStyles(); | ||||
| 
 | ||||
|     if (!environmentMetric) return null; | ||||
|     const totalTraffic = environmentMetric.yes + environmentMetric.no; | ||||
|     if (!environmentMetric) { | ||||
|         return null; | ||||
|     } | ||||
| 
 | ||||
|     return ( | ||||
|         <> | ||||
| @ -26,47 +22,14 @@ const FeatureOverviewEnvironmentFooter = ({ | ||||
|                 <div className={styles.separatorText}>Result</div> | ||||
|                 <div className={styles.rightWing} /> | ||||
|             </div> | ||||
| 
 | ||||
|             <div className={styles.accordionBodyFooter}> | ||||
|                 <div className={styles.resultContainer}> | ||||
|                     <div className={styles.dataContainer}> | ||||
|                         <h3 className={styles.resultTitle}>Exposure</h3> | ||||
|                         <div className={styles.percentageContainer}> | ||||
|                             {environmentMetric?.yes} | ||||
|                         </div> | ||||
|                         <p className={styles.requestText}> | ||||
|                             Total exposure of the feature in the environment in | ||||
|                             the last hour | ||||
|                         </p> | ||||
|                     </div> | ||||
|                     <div className={styles.dataContainer}> | ||||
|                         <h3 className={styles.resultTitle}>% exposure</h3> | ||||
|                         <div className={styles.percentageContainer}> | ||||
|                             {calculatePercentage( | ||||
|                                 totalTraffic, | ||||
|                                 environmentMetric?.yes | ||||
|                             )} | ||||
|                             % | ||||
|                         </div> | ||||
|                         <p className={styles.requestText}> | ||||
|                             Total exposure of the feature in the environment in | ||||
|                             the last hour | ||||
|                         </p> | ||||
|                     </div> | ||||
|                     <div className={styles.dataContainer}> | ||||
|                         <h3 className={styles.resultTitle}>Total requests</h3> | ||||
|                         <div className={styles.percentageContainer}> | ||||
|                             {environmentMetric?.yes + environmentMetric?.no} | ||||
|                         </div> | ||||
|                         <p className={styles.requestText}> | ||||
|                             The total request of the feature in the environment | ||||
|                             in the last hour | ||||
|                         </p> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             <div> | ||||
|                 <FeatureMetricsStats | ||||
|                     totalYes={environmentMetric.yes} | ||||
|                     totalNo={environmentMetric.no} | ||||
|                     hoursBack={1} | ||||
|                 /> | ||||
|             </div> | ||||
|         </> | ||||
|     ); | ||||
| }; | ||||
| 
 | ||||
| export default FeatureOverviewEnvironmentFooter; | ||||
|  | ||||
| @ -1,15 +0,0 @@ | ||||
| import { makeStyles } from '@material-ui/core/styles'; | ||||
| 
 | ||||
| export const useStyles = makeStyles(theme => ({ | ||||
|     listLink: { | ||||
|         color: '#212121', | ||||
|         textDecoration: 'none', | ||||
|         fontWeight: 'normal', | ||||
|         display: 'block', | ||||
|     }, | ||||
|     truncate: { | ||||
|         whiteSpace: 'nowrap', | ||||
|         overflow: 'hidden', | ||||
|         textOverflow: 'ellipsis', | ||||
|     }, | ||||
| })); | ||||
| @ -1,46 +0,0 @@ | ||||
| import useFeatureMetrics from '../../../../hooks/api/getters/useFeatureMetrics/useFeatureMetrics'; | ||||
| import { Link, useParams } from 'react-router-dom'; | ||||
| import { IFeatureViewParams } from '../../../../interfaces/params'; | ||||
| import { Grid } from '@material-ui/core'; | ||||
| import React from 'react'; | ||||
| import { useStyles } from './FeatureSeenApplications.styles'; | ||||
| import ConditionallyRender from '../../../common/ConditionallyRender'; | ||||
| 
 | ||||
| const FeatureSeenApplications: React.FC = () => { | ||||
|     const { projectId, featureId } = useParams<IFeatureViewParams>(); | ||||
|     const { metrics } = useFeatureMetrics(projectId, featureId); | ||||
|     const styles = useStyles(); | ||||
|     const seenApplications = (seenApps: string[]) => { | ||||
|         return seenApps.map(appName => { | ||||
|             return ( | ||||
|                 <Grid item md={4} xs={6} xl={3}> | ||||
|                     <Link | ||||
|                         to={`/applications/${appName}`} | ||||
|                         className={[styles.listLink, styles.truncate].join(' ')} | ||||
|                     > | ||||
|                         {appName} | ||||
|                     </Link> | ||||
|                 </Grid> | ||||
|             ); | ||||
|         }); | ||||
|     }; | ||||
| 
 | ||||
|     const noApplications = ( | ||||
|         <Grid item xs={12}> | ||||
|             <div>{'Not seen in any applications'}</div> | ||||
|         </Grid> | ||||
|     ); | ||||
| 
 | ||||
|     return ( | ||||
|         <Grid container spacing={1}> | ||||
|             <hr /> | ||||
|             <ConditionallyRender | ||||
|                 condition={metrics?.seenApplications?.length > 0} | ||||
|                 show={seenApplications(metrics.seenApplications)} | ||||
|                 elseShow={noApplications} | ||||
|             /> | ||||
|         </Grid> | ||||
|     ); | ||||
| }; | ||||
| 
 | ||||
| export default FeatureSeenApplications; | ||||
| @ -1,5 +1,5 @@ | ||||
| import { Tab, Tabs, useMediaQuery } from '@material-ui/core'; | ||||
| import { useState } from 'react'; | ||||
| import React, { useState } from 'react'; | ||||
| import { Archive, FileCopy, Label, WatchLater } from '@material-ui/icons'; | ||||
| import { Link, Route, useHistory, useParams } from 'react-router-dom'; | ||||
| import useFeatureApi from '../../../hooks/api/actions/useFeatureApi/useFeatureApi'; | ||||
| @ -16,10 +16,10 @@ import { | ||||
| import Dialogue from '../../common/Dialogue'; | ||||
| import PermissionIconButton from '../../common/PermissionIconButton/PermissionIconButton'; | ||||
| import FeatureLog from './FeatureLog/FeatureLog'; | ||||
| import FeatureMetrics from './FeatureMetrics/FeatureMetrics'; | ||||
| import FeatureOverview from './FeatureOverview/FeatureOverview'; | ||||
| import FeatureStrategies from './FeatureStrategies/FeatureStrategies'; | ||||
| import FeatureVariants from './FeatureVariants/FeatureVariants'; | ||||
| import { FeatureMetrics } from './FeatureMetrics/FeatureMetrics'; | ||||
| import { useStyles } from './FeatureView.styles'; | ||||
| import FeatureSettings from './FeatureSettings/FeatureSettings'; | ||||
| import useLoading from '../../../hooks/useLoading'; | ||||
| @ -29,6 +29,7 @@ import useUiConfig from '../../../hooks/api/getters/useUiConfig/useUiConfig'; | ||||
| import StaleDialog from './FeatureOverview/StaleDialog/StaleDialog'; | ||||
| import AddTagDialog from './FeatureOverview/AddTagDialog/AddTagDialog'; | ||||
| import StatusChip from '../../common/StatusChip/StatusChip'; | ||||
| import { formatUnknownError } from '../../../utils/format-unknown-error'; | ||||
| 
 | ||||
| const FeatureView = () => { | ||||
|     const { projectId, featureId } = useParams<IFeatureViewParams>(); | ||||
| @ -60,8 +61,8 @@ const FeatureView = () => { | ||||
|             setShowDelDialog(false); | ||||
|             projectRefetch(); | ||||
|             history.push(`/projects/${projectId}`); | ||||
|         } catch (e) { | ||||
|             setToastApiError(e.toString()); | ||||
|         } catch (error) { | ||||
|             setToastApiError(formatUnknownError(error)); | ||||
|             setShowDelDialog(false); | ||||
|         } | ||||
|     }; | ||||
| @ -160,7 +161,7 @@ const FeatureView = () => { | ||||
|                                     component={Link} | ||||
|                                     to={`/projects/${projectId}/features/${featureId}/strategies/copy`} | ||||
|                                 > | ||||
|                                     <FileCopy /> | ||||
|                                     <FileCopy titleAccess="Copy" /> | ||||
|                                 </PermissionIconButton> | ||||
|                                 <PermissionIconButton | ||||
|                                     permission={DELETE_FEATURE} | ||||
| @ -169,7 +170,7 @@ const FeatureView = () => { | ||||
|                                     data-loading | ||||
|                                     onClick={() => setShowDelDialog(true)} | ||||
|                                 > | ||||
|                                     <Archive /> | ||||
|                                     <Archive titleAccess="Archive feature toggle" /> | ||||
|                                 </PermissionIconButton> | ||||
|                                 <PermissionIconButton | ||||
|                                     onClick={() => setOpenStaleDialog(true)} | ||||
| @ -178,7 +179,7 @@ const FeatureView = () => { | ||||
|                                     tooltip="Toggle stale status" | ||||
|                                     data-loading | ||||
|                                 > | ||||
|                                     <WatchLater /> | ||||
|                                     <WatchLater titleAccess="Toggle stale status" /> | ||||
|                                 </PermissionIconButton> | ||||
|                                 <PermissionIconButton | ||||
|                                     onClick={() => setOpenTagDialog(true)} | ||||
| @ -187,7 +188,7 @@ const FeatureView = () => { | ||||
|                                     tooltip="Add tag" | ||||
|                                     data-loading | ||||
|                                 > | ||||
|                                     <Label /> | ||||
|                                     <Label titleAccess="Add tag" /> | ||||
|                                 </PermissionIconButton> | ||||
|                             </div> | ||||
|                         </div> | ||||
|  | ||||
| @ -1,11 +1,8 @@ | ||||
| import React from 'react'; | ||||
| import renderer from 'react-test-renderer'; | ||||
| import { MemoryRouter } from 'react-router-dom'; | ||||
| 
 | ||||
| import Breadcrumb from '../breadcrumb'; | ||||
| 
 | ||||
| jest.mock('@material-ui/core'); | ||||
| 
 | ||||
| test('breadcrumb for /features', () => { | ||||
|     const tree = renderer.create( | ||||
|         <MemoryRouter initialEntries={['/features']}> | ||||
|  | ||||
| @ -8,7 +8,7 @@ class ScrollToTop extends Component { | ||||
|     }; | ||||
| 
 | ||||
|     componentDidUpdate(prevProps) { | ||||
|         if (this.props.location !== prevProps.location) { | ||||
|         if (this.props.location.pathname !== prevProps.location.pathname) { | ||||
|             if ( | ||||
|                 this.props.location.pathname.includes('/features/metrics') || | ||||
|                 this.props.location.pathname.includes('/features/variants') || | ||||
|  | ||||
| @ -0,0 +1,51 @@ | ||||
| import useSWR, { mutate } from 'swr'; | ||||
| import { useCallback } from 'react'; | ||||
| import { formatApiPath } from '../../../../utils/format-path'; | ||||
| import handleErrorResponses from '../httpErrorResponseHandler'; | ||||
| import { IFeatureMetricsRaw } from '../../../../interfaces/featureToggle'; | ||||
| 
 | ||||
| const PATH = formatApiPath('api/admin/client-metrics/features'); | ||||
| 
 | ||||
| interface IUseFeatureMetricsRawOutput { | ||||
|     featureMetrics?: Readonly<IFeatureMetricsRaw[]>; | ||||
|     refetchFeatureMetrics: () => void; | ||||
|     loading: boolean; | ||||
|     error?: Error; | ||||
| } | ||||
| 
 | ||||
| interface IUseFeatureMetricsRawResponse { | ||||
|     data: IFeatureMetricsRaw[]; | ||||
| } | ||||
| 
 | ||||
| export const useFeatureMetricsRaw = ( | ||||
|     featureId: string, | ||||
|     hoursBack: number | ||||
| ): IUseFeatureMetricsRawOutput => { | ||||
|     const path = formatApiPath( | ||||
|         `api/admin/client-metrics/features/${featureId}/raw?hoursBack=${hoursBack}` | ||||
|     ); | ||||
| 
 | ||||
|     const { data, error } = useSWR(path, () => { | ||||
|         return fetchFeatureMetricsRaw(path); | ||||
|     }); | ||||
| 
 | ||||
|     const refetchFeatureMetricsRaw = useCallback(() => { | ||||
|         mutate(PATH).catch(console.warn); | ||||
|     }, []); | ||||
| 
 | ||||
|     return { | ||||
|         featureMetrics: data?.data, | ||||
|         loading: !error && !data, | ||||
|         refetchFeatureMetrics: refetchFeatureMetricsRaw, | ||||
|         error, | ||||
|     }; | ||||
| }; | ||||
| 
 | ||||
| const fetchFeatureMetricsRaw = ( | ||||
|     path: string | ||||
| ): Promise<IUseFeatureMetricsRawResponse> => { | ||||
|     return fetch(path) | ||||
|         .then(handleErrorResponses('Features')) | ||||
|         .then(res => res.json()) | ||||
|         .then(); | ||||
| }; | ||||
							
								
								
									
										19
									
								
								frontend/src/hooks/useQueryStringNumberState.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								frontend/src/hooks/useQueryStringNumberState.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,19 @@ | ||||
| import { useCallback } from 'react'; | ||||
| import { useQueryStringState } from './useQueryStringState'; | ||||
| 
 | ||||
| // Store a number in the query string. Call setState to update the query string.
 | ||||
| export const useQueryStringNumberState = ( | ||||
|     key: string | ||||
| ): [number | undefined, (value: number) => void] => { | ||||
|     const [value, setValue] = useQueryStringState(key); | ||||
| 
 | ||||
|     const setState = useCallback( | ||||
|         (value: number) => setValue(String(value)), | ||||
|         [setValue] | ||||
|     ); | ||||
| 
 | ||||
|     return [ | ||||
|         Number.isFinite(Number(value)) ? Number(value) : undefined, | ||||
|         setState, | ||||
|     ]; | ||||
| }; | ||||
							
								
								
									
										25
									
								
								frontend/src/hooks/useQueryStringState.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								frontend/src/hooks/useQueryStringState.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,25 @@ | ||||
| import { useCallback, useMemo } from 'react'; | ||||
| import { useHistory } from 'react-router-dom'; | ||||
| 
 | ||||
| // Store a value in the query string. Call setState to update the query string.
 | ||||
| export const useQueryStringState = ( | ||||
|     key: string | ||||
| ): [string | undefined, (value: string) => void] => { | ||||
|     const { search } = window.location; | ||||
|     const { replace } = useHistory(); | ||||
| 
 | ||||
|     const params = useMemo(() => { | ||||
|         return new URLSearchParams(search); | ||||
|     }, [search]); | ||||
| 
 | ||||
|     const setState = useCallback( | ||||
|         (value: string) => { | ||||
|             const next = new URLSearchParams(search); | ||||
|             next.set(key, value); | ||||
|             replace({ search: next.toString() }); | ||||
|         }, | ||||
|         [key, search, replace] | ||||
|     ); | ||||
| 
 | ||||
|     return [params.get(key) || undefined, setState]; | ||||
| }; | ||||
| @ -74,3 +74,19 @@ export interface IFeatureMetrics { | ||||
|     lastHourUsage: IFeatureEnvironmentMetrics[]; | ||||
|     seenApplications: string[]; | ||||
| } | ||||
| 
 | ||||
| export interface IFeatureMetrics { | ||||
|     version: number; | ||||
|     maturity: string; | ||||
|     lastHourUsage: IFeatureEnvironmentMetrics[]; | ||||
|     seenApplications: string[]; | ||||
| } | ||||
| 
 | ||||
| export interface IFeatureMetricsRaw { | ||||
|     featureName: string; | ||||
|     appName: string; | ||||
|     environment: string; | ||||
|     timestamp: string; | ||||
|     yes: number; | ||||
|     no: number; | ||||
| } | ||||
|  | ||||
| @ -122,10 +122,10 @@ const mainTheme = { | ||||
|         }, | ||||
|     }, | ||||
|     fontWeight: { | ||||
|         thin: '300', | ||||
|         medium: '400', | ||||
|         semi: '700', | ||||
|         bold: '700', | ||||
|         thin: 300, | ||||
|         medium: 400, | ||||
|         semi: 700, | ||||
|         bold: 700, | ||||
|     }, | ||||
| }; | ||||
| 
 | ||||
|  | ||||
| @ -3688,6 +3688,16 @@ char-regex@^1.0.2: | ||||
|   resolved "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz" | ||||
|   integrity sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw== | ||||
| 
 | ||||
| chart.js@3.7.1: | ||||
|   version "3.7.1" | ||||
|   resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-3.7.1.tgz#0516f690c6a8680c6c707e31a4c1807a6f400ada" | ||||
|   integrity sha512-8knRegQLFnPQAheZV8MjxIXc5gQEfDFD897BJgv/klO/vtIyFFmgMXrNfgrXpbTr/XbTturxRgxIXx/Y+ASJBA== | ||||
| 
 | ||||
| chartjs-adapter-date-fns@2.0.0: | ||||
|   version "2.0.0" | ||||
|   resolved "https://registry.yarnpkg.com/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-2.0.0.tgz#5e53b2f660b993698f936f509c86dddf9ed44c6b" | ||||
|   integrity sha512-rmZINGLe+9IiiEB0kb57vH3UugAtYw33anRiw5kS2Tu87agpetDDoouquycWc9pRsKtQo5j+vLsYHyr8etAvFw== | ||||
| 
 | ||||
| check-more-types@^2.24.0: | ||||
|   version "2.24.0" | ||||
|   resolved "https://registry.npmjs.org/check-more-types/-/check-more-types-2.24.0.tgz" | ||||
| @ -10180,6 +10190,11 @@ react-app-polyfill@^2.0.0: | ||||
|     regenerator-runtime "^0.13.7" | ||||
|     whatwg-fetch "^3.4.1" | ||||
| 
 | ||||
| react-chartjs-2@4.0.1: | ||||
|   version "4.0.1" | ||||
|   resolved "https://registry.yarnpkg.com/react-chartjs-2/-/react-chartjs-2-4.0.1.tgz#38ed1d5b6e2f789408450c981e9e8e1d78b2e015" | ||||
|   integrity sha512-q8bgWzKoFvBvD7YcjT/hXG8jt55TaMAuJ1dmI3tKFJ7CijUWYz4pIfOhkTI6PBTwqu/pmeWsClBRd/7HiWzN1g== | ||||
| 
 | ||||
| react-dev-utils@^11.0.3: | ||||
|   version "11.0.4" | ||||
|   resolved "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-11.0.4.tgz" | ||||
| @ -11737,10 +11752,10 @@ svgo@^1.0.0, svgo@^1.2.2: | ||||
|     unquote "~1.1.1" | ||||
|     util.promisify "~1.0.0" | ||||
| 
 | ||||
| swr@1.2.1: | ||||
|   version "1.2.1" | ||||
|   resolved "https://registry.yarnpkg.com/swr/-/swr-1.2.1.tgz#c21a4fe2139cb1c4630450589b5b5add947a9d41" | ||||
|   integrity sha512-1cuWXqJqXcFwbgONGCY4PHZ8v05009JdHsC3CIC6u7d00kgbMswNr1sHnnhseOBxtzVqcCNpOHEgVDciRer45w== | ||||
| swr@1.2.2: | ||||
|   version "1.2.2" | ||||
|   resolved "https://registry.yarnpkg.com/swr/-/swr-1.2.2.tgz#6cae09928d30593a7980d80f85823e57468fac5d" | ||||
|   integrity sha512-ky0BskS/V47GpW8d6RU7CPsr6J8cr7mQD6+do5eky3bM0IyJaoi3vO8UhvrzJaObuTlGhPl2szodeB2dUd76Xw== | ||||
| 
 | ||||
| symbol-observable@1.2.0: | ||||
|   version "1.2.0" | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user