diff --git a/projects/common/src/time/time-range.service.test.ts b/projects/common/src/time/time-range.service.test.ts index 82cd31317..f6b37c48e 100644 --- a/projects/common/src/time/time-range.service.test.ts +++ b/projects/common/src/time/time-range.service.test.ts @@ -81,4 +81,11 @@ describe('Time range service', () => { }); }); }); + + test('returns custom time filter', () => { + const spectator = buildService(); + expect(spectator.service.toQueryParams(new Date(1642296703000), new Date(1642396703000))).toStrictEqual({ + ['time']: new FixedTimeRange(new Date(1642296703000), new Date(1642396703000)).toUrlString() + }); + }); }); diff --git a/projects/common/src/time/time-range.service.ts b/projects/common/src/time/time-range.service.ts index f9e96ca8e..583773d78 100644 --- a/projects/common/src/time/time-range.service.ts +++ b/projects/common/src/time/time-range.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@angular/core'; import { isEmpty } from 'lodash-es'; import { EMPTY, ReplaySubject } from 'rxjs'; import { catchError, defaultIfEmpty, filter, map, switchMap, take } from 'rxjs/operators'; -import { NavigationService } from '../navigation/navigation.service'; +import { NavigationService, QueryParamObject } from '../navigation/navigation.service'; import { ReplayObservable } from '../utilities/rxjs/rxjs-utils'; import { FixedTimeRange } from './fixed-time-range'; import { RelativeTimeRange } from './relative-time-range'; @@ -110,4 +110,12 @@ export class TimeRangeService { public static toFixedTimeRange(startTime: Date, endTime: Date): FixedTimeRange { return new FixedTimeRange(startTime, endTime); } + + public toQueryParams(startTime: Date, endTime: Date): QueryParamObject { + const newTimeRange = new FixedTimeRange(startTime, endTime); + + return { + [TimeRangeService.TIME_RANGE_QUERY_PARAM]: newTimeRange.toUrlString() + }; + } } diff --git a/projects/components/src/popover/popover-position-builder.ts b/projects/components/src/popover/popover-position-builder.ts index 54e902676..70d563108 100644 --- a/projects/components/src/popover/popover-position-builder.ts +++ b/projects/components/src/popover/popover-position-builder.ts @@ -109,6 +109,11 @@ export class PopoverPositionBuilder { return globalPosition.centerHorizontally().centerVertically(); case PopoverFixedPositionLocation.Right: return globalPosition.right('0').top('0'); + + case PopoverFixedPositionLocation.Custom: + return globalPosition + .left(`${popoverPosition.customLocation!.x}px`) + .top(`${popoverPosition.customLocation!.y}px`); case PopoverFixedPositionLocation.RightUnderHeader: default: return globalPosition.right('0').top(this.headerHeight ?? '0'); diff --git a/projects/components/src/popover/popover.ts b/projects/components/src/popover/popover.ts index 4c6c4e944..04e06e1d6 100644 --- a/projects/components/src/popover/popover.ts +++ b/projects/components/src/popover/popover.ts @@ -34,6 +34,10 @@ export interface PopoverRelativePosition { export interface PopoverFixedPosition { type: PopoverPositionType.Fixed; location: PopoverFixedPositionLocation; + customLocation?: { + x: number; + y: number; + }; } export type PopoverPosition = @@ -65,7 +69,8 @@ export const enum PopoverRelativePositionLocation { export const enum PopoverFixedPositionLocation { RightUnderHeader, Centered, - Right + Right, + Custom } export const POPOVER_DATA = new InjectionToken('POPOVER_DATA'); diff --git a/projects/observability/src/pages/apis/api-detail/metrics/api-metrics.dashboard.ts b/projects/observability/src/pages/apis/api-detail/metrics/api-metrics.dashboard.ts index b5e6abab9..d1b726a31 100644 --- a/projects/observability/src/pages/apis/api-detail/metrics/api-metrics.dashboard.ts +++ b/projects/observability/src/pages/apis/api-detail/metrics/api-metrics.dashboard.ts @@ -276,7 +276,10 @@ export const apiMetricsDashboard: DashboardDefaultConfiguration = { } } } - ] + ], + 'selection-handler': { + type: 'cartesian-explorer-selection-handler' + } }, { type: 'container-widget', @@ -336,7 +339,11 @@ export const apiMetricsDashboard: DashboardDefaultConfiguration = { type: 'entity-error-percentage-timeseries-data-source' } } - ] + ], + 'selection-handler': { + type: 'cartesian-explorer-selection-handler', + 'show-context-menu': true + } }, { type: 'cartesian-widget', @@ -375,7 +382,11 @@ export const apiMetricsDashboard: DashboardDefaultConfiguration = { } } } - ] + ], + 'selection-handler': { + type: 'cartesian-explorer-selection-handler', + 'show-context-menu': true + } } ] } @@ -410,7 +421,11 @@ export const apiMetricsDashboard: DashboardDefaultConfiguration = { } } } - ] + ], + 'selection-handler': { + type: 'cartesian-explorer-selection-handler', + 'show-context-menu': true + } } ] } diff --git a/projects/observability/src/pages/apis/api-detail/overview/api-overview.dashboard.ts b/projects/observability/src/pages/apis/api-detail/overview/api-overview.dashboard.ts index 45a638748..30c48924c 100644 --- a/projects/observability/src/pages/apis/api-detail/overview/api-overview.dashboard.ts +++ b/projects/observability/src/pages/apis/api-detail/overview/api-overview.dashboard.ts @@ -295,7 +295,10 @@ export const apiOverviewDashboard: DashboardDefaultConfiguration = { } } } - ] + ], + 'selection-handler': { + type: 'cartesian-explorer-selection-handler' + } }, { type: 'cartesian-widget', @@ -389,7 +392,10 @@ export const apiOverviewDashboard: DashboardDefaultConfiguration = { } } } - ] + ], + 'selection-handler': { + type: 'cartesian-explorer-selection-handler' + } }, { type: 'cartesian-widget', @@ -483,7 +489,10 @@ export const apiOverviewDashboard: DashboardDefaultConfiguration = { } } } - ] + ], + 'selection-handler': { + type: 'cartesian-explorer-selection-handler' + } } ] }, diff --git a/projects/observability/src/pages/apis/backend-detail/metrics/backend-metrics.dashboard.ts b/projects/observability/src/pages/apis/backend-detail/metrics/backend-metrics.dashboard.ts index 433d293e4..e5f825579 100644 --- a/projects/observability/src/pages/apis/backend-detail/metrics/backend-metrics.dashboard.ts +++ b/projects/observability/src/pages/apis/backend-detail/metrics/backend-metrics.dashboard.ts @@ -260,7 +260,10 @@ export const backendMetricsDashboard: DashboardDefaultConfiguration = { } } } - ] + ], + 'selection-handler': { + type: 'cartesian-explorer-selection-handler' + } }, { type: 'container-widget', @@ -320,7 +323,11 @@ export const backendMetricsDashboard: DashboardDefaultConfiguration = { type: 'entity-error-percentage-timeseries-data-source' } } - ] + ], + 'selection-handler': { + type: 'cartesian-explorer-selection-handler', + 'show-context-menu': true + } }, { type: 'cartesian-widget', @@ -343,7 +350,11 @@ export const backendMetricsDashboard: DashboardDefaultConfiguration = { } } } - ] + ], + 'selection-handler': { + type: 'cartesian-explorer-selection-handler', + 'show-context-menu': true + } } ] } @@ -378,7 +389,11 @@ export const backendMetricsDashboard: DashboardDefaultConfiguration = { } } } - ] + ], + 'selection-handler': { + type: 'cartesian-explorer-selection-handler', + 'show-context-menu': true + } } ] } diff --git a/projects/observability/src/pages/apis/backend-detail/overview/backend-overview.component.ts b/projects/observability/src/pages/apis/backend-detail/overview/backend-overview.component.ts index 7a90af881..685685ba3 100644 --- a/projects/observability/src/pages/apis/backend-detail/overview/backend-overview.component.ts +++ b/projects/observability/src/pages/apis/backend-detail/overview/backend-overview.component.ts @@ -287,7 +287,10 @@ export class BackendOverviewComponent { } } } - ] + ], + 'selection-handler': { + type: 'cartesian-explorer-selection-handler' + } }, { type: 'cartesian-widget', @@ -365,7 +368,10 @@ export class BackendOverviewComponent { } } } - ] + ], + 'selection-handler': { + type: 'cartesian-explorer-selection-handler' + } }, { type: 'cartesian-widget', @@ -443,7 +449,10 @@ export class BackendOverviewComponent { } } } - ] + ], + 'selection-handler': { + type: 'cartesian-explorer-selection-handler' + } } ] }, diff --git a/projects/observability/src/pages/apis/service-detail/metrics/service-metrics.dashboard.ts b/projects/observability/src/pages/apis/service-detail/metrics/service-metrics.dashboard.ts index 6d8ae4569..14cd5e250 100644 --- a/projects/observability/src/pages/apis/service-detail/metrics/service-metrics.dashboard.ts +++ b/projects/observability/src/pages/apis/service-detail/metrics/service-metrics.dashboard.ts @@ -276,7 +276,10 @@ export const serviceMetricsDashboard: DashboardDefaultConfiguration = { } } } - ] + ], + 'selection-handler': { + type: 'cartesian-explorer-selection-handler' + } }, { type: 'container-widget', @@ -336,7 +339,11 @@ export const serviceMetricsDashboard: DashboardDefaultConfiguration = { type: 'entity-error-percentage-timeseries-data-source' } } - ] + ], + 'selection-handler': { + type: 'cartesian-explorer-selection-handler', + 'show-context-menu': true + } }, { type: 'cartesian-widget', @@ -375,7 +382,11 @@ export const serviceMetricsDashboard: DashboardDefaultConfiguration = { } } } - ] + ], + 'selection-handler': { + type: 'cartesian-explorer-selection-handler', + 'show-context-menu': true + } } ] } @@ -410,7 +421,11 @@ export const serviceMetricsDashboard: DashboardDefaultConfiguration = { } } } - ] + ], + 'selection-handler': { + type: 'cartesian-explorer-selection-handler', + 'show-context-menu': true + } } ] } diff --git a/projects/observability/src/pages/apis/service-detail/overview/service-overview.dashboard.ts b/projects/observability/src/pages/apis/service-detail/overview/service-overview.dashboard.ts index 316a9379a..288195d75 100644 --- a/projects/observability/src/pages/apis/service-detail/overview/service-overview.dashboard.ts +++ b/projects/observability/src/pages/apis/service-detail/overview/service-overview.dashboard.ts @@ -339,7 +339,11 @@ export const serviceOverviewDashboard: DashboardDefaultConfiguration = { } } } - ] + ], + 'selection-handler': { + type: 'cartesian-explorer-selection-handler', + 'show-context-menu': true + } }, { type: 'cartesian-widget', @@ -433,7 +437,11 @@ export const serviceOverviewDashboard: DashboardDefaultConfiguration = { } } } - ] + ], + 'selection-handler': { + type: 'cartesian-explorer-selection-handler', + 'show-context-menu': true + } }, { type: 'cartesian-widget', @@ -527,7 +535,11 @@ export const serviceOverviewDashboard: DashboardDefaultConfiguration = { } } } - ] + ], + 'selection-handler': { + type: 'cartesian-explorer-selection-handler', + 'show-context-menu': true + } } ] }, diff --git a/projects/observability/src/pages/explorer/explorer-dashboard-builder.test.ts b/projects/observability/src/pages/explorer/explorer-dashboard-builder.test.ts index f3edcb978..4caf64e4a 100644 --- a/projects/observability/src/pages/explorer/explorer-dashboard-builder.test.ts +++ b/projects/observability/src/pages/explorer/explorer-dashboard-builder.test.ts @@ -41,7 +41,11 @@ describe('Explorer dashboard builder', () => { type: 'cartesian-widget', 'selectable-interval': false, 'series-from-data': true, - 'legend-position': LegendPosition.Bottom + 'legend-position': LegendPosition.Bottom, + 'selection-handler': { + type: 'cartesian-explorer-selection-handler', + 'show-context-menu': false + } }, onReady: expect.any(Function) } diff --git a/projects/observability/src/pages/explorer/explorer-dashboard-builder.ts b/projects/observability/src/pages/explorer/explorer-dashboard-builder.ts index c28f45dff..14a72b512 100644 --- a/projects/observability/src/pages/explorer/explorer-dashboard-builder.ts +++ b/projects/observability/src/pages/explorer/explorer-dashboard-builder.ts @@ -62,7 +62,11 @@ export class ExplorerDashboardBuilder { type: 'cartesian-widget', 'selectable-interval': false, 'series-from-data': true, - 'legend-position': LegendPosition.Bottom + 'legend-position': LegendPosition.Bottom, + 'selection-handler': { + type: 'cartesian-explorer-selection-handler', + 'show-context-menu': false + } }, onReady: dashboard => { dashboard.createAndSetRootDataFromModelClass(ExplorerVisualizationCartesianDataSourceModel); diff --git a/projects/observability/src/shared/components/cartesian/cartesian-chart.component.test.ts b/projects/observability/src/shared/components/cartesian/cartesian-chart.component.test.ts index 2b67efac3..151495d62 100644 --- a/projects/observability/src/shared/components/cartesian/cartesian-chart.component.test.ts +++ b/projects/observability/src/shared/components/cartesian/cartesian-chart.component.test.ts @@ -1,6 +1,7 @@ import { Renderer2 } from '@angular/core'; import { fakeAsync, tick } from '@angular/core/testing'; import { DomElementMeasurerService, selector } from '@hypertrace/common'; +import { PopoverService } from '@hypertrace/components'; import { createHostFactory, mockProvider } from '@ngneat/spectator/jest'; import { LegendPosition } from '../legend/legend.component'; import { ChartTooltipBuilderService } from '../utils/chart-tooltip/chart-tooltip-builder.service'; @@ -29,7 +30,8 @@ describe('Cartesian Chart component', () => { }), getComputedTextLength: () => 0 }), - mockProvider(Renderer2) + mockProvider(Renderer2), + mockProvider(PopoverService) ] }); diff --git a/projects/observability/src/shared/components/cartesian/cartesian-chart.component.ts b/projects/observability/src/shared/components/cartesian/cartesian-chart.component.ts index 69c6c57dd..130b8d530 100644 --- a/projects/observability/src/shared/components/cartesian/cartesian-chart.component.ts +++ b/projects/observability/src/shared/components/cartesian/cartesian-chart.component.ts @@ -11,6 +11,7 @@ import { ViewChild } from '@angular/core'; import { DateCoercer, DateFormatter, TimeRange } from '@hypertrace/common'; + import { defaults } from 'lodash-es'; import { IntervalValue } from '../interval-select/interval-select.component'; import { LegendPosition } from '../legend/legend.component'; @@ -19,6 +20,7 @@ import { DefaultChartTooltipRenderData } from '../utils/chart-tooltip/default/de import { MouseLocationData } from '../utils/mouse-tracking/mouse-tracking'; import { Axis, AxisLocation, AxisType, Band, CartesianChart, RenderingStrategy, Series } from './chart'; import { ChartBuilderService } from './chart-builder.service'; +import { CartesianSelectedData, ChartEvent } from './chart-interactivty'; import { defaultXDataAccessor, defaultYDataAccessor } from './d3/scale/default-data-accessors'; @Component({ @@ -64,6 +66,11 @@ export class CartesianChartComponent implements OnChanges, OnDestroy { @Output() public readonly selectedIntervalChange: EventEmitter = new EventEmitter(); + @Output() + public readonly selectionChange: EventEmitter< + MouseLocationData | Band>[] | CartesianSelectedData + > = new EventEmitter(); + @ViewChild('chartContainer', { static: true }) public readonly container!: ElementRef; @@ -93,7 +100,10 @@ export class CartesianChartComponent implements OnChanges, OnDestroy { this.chartTooltipBuilderService.constructTooltip>(data => this.convertToDefaultTooltipRenderData(data) ) - ); + ) + .withEventListener(ChartEvent.Select, selectedData => { + this.selectionChange.emit(selectedData); + }); if (this.bands) { this.chart.withBands(...this.bands); diff --git a/projects/observability/src/shared/components/cartesian/chart-interactivty.ts b/projects/observability/src/shared/components/cartesian/chart-interactivty.ts index 411330319..d7f18e42c 100644 --- a/projects/observability/src/shared/components/cartesian/chart-interactivty.ts +++ b/projects/observability/src/shared/components/cartesian/chart-interactivty.ts @@ -1,15 +1,28 @@ +import { TimeRange } from '@hypertrace/common'; import { MouseLocationData } from '../utils/mouse-tracking/mouse-tracking'; import { AxisType, Band, Series } from './chart'; export const enum ChartEvent { Click, DoubleClick, - RightClick + RightClick, + Select } -export type ChartEventListener = (data: MouseLocationData | Band>[]) => void; +export type ChartEventListener = ( + data: MouseLocationData | Band>[] | CartesianSelectedData +) => void; export interface ChartTooltipTrackingOptions { followSingleAxis?: AxisType; radius?: number; } + +export interface CartesianSelectedData { + timeRange: TimeRange; + selectedData: MouseLocationData | Band>[]; + location: { + x: number; + y: number; + }; +} diff --git a/projects/observability/src/shared/components/cartesian/d3/chart/cartesian-chart.ts b/projects/observability/src/shared/components/cartesian/d3/chart/cartesian-chart.ts index 2ba8c615b..a6188143b 100644 --- a/projects/observability/src/shared/components/cartesian/d3/chart/cartesian-chart.ts +++ b/projects/observability/src/shared/components/cartesian/d3/chart/cartesian-chart.ts @@ -1,6 +1,9 @@ import { Injector, Renderer2 } from '@angular/core'; -import { TimeRange } from '@hypertrace/common'; -import { ContainerElement, mouse, select } from 'd3-selection'; + +import { TimeRange, TimeRangeService } from '@hypertrace/common'; +import { BrushBehavior, brushX, D3BrushEvent } from 'd3-brush'; +// tslint:disable-next-line: no-restricted-globals +import { ContainerElement, event as d3CurrentEvent, mouse, select } from 'd3-selection'; import { Subscription } from 'rxjs'; import { LegendPosition } from '../../../legend/legend.component'; import { ChartTooltipRef } from '../../../utils/chart-tooltip/chart-tooltip-popover'; @@ -79,6 +82,38 @@ export class DefaultCartesianChart implements CartesianChart { protected readonly domRenderer: Renderer2 ) {} + protected onBrushSelection(event: D3BrushEvent): void { + if (!event.selection) { + return; + } + + this.eventListeners.forEach(listener => { + if (listener.event === ChartEvent.Select) { + const { height } = this.mouseEventContainer!.getBoundingClientRect(); + + const [startPoint, endPoint] = event.selection as [number, number]; + + const startDate = this.allSeriesData[0].getXAxisValue(startPoint); + const endDate = this.allSeriesData[0].getXAxisValue(endPoint); + + const startData = this.allSeriesData.flatMap(viz => viz.dataForLocation({ x: startPoint, y: height })); + + const endData = this.allSeriesData.flatMap(viz => viz.dataForLocation({ x: endPoint, y: height })); + + const timeRange = TimeRangeService.toFixedTimeRange(startDate, endDate); + const selectedData = { + timeRange: timeRange, + selectedData: [startData[0], endData[0]], + location: { + x: event.sourceEvent.clientX, + y: event.sourceEvent.clientY + } + }; + listener.onEvent(selectedData); + } + }); + } + public destroy(): this { this.clear(); @@ -266,11 +301,15 @@ export class DefaultCartesianChart implements CartesianChart { eventContainer.on('mousemove', () => this.onMouseMove()).on('mouseleave', () => this.onMouseLeave()); - this.eventListeners.forEach(listener => - eventContainer.on(this.getNativeEventName(listener.event), () => - listener.onEvent(this.getMouseDataForCurrentEvent()) - ) - ); + this.eventListeners.forEach(listener => { + if (listener.event === ChartEvent.Select) { + this.attachBrush(); + } else { + eventContainer.on(this.getNativeEventName(listener.event), () => + listener.onEvent(this.getMouseDataForCurrentEvent()) + ); + } + }); } protected clear(): void { @@ -297,6 +336,20 @@ export class DefaultCartesianChart implements CartesianChart { this.setupEventListeners(); } + private attachBrush(): void { + const brushBehaviour: BrushBehavior = brushX().on('end', () => + this.onBrushSelection(d3CurrentEvent) + ); + + const { width, height } = this.hostElement.getBoundingClientRect(); + brushBehaviour.extent([ + [0, 0], + [width, height] + ]); + + select(this.mouseEventContainer!).append('g').attr('class', 'brush').call(brushBehaviour); + } + private redrawVisualization(): void { const chartViz = select(this.chartContainerElement!).selectAll( `.${DefaultCartesianChart.CHART_VISUALIZATION_CLASS}` @@ -442,6 +495,7 @@ export class DefaultCartesianChart implements CartesianChart { if (!this.dataElement) { return []; } + const location = mouse(this.dataElement); return this.allSeriesData.flatMap(viz => viz.dataForLocation({ x: location[0], y: location[1] })); @@ -455,6 +509,8 @@ export class DefaultCartesianChart implements CartesianChart { return 'dblclick'; case ChartEvent.RightClick: return 'contextmenu'; + case ChartEvent.Select: + return 'select'; default: return ''; } diff --git a/projects/observability/src/shared/components/cartesian/d3/data/cartesian-data.ts b/projects/observability/src/shared/components/cartesian/d3/data/cartesian-data.ts index 57359d6c5..577f9f235 100644 --- a/projects/observability/src/shared/components/cartesian/d3/data/cartesian-data.ts +++ b/projects/observability/src/shared/components/cartesian/d3/data/cartesian-data.ts @@ -48,4 +48,8 @@ export abstract class CartesianData { protected buildYScale(): AnyCartesianScale { return this.scaleBuilder.build(AxisType.Y); } + + public getXAxisValue(x: number): Date { + return new Date(this.xScale.invert(x)); + } } diff --git a/projects/observability/src/shared/components/cartesian/d3/scale/band/cartesian-band-scale.ts b/projects/observability/src/shared/components/cartesian/d3/scale/band/cartesian-band-scale.ts index 6648e3281..93048fee6 100644 --- a/projects/observability/src/shared/components/cartesian/d3/scale/band/cartesian-band-scale.ts +++ b/projects/observability/src/shared/components/cartesian/d3/scale/band/cartesian-band-scale.ts @@ -40,4 +40,8 @@ export class CartesianBandScale extends CartesianScale { protected getEmptyScale(): ScaleBand { return scaleBand().paddingInner(0.2).paddingOuter(0.1).align(1); } + + public invert(x: number): number { + return x; + } } diff --git a/projects/observability/src/shared/components/cartesian/d3/scale/cartesian-scale.ts b/projects/observability/src/shared/components/cartesian/d3/scale/cartesian-scale.ts index ab68983fe..54cc3ff79 100644 --- a/projects/observability/src/shared/components/cartesian/d3/scale/cartesian-scale.ts +++ b/projects/observability/src/shared/components/cartesian/d3/scale/cartesian-scale.ts @@ -29,6 +29,8 @@ export abstract class CartesianScale { protected abstract setRange(): void; + public abstract invert(x: number): Date | number; + public getValueFromData(data: TData): TDomain { return this.dataAccessor(data); } diff --git a/projects/observability/src/shared/components/cartesian/d3/scale/numeric/linear/cartesian-continuous-scale.ts b/projects/observability/src/shared/components/cartesian/d3/scale/numeric/linear/cartesian-continuous-scale.ts index 07889d0c3..ae033702f 100644 --- a/projects/observability/src/shared/components/cartesian/d3/scale/numeric/linear/cartesian-continuous-scale.ts +++ b/projects/observability/src/shared/components/cartesian/d3/scale/numeric/linear/cartesian-continuous-scale.ts @@ -47,4 +47,8 @@ export class CartesianContinuousScale extends CartesianNumericScale { return scaleLinear(); } + + public invert(x: number): number { + return this.d3Implementation.invert(x); + } } diff --git a/projects/observability/src/shared/components/cartesian/d3/scale/numeric/time/cartesian-time-scale.ts b/projects/observability/src/shared/components/cartesian/d3/scale/numeric/time/cartesian-time-scale.ts index 81102382f..4c0c7e441 100644 --- a/projects/observability/src/shared/components/cartesian/d3/scale/numeric/time/cartesian-time-scale.ts +++ b/projects/observability/src/shared/components/cartesian/d3/scale/numeric/time/cartesian-time-scale.ts @@ -63,4 +63,8 @@ export class CartesianTimeScale extends CartesianNumericScale { protected getEmptyScale(): ScaleTime { return scaleTime(); } + + public invert(x: number): Date { + return this.d3Implementation.invert(x); + } } diff --git a/projects/observability/src/shared/dashboard/widgets/charts/cartesian-widget/cartesian-widget-renderer.component.test.ts b/projects/observability/src/shared/dashboard/widgets/charts/cartesian-widget/cartesian-widget-renderer.component.test.ts index ef173d79c..018fdb566 100644 --- a/projects/observability/src/shared/dashboard/widgets/charts/cartesian-widget/cartesian-widget-renderer.component.test.ts +++ b/projects/observability/src/shared/dashboard/widgets/charts/cartesian-widget/cartesian-widget-renderer.component.test.ts @@ -3,8 +3,10 @@ import { IconLibraryTestingModule } from '@hypertrace/assets-library'; import { FormattingModule, IntervalDurationService, + MemoizeModule, RecursivePartial, TimeDuration, + TimeRangeService, TimeUnit } from '@hypertrace/common'; import { LoadAsyncModule } from '@hypertrace/components'; @@ -15,11 +17,56 @@ import { runFakeRxjs } from '@hypertrace/test-utils'; import { createComponentFactory, mockProvider } from '@ngneat/spectator/jest'; import { of } from 'rxjs'; import { CartesianSeriesVisualizationType } from '../../../../components/cartesian/chart'; +import { CartesianSelectedData } from '../../../../components/cartesian/chart-interactivty'; import { CartesianWidgetRendererComponent } from './cartesian-widget-renderer.component'; import { CartesianWidgetModel } from './cartesian-widget.model'; import { MetricSeriesDataFetcher, SeriesModel } from './series.model'; describe('Cartesian widget renderer component', () => { + const selectedData: CartesianSelectedData = { + timeRange: TimeRangeService.toFixedTimeRange( + new Date('2021-11-02T05:33:19.288Z'), + new Date('2021-11-02T14:30:15.141Z') + ), + selectedData: [ + { + dataPoint: { timestamp: '2021-11-02T05:40:00.000Z', value: 1477.3599999999983 }, + context: { + data: [ + { timestamp: '2021-11-02T05:15:00.000Z', value: 774 }, + { timestamp: '2021-11-02T05:40:00.000Z', value: 1477.3599999999983 }, + { timestamp: '2021-11-02T12:05:00.000Z', value: 1056.48 } + ], + units: 'ms', + color: '#4b5f77', + name: 'p99', + type: CartesianSeriesVisualizationType.Column, + stacking: false, + hide: false + }, + location: { x: 59, y: 31.023400000000215 } + }, + { + dataPoint: { timestamp: '2021-11-02T12:05:00.000Z', value: 1056.48 }, + context: { + data: [ + { timestamp: '2021-11-02T05:15:00.000Z', value: 774 }, + { timestamp: '2021-11-02T05:40:00.000Z', value: 1477.3599999999983 }, + { timestamp: '2021-11-02T12:05:00.000Z', value: 1056.48 } + ], + units: 'ms', + color: '#4b5f77', + name: 'p99', + type: CartesianSeriesVisualizationType.Column, + stacking: false, + hide: false + }, + location: { x: 138, y: 82.58120000000001 } + } + ], + location: { x: 452, y: 763 } + }; + const buildComponent = createComponentFactory({ component: CartesianWidgetRendererComponent, providers: [ @@ -33,7 +80,7 @@ describe('Cartesian widget renderer component', () => { availableDurations.find(availableDuration => duration.equals(availableDuration)) }) ], - imports: [LoadAsyncModule, HttpClientTestingModule, IconLibraryTestingModule, FormattingModule], + imports: [LoadAsyncModule, HttpClientTestingModule, IconLibraryTestingModule, FormattingModule, MemoizeModule], shallow: true }); @@ -207,4 +254,23 @@ describe('Cartesian widget renderer component', () => { spectator.component.onIntervalChange('AUTO'); expect(fetcher.getData).toHaveBeenLastCalledWith(undefined); }); + + test('calls selection handler on selection change', () => { + const fetcher = fetcherFactory([]); + const series = seriesFactory({}, fetcher); + const mockModel = cartesianModelFactory({ + series: [series], + maxSeriesDataPoints: 20, + selectionHandler: { + execute: jest.fn() + } + }); + const spectator = buildComponent({ + providers: [...mockDashboardWidgetProviders(mockModel)] + }); + + spectator.component.onSelectionChange(selectedData); + + expect(spectator.component.model.selectionHandler?.execute).toHaveBeenCalled(); + }); }); diff --git a/projects/observability/src/shared/dashboard/widgets/charts/cartesian-widget/cartesian-widget-renderer.component.ts b/projects/observability/src/shared/dashboard/widgets/charts/cartesian-widget/cartesian-widget-renderer.component.ts index 0e3298da3..d1f04f134 100644 --- a/projects/observability/src/shared/dashboard/widgets/charts/cartesian-widget/cartesian-widget-renderer.component.ts +++ b/projects/observability/src/shared/dashboard/widgets/charts/cartesian-widget/cartesian-widget-renderer.component.ts @@ -1,12 +1,15 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject } from '@angular/core'; import { IntervalDurationService, TimeDuration } from '@hypertrace/common'; + import { InteractiveDataWidgetRenderer } from '@hypertrace/dashboards'; import { Renderer } from '@hypertrace/hyperdash'; import { RendererApi, RENDERER_API } from '@hypertrace/hyperdash-angular'; import { NEVER, Observable } from 'rxjs'; import { switchMap, tap } from 'rxjs/operators'; -import { Band, Series } from '../../../../components/cartesian/chart'; +import { Axis, Band, Series } from '../../../../components/cartesian/chart'; +import { CartesianSelectedData } from '../../../../components/cartesian/chart-interactivty'; import { IntervalValue } from '../../../../components/interval-select/interval-select.component'; +import { CartesianAxisModel } from './axis/cartesian-axis.model'; import { CartesianDataFetcher, CartesianResult, CartesianWidgetModel } from './cartesian-widget.model'; @Renderer({ modelClass: CartesianWidgetModel }) @@ -19,8 +22,8 @@ import { CartesianDataFetcher, CartesianResult, CartesianWidgetModel } from './c class="fill-container" [series]="data.series" [bands]="data.bands" - [xAxisOption]="this.model.xAxis && this.model.xAxis!.getAxisOption()" - [yAxisOption]="this.model.yAxis && this.model.yAxis!.getAxisOption()" + [xAxisOption]="this.getAxisOption | htMemoize: this.model?.xAxis" + [yAxisOption]="this.getAxisOption | htMemoize: this.model?.yAxis" [showXAxis]="this.model.showXAxis" [showYAxis]="this.model.showYAxis" [timeRange]="this.timeRange" @@ -28,12 +31,13 @@ import { CartesianDataFetcher, CartesianResult, CartesianWidgetModel } from './c [intervalOptions]="this.intervalOptions" [legend]="this.model.legendPosition" (selectedIntervalChange)="this.onIntervalChange($event)" + (selectionChange)="this.onSelectionChange($event)" > ` }) -export class CartesianWidgetRendererComponent extends InteractiveDataWidgetRenderer< +export class CartesianWidgetRendererComponent extends InteractiveDataWidgetRenderer< CartesianWidgetModel, CartesianData > { @@ -54,6 +58,14 @@ export class CartesianWidgetRendererComponent extends Interacti this.updateDataObservable(); } + public onSelectionChange(selectedData: CartesianSelectedData): void { + this.model.selectionHandler?.execute(selectedData); + } + + public getAxisOption(axis: CartesianAxisModel): Partial { + return axis?.getAxisOption(); + } + protected fetchData(): Observable> { return this.model.getDataFetcher().pipe( tap(fetcher => { diff --git a/projects/observability/src/shared/dashboard/widgets/charts/cartesian-widget/cartesian-widget.model.ts b/projects/observability/src/shared/dashboard/widgets/charts/cartesian-widget/cartesian-widget.model.ts index f8043407a..2d16152b2 100644 --- a/projects/observability/src/shared/dashboard/widgets/charts/cartesian-widget/cartesian-widget.model.ts +++ b/projects/observability/src/shared/dashboard/widgets/charts/cartesian-widget/cartesian-widget.model.ts @@ -16,6 +16,7 @@ import { map } from 'rxjs/operators'; import { Band, CartesianSeriesVisualizationType, Series } from '../../../../components/cartesian/chart'; import { LegendPosition } from '../../../../components/legend/legend.component'; import { MetricTimeseriesBandInterval } from '../../../../graphql/model/metric/metric-timeseries'; +import { InteractionHandler } from '../../../interaction/interaction-handler'; import { CartesianAxisModel } from './axis/cartesian-axis.model'; import { BAND_ARRAY_TYPE } from './band-array/band-array-type'; import { BandModel, MetricBand, MetricBandDataFetcher } from './band.model'; @@ -146,6 +147,13 @@ export class CartesianWidgetModel { }) public maxSeriesDataPoints?: number; + @ModelProperty({ + key: 'selection-handler', + displayName: 'Selection Handler', + type: ModelPropertyType.TYPE + }) + public selectionHandler?: InteractionHandler; + @ModelInject(MODEL_API) private readonly api!: ModelApi; diff --git a/projects/observability/src/shared/dashboard/widgets/charts/cartesian-widget/cartesian-widget.module.ts b/projects/observability/src/shared/dashboard/widgets/charts/cartesian-widget/cartesian-widget.module.ts index 9380af394..6d450aab1 100644 --- a/projects/observability/src/shared/dashboard/widgets/charts/cartesian-widget/cartesian-widget.module.ts +++ b/projects/observability/src/shared/dashboard/widgets/charts/cartesian-widget/cartesian-widget.module.ts @@ -1,6 +1,6 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; -import { FormattingModule } from '@hypertrace/common'; +import { FormattingModule, MemoizeModule } from '@hypertrace/common'; import { ButtonModule, LabelModule, LoadAsyncModule, TitledContentModule } from '@hypertrace/components'; import { DashboardPropertyEditorsModule } from '@hypertrace/dashboards'; import { DashboardCoreModule, DashboardEditorModule } from '@hypertrace/hyperdash-angular'; @@ -10,6 +10,9 @@ import { BAND_ARRAY_TYPE } from './band-array/band-array-type'; import { BandModel } from './band.model'; import { CartesianWidgetRendererComponent } from './cartesian-widget-renderer.component'; import { CartesianWidgetModel } from './cartesian-widget.model'; +import { CartesianExplorerContextMenuModule } from './interactions/cartesian-explorer-context-menu/cartesian-explorer-context-menu.module'; +import { CartesianExplorerSelectionHandlerModel } from './interactions/cartesian-explorer-selection-handler.model'; + import { SeriesArrayEditorComponent } from './series-array/series-array-editor.component'; import { SERIES_ARRAY_TYPE } from './series-array/series-array-type'; import { SeriesModel } from './series.model'; @@ -24,14 +27,22 @@ import { SeriesModel } from './series.model'; DashboardEditorModule, ButtonModule, DashboardCoreModule.with({ - models: [CartesianWidgetModel, SeriesModel, BandModel, CartesianAxisModel], + models: [ + CartesianWidgetModel, + SeriesModel, + BandModel, + CartesianAxisModel, + CartesianExplorerSelectionHandlerModel + ], renderers: [CartesianWidgetRendererComponent], editors: [SeriesArrayEditorComponent], propertyTypes: [SERIES_ARRAY_TYPE, BAND_ARRAY_TYPE] }), TitledContentModule, LoadAsyncModule, - FormattingModule + FormattingModule, + CartesianExplorerContextMenuModule, + MemoizeModule ] }) export class CartesianWidgetModule {} diff --git a/projects/observability/src/shared/dashboard/widgets/charts/cartesian-widget/interactions/cartesian-explorer-context-menu/cartesian-explorer-context-menu.component.scss b/projects/observability/src/shared/dashboard/widgets/charts/cartesian-widget/interactions/cartesian-explorer-context-menu/cartesian-explorer-context-menu.component.scss new file mode 100644 index 000000000..50e7f903b --- /dev/null +++ b/projects/observability/src/shared/dashboard/widgets/charts/cartesian-widget/interactions/cartesian-explorer-context-menu/cartesian-explorer-context-menu.component.scss @@ -0,0 +1,19 @@ +@import 'font'; +@import 'color-palette'; +@import 'interaction'; + +.context-menu-container { + box-shadow: -1px -1px 8px rgba(0, 0, 0, 0.08), -3px 1px 24px rgba(0, 0, 0, 0.12); + border-radius: 6px; + overflow: hidden; + background-color: white; + z-index: 1; +} + +.context-menu { + display: flex; + flex-direction: row; + align-items: center; + padding: 5px 20px; + gap: 4px; +} diff --git a/projects/observability/src/shared/dashboard/widgets/charts/cartesian-widget/interactions/cartesian-explorer-context-menu/cartesian-explorer-context-menu.component.test.ts b/projects/observability/src/shared/dashboard/widgets/charts/cartesian-widget/interactions/cartesian-explorer-context-menu/cartesian-explorer-context-menu.component.test.ts new file mode 100644 index 000000000..70d15677c --- /dev/null +++ b/projects/observability/src/shared/dashboard/widgets/charts/cartesian-widget/interactions/cartesian-explorer-context-menu/cartesian-explorer-context-menu.component.test.ts @@ -0,0 +1,130 @@ +import { StaticProvider } from '@angular/core'; +import { IconType } from '@hypertrace/assets-library'; +import { TimeRangeService } from '@hypertrace/common'; +import { ButtonComponent, DividerComponent, POPOVER_DATA } from '@hypertrace/components'; +import { createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; +import { MockComponent } from 'ng-mocks'; +import { CartesianSeriesVisualizationType } from '../../../../../../components/cartesian/chart'; +import { CartesianSelectedData } from '../../../../../../components/cartesian/chart-interactivty'; +import { CartesainExplorerNavigationService } from '../cartesian-explorer-navigation.service'; +import { CartesianExplorerContextMenuComponent } from './cartesian-explorer-context-menu.component'; + +describe('Cartesian context menu component', () => { + const selectedData: CartesianSelectedData = { + timeRange: TimeRangeService.toFixedTimeRange( + new Date('2021-11-02T05:33:19.288Z'), + new Date('2021-11-02T14:30:15.141Z') + ), + selectedData: [ + { + dataPoint: { timestamp: '2021-11-02T05:40:00.000Z', value: 1477.3599999999983 }, + context: { + data: [ + { timestamp: '2021-11-02T05:15:00.000Z', value: 774 }, + { timestamp: '2021-11-02T05:40:00.000Z', value: 1477.3599999999983 }, + { timestamp: '2021-11-02T12:05:00.000Z', value: 1056.48 } + ], + units: 'ms', + color: '#4b5f77', + name: 'p99', + type: CartesianSeriesVisualizationType.Column, + stacking: false, + hide: false + }, + location: { x: 59, y: 31.023400000000215 } + }, + { + dataPoint: { timestamp: '2021-11-02T12:05:00.000Z', value: 1056.48 }, + context: { + data: [ + { timestamp: '2021-11-02T05:15:00.000Z', value: 774 }, + { timestamp: '2021-11-02T05:40:00.000Z', value: 1477.3599999999983 }, + { timestamp: '2021-11-02T12:05:00.000Z', value: 1056.48 } + ], + units: 'ms', + color: '#4b5f77', + name: 'p99', + type: CartesianSeriesVisualizationType.Column, + stacking: false, + hide: false + }, + location: { x: 138, y: 82.58120000000001 } + } + ], + location: { x: 452, y: 763 } + }; + let spectator: Spectator>; + + const createComponent = createComponentFactory({ + component: CartesianExplorerContextMenuComponent, + declarations: [MockComponent(ButtonComponent), MockComponent(DividerComponent)], + shallow: true, + providers: [ + mockProvider(CartesainExplorerNavigationService, { + navigateToExplorer: jest.fn() + }), + mockProvider(TimeRangeService, { + setFixedRange: jest.fn() + }) + ] + }); + + const buildProviders = (data: CartesianSelectedData): { providers: StaticProvider[] } => ({ + providers: [ + { + provide: POPOVER_DATA, + useValue: data + } + ] + }); + + test('should navigate to explorer on click explore menu', () => { + spectator = createComponent(buildProviders(selectedData)); + + spectator.component.selectionData = selectedData; + spectator.component.menus = [ + { + name: 'Set Time Range', + icon: IconType.Alarm, + onClick: () => { + spectator + .inject(TimeRangeService) + .setFixedRange(selectedData.timeRange.startTime, selectedData.timeRange.endTime); + } + }, + { + name: 'Explore', + icon: IconType.ArrowUpRight, + onClick: () => { + spectator + .inject(CartesainExplorerNavigationService) + .navigateToExplorer(selectedData.timeRange.startTime, selectedData.timeRange.endTime); + } + } + ]; + + const buttons = spectator.queryAll(ButtonComponent); + expect(buttons.length).toBe(2); + + const exploreMenu = spectator.queryAll('ht-button')[1]; + + spectator.click(exploreMenu); + + expect(spectator.inject(CartesainExplorerNavigationService).navigateToExplorer).toHaveBeenCalled(); + }); + + test('should change timerange on click timerange menu', () => { + spectator = createComponent(buildProviders(selectedData)); + + spectator.component.selectionData = selectedData; + + const buttons = spectator.queryAll(ButtonComponent); + expect(buttons.length).toBe(2); + + const timerangeMenu = spectator.queryAll('ht-button')[0]; + + spectator.click(timerangeMenu); + + expect(spectator.inject(TimeRangeService).setFixedRange).toHaveBeenCalled(); + }); +}); diff --git a/projects/observability/src/shared/dashboard/widgets/charts/cartesian-widget/interactions/cartesian-explorer-context-menu/cartesian-explorer-context-menu.component.ts b/projects/observability/src/shared/dashboard/widgets/charts/cartesian-widget/interactions/cartesian-explorer-context-menu/cartesian-explorer-context-menu.component.ts new file mode 100644 index 000000000..91da869ec --- /dev/null +++ b/projects/observability/src/shared/dashboard/widgets/charts/cartesian-widget/interactions/cartesian-explorer-context-menu/cartesian-explorer-context-menu.component.ts @@ -0,0 +1,65 @@ +import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'; +import { IconType } from '@hypertrace/assets-library'; +import { TimeRangeService } from '@hypertrace/common'; +import { ButtonStyle, POPOVER_DATA } from '@hypertrace/components'; +import { CartesianSelectedData } from '../../../../../../components/cartesian/chart-interactivty'; +import { CartesainExplorerNavigationService } from '../cartesian-explorer-navigation.service'; + +@Component({ + selector: 'ht-cartesian-explorer-context-menu', + styleUrls: ['./cartesian-explorer-context-menu.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+
+
+ +
+ + +
+
+ ` +}) +export class CartesianExplorerContextMenuComponent { + public menus?: ContextMenu[] = [ + { + name: 'Set Time Range', + icon: IconType.Alarm, + onClick: () => this.setTimeRangeHandler() + }, + { + name: 'Explore', + icon: IconType.ArrowUpRight, + onClick: () => this.explorerNavigationHandler() + } + ]; + + public display: string = ButtonStyle.PlainText; + public selectionData: CartesianSelectedData; + + public constructor( + @Inject(POPOVER_DATA) data: CartesianSelectedData, + private readonly cartesainExplorerNavigationService: CartesainExplorerNavigationService, + private readonly timeRangeService: TimeRangeService + ) { + this.selectionData = data; + } + + public readonly explorerNavigationHandler = () => { + this.cartesainExplorerNavigationService.navigateToExplorer( + this.selectionData.timeRange.startTime, + this.selectionData.timeRange.endTime + ); + }; + + public readonly setTimeRangeHandler = () => { + this.timeRangeService.setFixedRange(this.selectionData.timeRange.startTime, this.selectionData.timeRange.endTime); + }; +} + +interface ContextMenu { + name: string; + icon: string; + onClick(): void; +} diff --git a/projects/observability/src/shared/dashboard/widgets/charts/cartesian-widget/interactions/cartesian-explorer-context-menu/cartesian-explorer-context-menu.module.ts b/projects/observability/src/shared/dashboard/widgets/charts/cartesian-widget/interactions/cartesian-explorer-context-menu/cartesian-explorer-context-menu.module.ts new file mode 100644 index 000000000..243589db9 --- /dev/null +++ b/projects/observability/src/shared/dashboard/widgets/charts/cartesian-widget/interactions/cartesian-explorer-context-menu/cartesian-explorer-context-menu.module.ts @@ -0,0 +1,11 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { ButtonModule, DividerModule } from '@hypertrace/components'; +import { CartesianExplorerContextMenuComponent } from './cartesian-explorer-context-menu.component'; + +@NgModule({ + imports: [CommonModule, DividerModule, ButtonModule], + declarations: [CartesianExplorerContextMenuComponent], + exports: [CartesianExplorerContextMenuComponent] +}) +export class CartesianExplorerContextMenuModule {} diff --git a/projects/observability/src/shared/dashboard/widgets/charts/cartesian-widget/interactions/cartesian-explorer-navigation.service.test.ts b/projects/observability/src/shared/dashboard/widgets/charts/cartesian-widget/interactions/cartesian-explorer-navigation.service.test.ts new file mode 100644 index 000000000..4bc26337f --- /dev/null +++ b/projects/observability/src/shared/dashboard/widgets/charts/cartesian-widget/interactions/cartesian-explorer-navigation.service.test.ts @@ -0,0 +1,30 @@ +import { NavigationService, TimeRangeService } from '@hypertrace/common'; +import { createServiceFactory, mockProvider, SpectatorService } from '@ngneat/spectator/jest'; +import { CartesainExplorerNavigationService } from './cartesian-explorer-navigation.service'; + +describe('Cartesian Explorer Navigation Service', () => { + let spectator: SpectatorService; + + const createService = createServiceFactory({ + service: CartesainExplorerNavigationService, + providers: [ + mockProvider(TimeRangeService, { + toQueryParams: jest.fn() + }), + mockProvider(NavigationService, { + navigate: jest.fn() + }) + ] + }); + + beforeEach(() => { + spectator = createService(); + }); + + test('should navigate to explorer with params', () => { + spectator.service.navigateToExplorer(new Date(), new Date()); + + expect(spectator.inject(TimeRangeService).toQueryParams).toHaveBeenCalled(); + expect(spectator.inject(NavigationService).navigate).toHaveBeenCalled(); + }); +}); diff --git a/projects/observability/src/shared/dashboard/widgets/charts/cartesian-widget/interactions/cartesian-explorer-navigation.service.ts b/projects/observability/src/shared/dashboard/widgets/charts/cartesian-widget/interactions/cartesian-explorer-navigation.service.ts new file mode 100644 index 000000000..d33c6d167 --- /dev/null +++ b/projects/observability/src/shared/dashboard/widgets/charts/cartesian-widget/interactions/cartesian-explorer-navigation.service.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@angular/core'; +import { NavigationParamsType, NavigationService, TimeRangeService } from '@hypertrace/common'; + +@Injectable({ + providedIn: 'root' +}) +export class CartesainExplorerNavigationService { + public constructor( + private readonly timeRangeService: TimeRangeService, + private readonly navigationService: NavigationService + ) {} + + public navigateToExplorer(start: Date, end: Date): void { + this.timeRangeService.setFixedRange(start, end); + const params = this.timeRangeService.toQueryParams(start, end); + + this.navigationService.navigate({ + navType: NavigationParamsType.InApp, + path: ['/explorer'], + queryParams: params, + queryParamsHandling: 'merge', + replaceCurrentHistory: false + }); + } +} diff --git a/projects/observability/src/shared/dashboard/widgets/charts/cartesian-widget/interactions/cartesian-explorer-selection-handler.model.test.ts b/projects/observability/src/shared/dashboard/widgets/charts/cartesian-widget/interactions/cartesian-explorer-selection-handler.model.test.ts new file mode 100644 index 000000000..caecb2dc2 --- /dev/null +++ b/projects/observability/src/shared/dashboard/widgets/charts/cartesian-widget/interactions/cartesian-explorer-selection-handler.model.test.ts @@ -0,0 +1,85 @@ +import { TimeRangeService } from '@hypertrace/common'; +import { PopoverService } from '@hypertrace/components'; +import { createModelFactory } from '@hypertrace/dashboards/testing'; +import { mockProvider } from '@ngneat/spectator/jest'; +import { CartesianSeriesVisualizationType } from '../../../../../components/cartesian/chart'; +import { CartesianSelectedData } from '../../../../../components/cartesian/chart-interactivty'; +import { CartesainExplorerNavigationService } from './cartesian-explorer-navigation.service'; +import { CartesianExplorerSelectionHandlerModel } from './cartesian-explorer-selection-handler.model'; + +describe('Cartesian Explorer Selection Handler Model', () => { + const selectedData: CartesianSelectedData = { + timeRange: TimeRangeService.toFixedTimeRange( + new Date('2021-11-02T05:33:19.288Z'), + new Date('2021-11-02T14:30:15.141Z') + ), + selectedData: [ + { + dataPoint: { timestamp: '2021-11-02T05:40:00.000Z', value: 1477.3599999999983 }, + context: { + data: [ + { timestamp: '2021-11-02T05:15:00.000Z', value: 774 }, + { timestamp: '2021-11-02T05:40:00.000Z', value: 1477.3599999999983 }, + { timestamp: '2021-11-02T12:05:00.000Z', value: 1056.48 } + ], + units: 'ms', + color: '#4b5f77', + name: 'p99', + type: CartesianSeriesVisualizationType.Column, + stacking: false, + hide: false + }, + location: { x: 59, y: 31.023400000000215 } + }, + { + dataPoint: { timestamp: '2021-11-02T12:05:00.000Z', value: 1056.48 }, + context: { + data: [ + { timestamp: '2021-11-02T05:15:00.000Z', value: 774 }, + { timestamp: '2021-11-02T05:40:00.000Z', value: 1477.3599999999983 }, + { timestamp: '2021-11-02T12:05:00.000Z', value: 1056.48 } + ], + units: 'ms', + color: '#4b5f77', + name: 'p99', + type: CartesianSeriesVisualizationType.Column, + stacking: false, + hide: false + }, + location: { x: 138, y: 82.58120000000001 } + } + ], + location: { x: 452, y: 763 } + }; + + const buildModel = createModelFactory({ + providers: [ + mockProvider(CartesainExplorerNavigationService, { + navigateToExplorer: jest.fn() + }), + mockProvider(PopoverService, { + drawPopover: jest.fn() + }) + ] + }); + + test('calls navigate to explorer correct parameters', () => { + const spectator = buildModel(CartesianExplorerSelectionHandlerModel); + const cartesainExplorerNavigationService = spectator.get(CartesainExplorerNavigationService); + + spectator.model.showContextMenu = false; + + spectator.model.execute(selectedData); + expect(cartesainExplorerNavigationService.navigateToExplorer).toHaveBeenCalled(); + }); + + test('show context menu', () => { + const spectator = buildModel(CartesianExplorerSelectionHandlerModel); + const popoverService = spectator.get(PopoverService); + + spectator.model.showContextMenu = true; + + spectator.model.execute(selectedData); + expect(popoverService.drawPopover).toHaveBeenCalled(); + }); +}); diff --git a/projects/observability/src/shared/dashboard/widgets/charts/cartesian-widget/interactions/cartesian-explorer-selection-handler.model.ts b/projects/observability/src/shared/dashboard/widgets/charts/cartesian-widget/interactions/cartesian-explorer-selection-handler.model.ts new file mode 100644 index 000000000..7689f8f67 --- /dev/null +++ b/projects/observability/src/shared/dashboard/widgets/charts/cartesian-widget/interactions/cartesian-explorer-selection-handler.model.ts @@ -0,0 +1,63 @@ +import { + PopoverBackdrop, + PopoverFixedPositionLocation, + PopoverPositionType, + PopoverRef, + PopoverService +} from '@hypertrace/components'; +import { BOOLEAN_PROPERTY, Model, ModelProperty } from '@hypertrace/hyperdash'; +import { ModelInject } from '@hypertrace/hyperdash-angular'; +import { Observable, of } from 'rxjs'; +import { CartesianSelectedData } from '../../../../../components/cartesian/chart-interactivty'; +import { InteractionHandler } from '../../../../interaction/interaction-handler'; +import { CartesianExplorerContextMenuComponent } from './cartesian-explorer-context-menu/cartesian-explorer-context-menu.component'; + +import { CartesainExplorerNavigationService } from './cartesian-explorer-navigation.service'; + +@Model({ + type: 'cartesian-explorer-selection-handler' +}) +export class CartesianExplorerSelectionHandlerModel implements InteractionHandler { + @ModelInject(CartesainExplorerNavigationService) + private readonly cartesainExplorerNavigationService!: CartesainExplorerNavigationService; + + @ModelInject(PopoverService) + private readonly popoverService!: PopoverService; + + public popover?: PopoverRef; + + @ModelProperty({ + key: 'show-context-menu', + displayName: 'Show Context Menu', + type: BOOLEAN_PROPERTY.type + }) + public showContextMenu: boolean = true; + + public execute(selectionData: CartesianSelectedData): Observable { + if (this.showContextMenu) { + this.showContextMenuList(selectionData); + this.popover?.closeOnBackdropClick(); + this.popover?.closeOnPopoverContentClick(); + } else { + this.cartesainExplorerNavigationService.navigateToExplorer( + selectionData.timeRange.startTime, + selectionData.timeRange.endTime + ); + } + + return of(); + } + + public showContextMenuList(selectionData: CartesianSelectedData): void { + this.popover = this.popoverService.drawPopover({ + componentOrTemplate: CartesianExplorerContextMenuComponent, + data: selectionData, + position: { + type: PopoverPositionType.Fixed, + location: PopoverFixedPositionLocation.Custom, + customLocation: selectionData.location + }, + backdrop: PopoverBackdrop.Transparent + }); + } +} diff --git a/src/app/home/home.dashboard.ts b/src/app/home/home.dashboard.ts index b5875503f..2dff0a0f9 100644 --- a/src/app/home/home.dashboard.ts +++ b/src/app/home/home.dashboard.ts @@ -477,7 +477,10 @@ export const homeDashboard: DashboardDefaultConfiguration = { } } } - ] + ], + 'selection-handler': { + type: 'cartesian-explorer-selection-handler' + } }, { type: 'cartesian-widget', @@ -554,7 +557,10 @@ export const homeDashboard: DashboardDefaultConfiguration = { } } } - ] + ], + 'selection-handler': { + type: 'cartesian-explorer-selection-handler' + } }, { type: 'cartesian-widget', @@ -631,7 +637,10 @@ export const homeDashboard: DashboardDefaultConfiguration = { } } } - ] + ], + 'selection-handler': { + type: 'cartesian-explorer-selection-handler' + } } ] },