diff --git a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/index.js b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/index.js index b072c22c09c19..41925d651e361 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/index.js +++ b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/index.js @@ -80,11 +80,7 @@ export class InnerCustomPlot extends PureComponent { }); if (typeof this.props.onToggleLegend === 'function') { - //Filters out disabled series - const availableSeries = this.props.series.filter( - (serie, index) => !nextSeriesEnabledState[index] - ); - this.props.onToggleLegend(availableSeries); + this.props.onToggleLegend(nextSeriesEnabledState); } return { diff --git a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/BrowserLineChart.tsx b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/BrowserLineChart.tsx index a3fa2da40619b..40caf35155918 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/BrowserLineChart.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/BrowserLineChart.tsx @@ -4,31 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; -import { i18n } from '@kbn/i18n'; import { EuiTitle } from '@elastic/eui'; -import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n'; -import { isValidCoordinateValue } from '../../../../utils/isValidCoordinateValue'; -import { TransactionLineChart } from './TransactionLineChart'; -import { getMaxY } from '.'; -import { - getDurationFormatter, - TimeFormatter, -} from '../../../../utils/formatters'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; import { useAvgDurationByBrowser } from '../../../../hooks/useAvgDurationByBrowser'; -import { Coordinate } from '../../../../../typings/timeseries'; - -function getResponseTimeTickFormatter(formatter: TimeFormatter) { - return (t: number) => formatter(t).formatted; -} - -function getResponseTimeTooltipFormatter(formatter: TimeFormatter) { - return (p: Coordinate) => { - return isValidCoordinateValue(p.y) - ? formatter(p.y).formatted - : NOT_AVAILABLE_LABEL; - }; -} +import { getDurationFormatter } from '../../../../utils/formatters'; +import { + getResponseTimeTickFormatter, + getResponseTimeTooltipFormatter, + getMaxY, +} from './helper'; +import { TransactionLineChart } from './TransactionLineChart'; export function BrowserLineChart() { const { data } = useAvgDurationByBrowser(); diff --git a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/TransactionLineChart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/TransactionLineChart/index.tsx index 3657a02b1589c..07b7f01194d5c 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/TransactionLineChart/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/TransactionLineChart/index.tsx @@ -19,7 +19,7 @@ interface Props { height?: number; stacked?: boolean; onHover?: () => void; - onToggleLegend?: (visibleSeries: TimeSeries[]) => void; + onToggleLegend?: (disabledSeriesState: boolean[]) => void; } function TransactionLineChart(props: Props) { diff --git a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/helper.test.ts b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/helper.test.ts new file mode 100644 index 0000000000000..a476892fa4a3f --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/helper.test.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + getResponseTimeTickFormatter, + getResponseTimeTooltipFormatter, + getMaxY, +} from './helper'; +import { + getDurationFormatter, + toMicroseconds, +} from '../../../../utils/formatters'; +import { TimeSeries } from '../../../../../typings/timeseries'; + +describe('transaction chart helper', () => { + describe('getResponseTimeTickFormatter', () => { + it('formattes time tick in minutes', () => { + const formatter = getDurationFormatter(toMicroseconds(11, 'minutes')); + const timeTickFormatter = getResponseTimeTickFormatter(formatter); + expect(timeTickFormatter(toMicroseconds(60, 'seconds'))).toEqual( + '1.0 min' + ); + }); + it('formattes time tick in seconds', () => { + const formatter = getDurationFormatter(toMicroseconds(11, 'seconds')); + const timeTickFormatter = getResponseTimeTickFormatter(formatter); + expect(timeTickFormatter(toMicroseconds(6, 'seconds'))).toEqual('6.0 s'); + }); + }); + describe('getResponseTimeTooltipFormatter', () => { + const formatter = getDurationFormatter(toMicroseconds(11, 'minutes')); + const tooltipFormatter = getResponseTimeTooltipFormatter(formatter); + it("doesn't format invalid y coordinate", () => { + expect(tooltipFormatter({ x: 1, y: undefined })).toEqual('N/A'); + expect(tooltipFormatter({ x: 1, y: null })).toEqual('N/A'); + }); + it('formattes tooltip in minutes', () => { + expect( + tooltipFormatter({ x: 1, y: toMicroseconds(60, 'seconds') }) + ).toEqual('1.0 min'); + }); + }); + describe('getMaxY', () => { + it('returns zero when empty time series', () => { + expect(getMaxY([])).toEqual(0); + }); + it('returns zero for invalid y coordinate', () => { + const timeSeries = ([ + { data: [{ x: 1 }, { x: 2 }, { x: 3, y: -1 }] }, + ] as unknown) as TimeSeries[]; + expect(getMaxY(timeSeries)).toEqual(0); + }); + it('returns the max y coordinate', () => { + const timeSeries = ([ + { + data: [ + { x: 1, y: 10 }, + { x: 2, y: 5 }, + { x: 3, y: 1 }, + ], + }, + ] as unknown) as TimeSeries[]; + expect(getMaxY(timeSeries)).toEqual(10); + }); + }); +}); diff --git a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/helper.tsx b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/helper.tsx new file mode 100644 index 0000000000000..f11a33f932553 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/helper.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { flatten } from 'lodash'; +import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n'; +import { isValidCoordinateValue } from '../../../../utils/isValidCoordinateValue'; +import { TimeSeries, Coordinate } from '../../../../../typings/timeseries'; +import { TimeFormatter } from '../../../../utils/formatters'; + +export function getResponseTimeTickFormatter(formatter: TimeFormatter) { + return (t: number) => { + return formatter(t).formatted; + }; +} + +export function getResponseTimeTooltipFormatter(formatter: TimeFormatter) { + return (coordinate: Coordinate) => { + return isValidCoordinateValue(coordinate.y) + ? formatter(coordinate.y).formatted + : NOT_AVAILABLE_LABEL; + }; +} + +export function getMaxY(timeSeries: TimeSeries[]) { + const coordinates = flatten( + timeSeries.map((serie: TimeSeries) => serie.data as Coordinate[]) + ); + + const numbers: number[] = coordinates.map((c: Coordinate) => (c.y ? c.y : 0)); + + return Math.max(...numbers, 0); +} diff --git a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/index.tsx index e55f86bd3abf3..2902b907ba691 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/index.tsx @@ -8,37 +8,34 @@ import { EuiFlexGrid, EuiFlexGroup, EuiFlexItem, - EuiIconTip, EuiPanel, EuiSpacer, - EuiText, EuiTitle, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { Location } from 'history'; -import { flatten, isEmpty } from 'lodash'; import React from 'react'; -import styled from 'styled-components'; import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n'; import { TRANSACTION_PAGE_LOAD, TRANSACTION_REQUEST, TRANSACTION_ROUTE_CHANGE, } from '../../../../../common/transaction_types'; -import { Coordinate, TimeSeries } from '../../../../../typings/timeseries'; +import { Coordinate } from '../../../../../typings/timeseries'; import { LicenseContext } from '../../../../context/LicenseContext'; import { IUrlParams } from '../../../../context/UrlParamsContext/types'; import { ITransactionChartData } from '../../../../selectors/chartSelectors'; -import { - asDecimal, - getDurationFormatter, - tpmUnit, -} from '../../../../utils/formatters'; +import { asDecimal, tpmUnit } from '../../../../utils/formatters'; import { isValidCoordinateValue } from '../../../../utils/isValidCoordinateValue'; -import { MLJobLink } from '../../Links/MachineLearningLinks/MLJobLink'; import { BrowserLineChart } from './BrowserLineChart'; import { DurationByCountryMap } from './DurationByCountryMap'; +import { + getResponseTimeTickFormatter, + getResponseTimeTooltipFormatter, +} from './helper'; +import { MLHeader } from './ml_header'; import { TransactionLineChart } from './TransactionLineChart'; +import { useFormatter } from './use_formatter'; interface TransactionChartProps { charts: ITransactionChartData; @@ -46,28 +43,6 @@ interface TransactionChartProps { urlParams: IUrlParams; } -const ShiftedIconWrapper = styled.span` - padding-right: 5px; - position: relative; - top: -1px; - display: inline-block; -`; - -const ShiftedEuiText = styled(EuiText)` - position: relative; - top: 5px; -`; - -export function getMaxY(responseTimeSeries: TimeSeries[]) { - const coordinates = flatten( - responseTimeSeries.map((serie: TimeSeries) => serie.data as Coordinate[]) - ); - - const numbers: number[] = coordinates.map((c: Coordinate) => (c.y ? c.y : 0)); - - return Math.max(...numbers, 0); -} - export function TransactionCharts({ charts, location, @@ -84,82 +59,16 @@ export function TransactionCharts({ : NOT_AVAILABLE_LABEL; }; - function renderMLHeader(hasValidMlLicense: boolean | undefined) { - const { mlJobId } = charts; - - if (!hasValidMlLicense || !mlJobId) { - return null; - } - - const { serviceName, kuery, transactionType } = urlParams; - if (!serviceName) { - return null; - } - - const hasKuery = !isEmpty(kuery); - const icon = hasKuery ? ( - - ) : ( - - ); - - return ( - - - {icon} - - {i18n.translate( - 'xpack.apm.metrics.transactionChart.machineLearningLabel', - { - defaultMessage: 'Machine learning:', - } - )}{' '} - - - View Job - - - - ); - } - const { responseTimeSeries, tpmSeries } = charts; const { transactionType } = urlParams; - const maxY = getMaxY(responseTimeSeries); - let formatter = getDurationFormatter(maxY); - function onToggleLegend(visibleSeries: TimeSeries[]) { - if (!isEmpty(visibleSeries)) { - // recalculate the formatter based on the max Y from the visible series - const maxVisibleY = getMaxY(visibleSeries); - formatter = getDurationFormatter(maxVisibleY); - } - } + const { responseTimeSeries, tpmSeries } = charts; - function getResponseTimeTickFormatter(t: number) { - return formatter(t).formatted; - } + const { formatter, setDisabledSeriesState } = useFormatter( + responseTimeSeries + ); - function getResponseTimeTooltipFormatter(coordinate: Coordinate) { - return isValidCoordinateValue(coordinate.y) - ? formatter(coordinate.y).formatted - : NOT_AVAILABLE_LABEL; + function onToggleLegend(disabledSeriesStates: boolean[]) { + setDisabledSeriesState(disabledSeriesStates); } return ( @@ -175,15 +84,18 @@ export function TransactionCharts({ - {(license) => - renderMLHeader(license?.getFeature('ml').isAvailable) - } + {(license) => ( + + )} diff --git a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/ml_header.tsx b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/ml_header.tsx new file mode 100644 index 0000000000000..e65c4ded4003b --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/ml_header.tsx @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiIconTip } from '@elastic/eui'; +import { isEmpty } from 'lodash'; +import React from 'react'; +import { EuiFlexItem } from '@elastic/eui'; +import styled from 'styled-components'; +import { i18n } from '@kbn/i18n'; +import { EuiText } from '@elastic/eui'; +import { useUrlParams } from '../../../../hooks/useUrlParams'; +import { MLJobLink } from '../../Links/MachineLearningLinks/MLJobLink'; + +interface Props { + hasValidMlLicense?: boolean; + mlJobId?: string; +} + +const ShiftedIconWrapper = styled.span` + padding-right: 5px; + position: relative; + top: -1px; + display: inline-block; +`; + +const ShiftedEuiText = styled(EuiText)` + position: relative; + top: 5px; +`; + +export function MLHeader({ hasValidMlLicense, mlJobId }: Props) { + const { urlParams } = useUrlParams(); + + if (!hasValidMlLicense || !mlJobId) { + return null; + } + + const { serviceName, kuery, transactionType } = urlParams; + if (!serviceName) { + return null; + } + + const hasKuery = !isEmpty(kuery); + const icon = hasKuery ? ( + + ) : ( + + ); + + return ( + + + {icon} + + {i18n.translate( + 'xpack.apm.metrics.transactionChart.machineLearningLabel', + { + defaultMessage: 'Machine learning:', + } + )}{' '} + + + {i18n.translate('xpack.apm.metrics.transactionChart.viewJob', { + defaultMessage: 'View Job:', + })} + + + + ); +} diff --git a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/use_formatter.test.tsx b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/use_formatter.test.tsx new file mode 100644 index 0000000000000..78ff9a398b2e7 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/use_formatter.test.tsx @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { TimeSeries } from '../../../../../typings/timeseries'; +import { toMicroseconds } from '../../../../utils/formatters'; +import { useFormatter } from './use_formatter'; +import { render, fireEvent, act } from '@testing-library/react'; + +function MockComponent({ + timeSeries, + disabledSeries, + value, +}: { + timeSeries: TimeSeries[]; + disabledSeries: boolean[]; + value: number; +}) { + const { formatter, setDisabledSeriesState } = useFormatter(timeSeries); + + const onDisableSeries = () => { + setDisabledSeriesState(disabledSeries); + }; + + return ( +
+ + {formatter(value).formatted} +
+ ); +} + +describe('useFormatter', () => { + const timeSeries = ([ + { + data: [ + { x: 1, y: toMicroseconds(11, 'minutes') }, + { x: 2, y: toMicroseconds(1, 'minutes') }, + { x: 3, y: toMicroseconds(60, 'seconds') }, + ], + }, + { + data: [ + { x: 1, y: toMicroseconds(120, 'seconds') }, + { x: 2, y: toMicroseconds(1, 'minutes') }, + { x: 3, y: toMicroseconds(60, 'seconds') }, + ], + }, + { + data: [ + { x: 1, y: toMicroseconds(60, 'seconds') }, + { x: 2, y: toMicroseconds(5, 'minutes') }, + { x: 3, y: toMicroseconds(100, 'seconds') }, + ], + }, + ] as unknown) as TimeSeries[]; + it('returns new formatter when disabled series state changes', () => { + const { getByText } = render( + + ); + expect(getByText('2.0 min')).toBeInTheDocument(); + act(() => { + fireEvent.click(getByText('disable series')); + }); + expect(getByText('120 s')).toBeInTheDocument(); + }); + it('falls back to the first formatter when disabled series is empty', () => { + const { getByText } = render( + + ); + expect(getByText('2.0 min')).toBeInTheDocument(); + act(() => { + fireEvent.click(getByText('disable series')); + }); + expect(getByText('2.0 min')).toBeInTheDocument(); + // const { formatter, setDisabledSeriesState } = useFormatter(timeSeries); + // expect(formatter(toMicroseconds(120, 'seconds'))).toEqual('2.0 min'); + // setDisabledSeriesState([true, true, false]); + // expect(formatter(toMicroseconds(120, 'seconds'))).toEqual('2.0 min'); + }); + it('falls back to the first formatter when disabled series is all true', () => { + const { getByText } = render( + + ); + expect(getByText('2.0 min')).toBeInTheDocument(); + act(() => { + fireEvent.click(getByText('disable series')); + }); + expect(getByText('2.0 min')).toBeInTheDocument(); + // const { formatter, setDisabledSeriesState } = useFormatter(timeSeries); + // expect(formatter(toMicroseconds(120, 'seconds'))).toEqual('2.0 min'); + // setDisabledSeriesState([true, true, false]); + // expect(formatter(toMicroseconds(120, 'seconds'))).toEqual('2.0 min'); + }); +}); diff --git a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/use_formatter.ts b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/use_formatter.ts new file mode 100644 index 0000000000000..8cd8929c89960 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/use_formatter.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useState, Dispatch, SetStateAction } from 'react'; +import { isEmpty } from 'lodash'; +import { + getDurationFormatter, + TimeFormatter, +} from '../../../../utils/formatters'; +import { TimeSeries } from '../../../../../typings/timeseries'; +import { getMaxY } from './helper'; + +export const useFormatter = ( + series: TimeSeries[] +): { + formatter: TimeFormatter; + setDisabledSeriesState: Dispatch>; +} => { + const [disabledSeriesState, setDisabledSeriesState] = useState([]); + const visibleSeries = series.filter( + (serie, index) => disabledSeriesState[index] !== true + ); + const maxY = getMaxY(isEmpty(visibleSeries) ? series : visibleSeries); + const formatter = getDurationFormatter(maxY); + + return { formatter, setDisabledSeriesState }; +};