diff --git a/projects/assets-library/assets/icons/calendar-dates.svg b/projects/assets-library/assets/icons/calendar-dates.svg new file mode 100644 index 000000000..3c8c8f497 --- /dev/null +++ b/projects/assets-library/assets/icons/calendar-dates.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/projects/assets-library/src/icons/icon-library.module.ts b/projects/assets-library/src/icons/icon-library.module.ts index b00809752..c5abfa164 100644 --- a/projects/assets-library/src/icons/icon-library.module.ts +++ b/projects/assets-library/src/icons/icon-library.module.ts @@ -25,6 +25,7 @@ const iconsRootPath = 'assets/icons'; { key: IconType.ArrowUpLeft, url: `${iconsRootPath}/arrow-up-left.svg` }, { key: IconType.ArrowUpRight, url: `${iconsRootPath}/arrow-up-right.svg` }, { key: IconType.Calls, url: `${iconsRootPath}/calls.svg` }, + { key: IconType.Calendar, url: `${iconsRootPath}/calendar-dates.svg` }, { key: IconType.CheckCircle, url: `${iconsRootPath}/check-circle.svg` }, { key: IconType.CheckCircleFill, url: `${iconsRootPath}/check-circle-fill.svg` }, { key: IconType.ChevronDown, url: `${iconsRootPath}/chevron-down.svg` }, diff --git a/projects/assets-library/src/icons/icon-type.ts b/projects/assets-library/src/icons/icon-type.ts index bf52f732f..946529f72 100644 --- a/projects/assets-library/src/icons/icon-type.ts +++ b/projects/assets-library/src/icons/icon-type.ts @@ -15,6 +15,7 @@ export const enum IconType { ArrowUpLeft = 'svg:arrow-up-left', ArrowUpRight = 'svg:arrow-up-right', Calls = 'svg:calls', + Calendar = 'svg:calendar-dates', Cancel = 'cancel', CheckCircle = 'svg:check-circle', CheckCircleFill = 'svg:check-circle-fill', diff --git a/projects/common/src/constants/application-constants.ts b/projects/common/src/constants/application-constants.ts index 67afae4cc..aa81c82c3 100644 --- a/projects/common/src/constants/application-constants.ts +++ b/projects/common/src/constants/application-constants.ts @@ -1,3 +1,7 @@ import { InjectionToken } from '@angular/core'; export const GLOBAL_HEADER_HEIGHT = new InjectionToken('Global Header Height'); + +export const enum ApplicationFeature { + PageTimeRange = 'ui.page-time-range' +} diff --git a/projects/common/src/navigation/ht-route-data.ts b/projects/common/src/navigation/ht-route-data.ts index 4d0d53d89..72af980d3 100644 --- a/projects/common/src/navigation/ht-route-data.ts +++ b/projects/common/src/navigation/ht-route-data.ts @@ -1,8 +1,10 @@ import { Observable } from 'rxjs'; +import { TimeRange } from '../time/time-range'; import { Breadcrumb } from './breadcrumb'; export interface HtRouteData { breadcrumb?: Breadcrumb | Observable; features?: string[]; title?: string; + defaultTimeRange?: TimeRange; } diff --git a/projects/common/src/public-api.ts b/projects/common/src/public-api.ts index e935fbe1d..0ea74530b 100644 --- a/projects/common/src/public-api.ts +++ b/projects/common/src/public-api.ts @@ -120,6 +120,7 @@ export * from './time/time-range.service'; export * from './time/time-range.type'; export * from './time/time-unit.type'; export * from './time/time'; +export * from './time/page-time-range-preference.service'; // Validators export * from './utilities/validators'; diff --git a/projects/common/src/time/page-time-range-preference-service.test.ts b/projects/common/src/time/page-time-range-preference-service.test.ts new file mode 100644 index 000000000..d9f4f4b17 --- /dev/null +++ b/projects/common/src/time/page-time-range-preference-service.test.ts @@ -0,0 +1,85 @@ +import { + FeatureState, + FeatureStateResolver, + FixedTimeRange, + NavigationService, + RelativeTimeRange, + TimeDuration, + TimeRange, + TimeRangeService, + TimeUnit +} from '@hypertrace/common'; +import { runFakeRxjs } from '@hypertrace/test-utils'; +import { createServiceFactory, mockProvider } from '@ngneat/spectator/jest'; +import { of } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { PageTimeRangePreferenceService } from './page-time-range-preference.service'; + +describe('Page time range preference service', () => { + const defaultPageTimeRange = new RelativeTimeRange(new TimeDuration(2, TimeUnit.Hour)); + const serviceFactory = createServiceFactory({ + service: PageTimeRangePreferenceService, + providers: [ + mockProvider(NavigationService, { + getCurrentActivatedRoute: jest + .fn() + .mockReturnValue({ snapshot: { data: { defaultTimeRange: defaultPageTimeRange } } }) + }), + mockProvider(FeatureStateResolver, { + getFeatureState: jest.fn().mockReturnValue(of(FeatureState.Enabled)) + }) + ] + }); + + test('Setting fixed time range emits corresponding time range from preferences', () => { + runFakeRxjs(({ expectObservable, cold }) => { + const timeRange: TimeRange = new FixedTimeRange(new Date(1573255100253), new Date(1573255111159)); + const spectator = serviceFactory({ + providers: [ + mockProvider(TimeRangeService, { + timeRangeFromUrlString: jest.fn().mockReturnValue(timeRange) + }) + ] + }); + + cold('-a|', { + a: () => spectator.service.setTimeRangePreferenceForPage('foo', timeRange) + }).subscribe(update => update()); + + const recordedTimeRanges$ = spectator.service + .getTimeRangePreferenceForPage('foo') + .pipe(map(timeRangeResolver => timeRangeResolver())); + + expectObservable(recordedTimeRanges$).toBe('da', { + d: defaultPageTimeRange, + a: timeRange + }); + }); + }); + + test('Setting relative time range emits corresponding time range from preferences', () => { + runFakeRxjs(({ expectObservable, cold }) => { + const timeRange = new RelativeTimeRange(new TimeDuration(1, TimeUnit.Hour)); + const spectator = serviceFactory({ + providers: [ + mockProvider(TimeRangeService, { + timeRangeFromUrlString: jest.fn().mockReturnValue(timeRange) + }) + ] + }); + + cold('-b|', { + b: () => spectator.service.setTimeRangePreferenceForPage('bar', timeRange) + }).subscribe(update => update()); + + const recordedTimeRanges$ = spectator.service + .getTimeRangePreferenceForPage('bar') + .pipe(map(timeRangeResolver => timeRangeResolver())); + + expectObservable(recordedTimeRanges$).toBe('db', { + d: defaultPageTimeRange, + b: timeRange + }); + }); + }); +}); diff --git a/projects/common/src/time/page-time-range-preference.service.ts b/projects/common/src/time/page-time-range-preference.service.ts new file mode 100644 index 000000000..9978b4f42 --- /dev/null +++ b/projects/common/src/time/page-time-range-preference.service.ts @@ -0,0 +1,96 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { isNil } from 'lodash-es'; +import { combineLatest, Observable } from 'rxjs'; +import { map, shareReplay, take } from 'rxjs/operators'; +import { ApplicationFeature } from '../constants/application-constants'; +import { FeatureStateResolver } from '../feature/state/feature-state.resolver'; +import { FeatureState } from '../feature/state/feature.state'; +import { NavigationService } from '../navigation/navigation.service'; +import { PreferenceService, StorageType } from '../preference/preference.service'; +import { RelativeTimeRange } from './relative-time-range'; +import { TimeDuration } from './time-duration'; +import { TimeRange } from './time-range'; +import { TimeRangeService } from './time-range.service'; +import { TimeUnit } from './time-unit.type'; + +@Injectable({ providedIn: 'root' }) +export class PageTimeRangePreferenceService { + private static readonly STORAGE_TYPE: StorageType = StorageType.Local; + private static readonly TIME_RANGE_PREFERENCE_KEY: string = 'page-time-range'; + + private readonly pageTimeRangeStringDictionary$: Observable; + + public constructor( + private readonly preferenceService: PreferenceService, + private readonly timeRangeService: TimeRangeService, + private readonly navigationService: NavigationService, + private readonly featureStateResolver: FeatureStateResolver + ) { + this.pageTimeRangeStringDictionary$ = this.buildPageTimeRangeObservable(); + } + + public getTimeRangePreferenceForPage(rootLevelPath: string): Observable { + return combineLatest([ + this.pageTimeRangeStringDictionary$, + this.featureStateResolver.getFeatureState(ApplicationFeature.PageTimeRange) + ]).pipe( + map(([pageTimeRangeStringDictionary, featureState]) => { + if (featureState === FeatureState.Enabled) { + if (isNil(pageTimeRangeStringDictionary[rootLevelPath])) { + return () => this.getDefaultTimeRangeForCurrentRoute(); + } + + return () => this.timeRangeService.timeRangeFromUrlString(pageTimeRangeStringDictionary[rootLevelPath]); + } + + // When FF is disabled + return () => this.getGlobalDefaultTimeRange(); + }) + ); + } + + public setTimeRangePreferenceForPage(rootLevelPath: string, value: TimeRange): void { + this.pageTimeRangeStringDictionary$.pipe(take(1)).subscribe(currentPageTimeRangeDictionary => { + this.setPreferenceServicePageTimeRange(currentPageTimeRangeDictionary, rootLevelPath, value); + }); + } + + private setPreferenceServicePageTimeRange( + currentTimeRangeDictionary: PageTimeRangeStringDictionary, + rootLevelPath: string, + timeRange: TimeRange + ): void { + this.preferenceService.set( + PageTimeRangePreferenceService.TIME_RANGE_PREFERENCE_KEY, + { ...currentTimeRangeDictionary, [rootLevelPath]: timeRange.toUrlString() }, + PageTimeRangePreferenceService.STORAGE_TYPE + ); + } + + private buildPageTimeRangeObservable(): Observable { + return this.preferenceService + .get( + PageTimeRangePreferenceService.TIME_RANGE_PREFERENCE_KEY, + {}, + PageTimeRangePreferenceService.STORAGE_TYPE + ) + .pipe(shareReplay(1)); + } + + public getDefaultTimeRangeForCurrentRoute(): TimeRange { + const currentRoute: ActivatedRoute = this.navigationService.getCurrentActivatedRoute(); + // Right side for when FF is enabled but 'defaultTimeRange' is not set on AR data + + return currentRoute.snapshot.data?.defaultTimeRange ?? this.getGlobalDefaultTimeRange(); + } + + public getGlobalDefaultTimeRange(): TimeRange { + return new RelativeTimeRange(new TimeDuration(1, TimeUnit.Hour)); + } +} + +interface PageTimeRangeStringDictionary { + [path: string]: string; +} +export type TimeRangeResolver = () => TimeRange; diff --git a/projects/common/src/time/time-range.service.ts b/projects/common/src/time/time-range.service.ts index 583773d78..95195734b 100644 --- a/projects/common/src/time/time-range.service.ts +++ b/projects/common/src/time/time-range.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; -import { isEmpty } from 'lodash-es'; +import { isEmpty, isNil } from 'lodash-es'; import { EMPTY, ReplaySubject } from 'rxjs'; -import { catchError, defaultIfEmpty, filter, map, switchMap, take } from 'rxjs/operators'; +import { catchError, filter, map, switchMap, take } from 'rxjs/operators'; import { NavigationService, QueryParamObject } from '../navigation/navigation.service'; import { ReplayObservable } from '../utilities/rxjs/rxjs-utils'; import { FixedTimeRange } from './fixed-time-range'; @@ -17,7 +17,6 @@ import { TimeUnit } from './time-unit.type'; export class TimeRangeService { private static readonly TIME_RANGE_QUERY_PARAM: string = 'time'; - private readonly defaultTimeRange: TimeRange = new RelativeTimeRange(new TimeDuration(1, TimeUnit.Hour)); private readonly timeRangeSubject$: ReplaySubject = new ReplaySubject(1); private currentTimeRange?: TimeRange; @@ -72,15 +71,14 @@ export class TimeRangeService { map(paramMap => paramMap.get(TimeRangeService.TIME_RANGE_QUERY_PARAM)), // Extract the time range value from it filter((timeRangeString): timeRangeString is string => !isEmpty(timeRangeString)), // Only valid time ranges map(timeRangeString => this.timeRangeFromUrlString(timeRangeString)), - catchError(() => EMPTY), - defaultIfEmpty(this.defaultTimeRange) + catchError(() => EMPTY) ) .subscribe(timeRange => { this.setTimeRange(timeRange); }); } - private timeRangeFromUrlString(timeRangeFromUrl: string): TimeRange { + public timeRangeFromUrlString(timeRangeFromUrl: string): TimeRange { const duration = this.timeDurationService.durationFromString(timeRangeFromUrl); if (duration) { return new RelativeTimeRange(duration); @@ -103,6 +101,12 @@ export class TimeRangeService { return this; } + public setDefaultTimeRange(timeRange: TimeRange): void { + if (!this.currentTimeRange) { + this.setTimeRange(timeRange); + } + } + public static toRelativeTimeRange(value: number, unit: TimeUnit): RelativeTimeRange { return new RelativeTimeRange(new TimeDuration(value, unit)); } @@ -118,4 +122,8 @@ export class TimeRangeService { [TimeRangeService.TIME_RANGE_QUERY_PARAM]: newTimeRange.toUrlString() }; } + + public isInitialized(): boolean { + return !isNil(this.currentTimeRange); + } } diff --git a/projects/components/src/header/application/application-header.component.ts b/projects/components/src/header/application/application-header.component.ts index 15d8ee9e5..e6fbef9b5 100644 --- a/projects/components/src/header/application/application-header.component.ts +++ b/projects/components/src/header/application/application-header.component.ts @@ -1,5 +1,13 @@ import { ChangeDetectionStrategy, Component, Inject, Input } from '@angular/core'; -import { GLOBAL_HEADER_HEIGHT, NavigationService } from '@hypertrace/common'; +import { + ApplicationFeature, + FeatureState, + FeatureStateResolver, + GLOBAL_HEADER_HEIGHT, + NavigationService +} from '@hypertrace/common'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; @Component({ selector: 'ht-application-header', @@ -15,9 +23,11 @@ import { GLOBAL_HEADER_HEIGHT, NavigationService } from '@hypertrace/common';
-
- -
+ +
+ +
+
@@ -30,10 +40,17 @@ export class ApplicationHeaderComponent { @Input() public showTimeRange: boolean = true; + public pageLevelTimeRangeDisabled$: Observable; + public constructor( @Inject(GLOBAL_HEADER_HEIGHT) public readonly height: string, - private readonly navigationService: NavigationService - ) {} + private readonly navigationService: NavigationService, + private readonly featureStateResolver: FeatureStateResolver + ) { + this.pageLevelTimeRangeDisabled$ = this.featureStateResolver + .getFeatureState(ApplicationFeature.PageTimeRange) + .pipe(map(featureState => featureState === FeatureState.Disabled)); + } public onLogoClick(): void { this.navigationService.navigateWithinApp(['']); // Empty route so we go to default screen diff --git a/projects/components/src/header/page/page-header.component.scss b/projects/components/src/header/page/page-header.component.scss index 03e4ffb89..07ecdfb42 100644 --- a/projects/components/src/header/page/page-header.component.scss +++ b/projects/components/src/header/page/page-header.component.scss @@ -5,7 +5,6 @@ margin: 24px 24px 0; display: flex; flex-direction: column; - .column-alignment { display: flex; flex-direction: column; @@ -16,12 +15,14 @@ justify-content: space-between; } - &.bottom-border { - padding-bottom: 16px; - border-bottom: 1px solid $color-border; + .primary-row { + display: flex; + justify-content: flex-end; + width: 100%; } .breadcrumb-container { + margin-right: auto; align-items: center; .breadcrumb-separator { @@ -43,6 +44,20 @@ } } + .time-range { + margin-left: 10px; + } + + .row-alignment { + display: flex; + justify-content: space-between; + } + + &.bottom-border { + padding-bottom: 16px; + border-bottom: 1px solid $color-border; + } + .tabs { padding-top: 16px; } diff --git a/projects/components/src/header/page/page-header.component.test.ts b/projects/components/src/header/page/page-header.component.test.ts index 48787020d..2077e08c6 100644 --- a/projects/components/src/header/page/page-header.component.test.ts +++ b/projects/components/src/header/page/page-header.component.test.ts @@ -1,7 +1,16 @@ -import { NavigationService, PreferenceService, SubscriptionLifecycle } from '@hypertrace/common'; +import { + FeatureState, + FeatureStateResolver, + NavigationService, + PreferenceService, + SubscriptionLifecycle +} from '@hypertrace/common'; import { createHostFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; +import { MockComponent } from 'ng-mocks'; import { of } from 'rxjs'; import { BreadcrumbsService } from '../../breadcrumbs/breadcrumbs.service'; +import { FeatureConfigCheckModule } from '../../feature-check/feature-config-check.module'; +import { PageTimeRangeComponent } from '../../page-time-range/page-time-range.component'; import { PageHeaderComponent } from './page-header.component'; describe('Page Header Component', () => { @@ -9,11 +18,16 @@ describe('Page Header Component', () => { const createHost = createHostFactory({ component: PageHeaderComponent, + imports: [FeatureConfigCheckModule], + declarations: [MockComponent(PageTimeRangeComponent)], shallow: true, providers: [ mockProvider(NavigationService), mockProvider(PreferenceService), mockProvider(SubscriptionLifecycle), + mockProvider(FeatureStateResolver, { + getCombinedFeatureState: () => of(FeatureState.Disabled) + }), mockProvider(BreadcrumbsService, { breadcrumbs$: of([ { @@ -33,4 +47,28 @@ describe('Page Header Component', () => { spectator = createHost(''); expect(spectator.query('.beta')).not.toExist(); }); + + test('should display page time range component when feature flag is enabled', () => { + spectator = createHost('', { + providers: [ + mockProvider(FeatureStateResolver, { + getCombinedFeatureState: () => of(FeatureState.Enabled) + }) + ] + }); + + expect(spectator.query(PageTimeRangeComponent)).toExist(); + }); + + test('should not display any time range if FF is disabled', () => { + spectator = createHost('', { + providers: [ + mockProvider(FeatureStateResolver, { + getCombinedFeatureState: () => of(FeatureState.Disabled) + }) + ] + }); + + expect(spectator.query(PageTimeRangeComponent)).not.toExist(); + }); }); diff --git a/projects/components/src/header/page/page-header.component.ts b/projects/components/src/header/page/page-header.component.ts index 2a3aa1405..5d6d7e302 100644 --- a/projects/components/src/header/page/page-header.component.ts +++ b/projects/components/src/header/page/page-header.component.ts @@ -1,5 +1,6 @@ import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core'; import { + ApplicationFeature, Breadcrumb, isNonEmptyString, NavigationService, @@ -23,7 +24,49 @@ import { NavigableTab } from '../../tabs/navigable/navigable-tab'; class="page-header" [class.bottom-border]="!this.tabs?.length" > -
+ +
+
+ + + + + + +
+ + + +
+ + + +
+ + + +
+
+ + + + {{ tab.label }} + + + + + + + + - - -
- - - - {{ tab.label }} - - +
` }) diff --git a/projects/components/src/header/page/page-header.module.ts b/projects/components/src/header/page/page-header.module.ts index d22db7bb3..cadb9326b 100644 --- a/projects/components/src/header/page/page-header.module.ts +++ b/projects/components/src/header/page/page-header.module.ts @@ -2,8 +2,10 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { BetaTagModule } from '../../beta-tag/beta-tag.module'; import { BreadcrumbsModule } from '../../breadcrumbs/breadcrumbs.module'; +import { FeatureConfigCheckModule } from '../../feature-check/feature-config-check.module'; import { IconModule } from '../../icon/icon.module'; import { LabelModule } from '../../label/label.module'; +import { PageTimeRangeModule } from '../../page-time-range/page-time-range.module'; import { NavigableTabModule } from '../../tabs/navigable/navigable-tab.module'; import { TimeRangeModule } from '../../time-range/time-range.module'; import { PageHeaderComponent } from './page-header.component'; @@ -18,7 +20,9 @@ import { PageHeaderComponent } from './page-header.component'; BreadcrumbsModule, LabelModule, NavigableTabModule, - BetaTagModule + BetaTagModule, + FeatureConfigCheckModule, + PageTimeRangeModule ] }) export class PageHeaderModule {} diff --git a/projects/components/src/navigation/navigation-list-component.service.test.ts b/projects/components/src/navigation/navigation-list-component.service.test.ts index 11929127c..bcc77d047 100644 --- a/projects/components/src/navigation/navigation-list-component.service.test.ts +++ b/projects/components/src/navigation/navigation-list-component.service.test.ts @@ -1,4 +1,11 @@ -import { FeatureState, FeatureStateResolver } from '@hypertrace/common'; +import { + FeatureState, + FeatureStateResolver, + PageTimeRangePreferenceService, + RelativeTimeRange, + TimeDuration, + TimeUnit +} from '@hypertrace/common'; import { NavItemConfig, NavItemType } from '@hypertrace/components'; import { runFakeRxjs } from '@hypertrace/test-utils'; import { createServiceFactory, mockProvider } from '@ngneat/spectator/jest'; @@ -31,11 +38,17 @@ describe('Navigation List Component Service', () => { } ]; + const mockTimeRangeResolver = () => new RelativeTimeRange(new TimeDuration(1, TimeUnit.Hour)); + const createService = createServiceFactory({ service: NavigationListComponentService, providers: [ mockProvider(FeatureStateResolver, { - getCombinedFeatureState: jest.fn().mockReturnValue(of(FeatureState.Enabled)) + getCombinedFeatureState: jest.fn().mockReturnValue(of(FeatureState.Enabled)), + getFeatureState: jest.fn().mockReturnValue(of(FeatureState.Enabled)) + }), + mockProvider(PageTimeRangePreferenceService, { + getTimeRangePreferenceForPage: jest.fn().mockReturnValue(of(mockTimeRangeResolver)) }) ] }); @@ -53,4 +66,64 @@ describe('Navigation List Component Service', () => { }); }); }); + + test('should return nav items with time ranges on them', () => { + const mockNavItems: NavItemConfig[] = [ + { + type: NavItemType.Header, + label: 'header 1' + }, + { + type: NavItemType.Link, + icon: 'icon', + label: 'label-1', + features: ['feature'], + matchPaths: [''] + }, + { + type: NavItemType.Link, + icon: 'icon', + label: 'label-2', + matchPaths: [''] + }, + { + type: NavItemType.Header, + label: 'header 2' + } + ]; + const spectator = createService(); + const resolvedItems$ = spectator.service.resolveNavItemConfigTimeRanges(mockNavItems); + + runFakeRxjs(({ expectObservable }) => { + expectObservable(resolvedItems$).toBe('(x|)', { + x: [ + { + type: NavItemType.Header, + label: 'header 1' + }, + { + type: NavItemType.Link, + icon: 'icon', + label: 'label-1', + features: ['feature'], + matchPaths: [''], + timeRangeResolver: mockTimeRangeResolver, + pageLevelTimeRangeIsEnabled: true + }, + { + type: NavItemType.Link, + icon: 'icon', + label: 'label-2', + matchPaths: [''], + timeRangeResolver: mockTimeRangeResolver, + pageLevelTimeRangeIsEnabled: true + }, + { + type: NavItemType.Header, + label: 'header 2' + } + ] + }); + }); + }); }); diff --git a/projects/components/src/navigation/navigation-list-component.service.ts b/projects/components/src/navigation/navigation-list-component.service.ts index 92c5e402d..32e27c772 100644 --- a/projects/components/src/navigation/navigation-list-component.service.ts +++ b/projects/components/src/navigation/navigation-list-component.service.ts @@ -1,13 +1,21 @@ import { Injectable } from '@angular/core'; -import { FeatureState, FeatureStateResolver } from '@hypertrace/common'; +import { + ApplicationFeature, + FeatureState, + FeatureStateResolver, + PageTimeRangePreferenceService +} from '@hypertrace/common'; import { isEmpty } from 'lodash-es'; import { combineLatest, Observable, of } from 'rxjs'; -import { map } from 'rxjs/operators'; +import { map, switchMap } from 'rxjs/operators'; import { NavItemConfig, NavItemHeaderConfig, NavItemLinkConfig, NavItemType } from './navigation.config'; @Injectable({ providedIn: 'root' }) export class NavigationListComponentService { - public constructor(private readonly featureStateResolver: FeatureStateResolver) {} + public constructor( + private readonly featureStateResolver: FeatureStateResolver, + private readonly pageTimeRangePreferenceService: PageTimeRangePreferenceService + ) {} public resolveFeaturesAndUpdateVisibilityForNavItems(navItems: NavItemConfig[]): NavItemConfig[] { const updatedItems = this.updateLinkNavItemsVisibility(navItems); @@ -26,6 +34,31 @@ export class NavigationListComponentService { return updatedItems; } + public resolveNavItemConfigTimeRanges(navItems: NavItemConfig[]): Observable { + return this.featureStateResolver + .getFeatureState(ApplicationFeature.PageTimeRange) + .pipe(switchMap(featureState => combineLatest(this.getTimeRangesForNavItems(navItems, featureState)))); + } + + private getTimeRangesForNavItems( + navItems: NavItemConfig[], + pageLevelTimeRangeFeatureState: FeatureState + ): Observable[] { + return navItems.map(navItem => { + if (navItem.type === NavItemType.Link) { + return this.pageTimeRangePreferenceService.getTimeRangePreferenceForPage(navItem.matchPaths[0]).pipe( + map(timeRangeResolver => ({ + ...navItem, + timeRangeResolver: timeRangeResolver, + pageLevelTimeRangeIsEnabled: pageLevelTimeRangeFeatureState === FeatureState.Enabled + })) + ); + } + + return of(navItem); + }); + } + private updateHeaderNavItemsVisibility(navItems: NavItemLinkConfig[]): Observable { return isEmpty(navItems) ? of(false) diff --git a/projects/components/src/navigation/navigation-list.component.test.ts b/projects/components/src/navigation/navigation-list.component.test.ts index 9d7d2880c..6da7161c4 100644 --- a/projects/components/src/navigation/navigation-list.component.test.ts +++ b/projects/components/src/navigation/navigation-list.component.test.ts @@ -1,6 +1,6 @@ import { ActivatedRoute } from '@angular/router'; import { IconType } from '@hypertrace/assets-library'; -import { MemoizeModule, NavigationService } from '@hypertrace/common'; +import { FeatureState, FeatureStateResolver, MemoizeModule, NavigationService } from '@hypertrace/common'; import { createHostFactory, mockProvider, SpectatorHost } from '@ngneat/spectator/jest'; import { MockComponent } from 'ng-mocks'; import { EMPTY, of } from 'rxjs'; @@ -11,6 +11,7 @@ import { NavItemComponent } from './nav-item/nav-item.component'; import { NavigationListComponentService } from './navigation-list-component.service'; import { NavigationListComponent } from './navigation-list.component'; import { FooterItemConfig, NavItemConfig, NavItemType } from './navigation.config'; + describe('Navigation List Component', () => { let spectator: SpectatorHost; const activatedRoute = { @@ -34,6 +35,9 @@ describe('Navigation List Component', () => { navigation$: EMPTY, navigateWithinApp: jest.fn(), getCurrentActivatedRoute: jest.fn().mockReturnValue(of(activatedRoute)) + }), + mockProvider(FeatureStateResolver, { + getCombinedFeatureState: jest.fn().mockReturnValue(of(FeatureState.Disabled)) }) ] }); @@ -77,7 +81,22 @@ describe('Navigation List Component', () => { }); test('should update layout when collapsed input is updated', () => { - spectator = createHost(``); + const navItems: NavItemConfig[] = [ + { + type: NavItemType.Header, + label: 'header 1', + isVisible$: of(true) + }, + { + type: NavItemType.Link, + icon: 'icon', + label: 'label', + matchPaths: [''] + } + ]; + spectator = createHost(``, { + hostProps: { navItems: navItems } + }); expect(spectator.query('.navigation-list')).toHaveClass('expanded'); expect(spectator.query(IconComponent)?.icon).toEqual(IconType.TriangleLeft); spectator.setInput({ diff --git a/projects/components/src/navigation/navigation-list.component.ts b/projects/components/src/navigation/navigation-list.component.ts index 925e6819c..6b330f578 100644 --- a/projects/components/src/navigation/navigation-list.component.ts +++ b/projects/components/src/navigation/navigation-list.component.ts @@ -1,7 +1,7 @@ import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { IconType } from '@hypertrace/assets-library'; -import { NavigationService } from '@hypertrace/common'; +import { NavigationService, TypedSimpleChanges } from '@hypertrace/common'; import { Observable } from 'rxjs'; import { map, startWith } from 'rxjs/operators'; import { IconSize } from '../icon/icon-size'; @@ -29,7 +29,12 @@ import { FooterItemConfig, NavItemConfig, NavItemLinkConfig, NavItemType } from - + @@ -68,6 +73,12 @@ export class NavigationListComponent implements OnChanges { @Output() public readonly collapsedChange: EventEmitter = new EventEmitter(); + @Output() + public readonly navItemClick: EventEmitter = new EventEmitter(); + + @Output() + public readonly activeItemChange: EventEmitter = new EventEmitter(); + public activeItem$?: Observable; public constructor( @@ -76,12 +87,21 @@ export class NavigationListComponent implements OnChanges { private readonly navListComponentService: NavigationListComponentService ) {} - public ngOnChanges(): void { - this.navItems = this.navListComponentService.resolveFeaturesAndUpdateVisibilityForNavItems(this.navItems); - this.activeItem$ = this.navigationService.navigation$.pipe( - startWith(this.navigationService.getCurrentActivatedRoute()), - map(() => this.findActiveItem(this.navItems)) - ); + public ngOnChanges(changes: TypedSimpleChanges): void { + if (changes.navItems) { + this.navItems = this.navListComponentService.resolveFeaturesAndUpdateVisibilityForNavItems(this.navItems); + + // Must remain subscribed to in template to maintain time range functionality for activeItemChange. + this.activeItem$ = this.navigationService.navigation$.pipe( + startWith(this.navigationService.getCurrentActivatedRoute()), + map(() => { + const activeItem = this.findActiveItem(this.navItems); + this.activeItemChange.emit(activeItem); + + return activeItem; + }) + ); + } } public toggleView(): void { diff --git a/projects/components/src/navigation/navigation.config.ts b/projects/components/src/navigation/navigation.config.ts index 18cbd7e0c..6426fa69f 100644 --- a/projects/components/src/navigation/navigation.config.ts +++ b/projects/components/src/navigation/navigation.config.ts @@ -1,4 +1,4 @@ -import { Color, FeatureState } from '@hypertrace/common'; +import { Color, FeatureState, TimeRangeResolver } from '@hypertrace/common'; import { Observable } from 'rxjs'; import { IconSize } from '../icon/icon-size'; @@ -16,6 +16,8 @@ export interface NavItemLinkConfig { trailingIcon?: string; trailingIconTooltip?: string; trailingIconColor?: Color; + timeRangeResolver?: TimeRangeResolver; + pageLevelTimeRangeIsEnabled?: boolean; featureState$?: Observable; } diff --git a/projects/components/src/page-time-range/page-time-range.component.ts b/projects/components/src/page-time-range/page-time-range.component.ts new file mode 100644 index 000000000..3a0c0c5b0 --- /dev/null +++ b/projects/components/src/page-time-range/page-time-range.component.ts @@ -0,0 +1,38 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ActivatedRoute, UrlSegment } from '@angular/router'; +import { LoggerService, NavigationService, PageTimeRangePreferenceService, TimeRange } from '@hypertrace/common'; +import { isNil } from 'lodash-es'; + +@Component({ + selector: 'ht-page-time-range', + template: ` `, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class PageTimeRangeComponent { + public constructor( + private readonly pageTimeRangePreferenceService: PageTimeRangePreferenceService, + private readonly navigationService: NavigationService, + private readonly loggerService: LoggerService + ) {} + + public onTimeRangeSelected(selectedTimeRange: TimeRange): void { + const activatedRoute = this.navigationService.getCurrentActivatedRoute(); + const urlSegments: UrlSegment[] = activatedRoute.pathFromRoot.flatMap(activeRoute => activeRoute.snapshot.url); + + if (this.shouldSavePageTimeRange(activatedRoute)) { + this.savePageTimeRange(selectedTimeRange, urlSegments[0]); + } + } + + public shouldSavePageTimeRange(currentRoute: ActivatedRoute): boolean { + return !isNil(currentRoute.snapshot.data?.defaultTimeRange); + } + + public savePageTimeRange(selectedTimeRange: TimeRange, segment: UrlSegment): void { + if (!isNil(segment.path)) { + this.pageTimeRangePreferenceService.setTimeRangePreferenceForPage(segment.path, selectedTimeRange); + } else { + this.loggerService.warn(`Unable to set time range. Invalid page.`); + } + } +} diff --git a/projects/components/src/page-time-range/page-time-range.module.ts b/projects/components/src/page-time-range/page-time-range.module.ts new file mode 100644 index 000000000..6cd0a559a --- /dev/null +++ b/projects/components/src/page-time-range/page-time-range.module.ts @@ -0,0 +1,11 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { TimeRangeModule } from '../time-range/time-range.module'; +import { PageTimeRangeComponent } from './page-time-range.component'; + +@NgModule({ + declarations: [PageTimeRangeComponent], + exports: [PageTimeRangeComponent], + imports: [CommonModule, TimeRangeModule] +}) +export class PageTimeRangeModule {} diff --git a/projects/components/src/page-time-range/page-time-range.test.ts b/projects/components/src/page-time-range/page-time-range.test.ts new file mode 100644 index 000000000..ff1160af0 --- /dev/null +++ b/projects/components/src/page-time-range/page-time-range.test.ts @@ -0,0 +1,108 @@ +import { UrlSegment } from '@angular/router'; +import { + FeatureState, + FeatureStateResolver, + NavigationService, + PageTimeRangePreferenceService, + RelativeTimeRange, + TimeDuration, + TimeUnit +} from '@hypertrace/common'; +import { FeatureConfigCheckModule, PageTimeRangeComponent, TimeRangeComponent } from '@hypertrace/components'; +import { createHostFactory, mockProvider, SpectatorHost } from '@ngneat/spectator/jest'; +import { MockComponent } from 'ng-mocks'; +import { of } from 'rxjs'; + +describe('Page time range component', () => { + let spectator: SpectatorHost; + const route = { + snapshot: { + data: { + defaultTimeRange: undefined + } + }, + pathFromRoot: { flatMap: jest.fn().mockReturnValue(['foo']) } + }; + + const createHost = createHostFactory({ + shallow: true, + component: PageTimeRangeComponent, + declarations: [MockComponent(TimeRangeComponent)], + imports: [FeatureConfigCheckModule], + providers: [ + mockProvider(NavigationService, { + getCurrentActivatedRoute: jest.fn().mockReturnValue(route) + }), + mockProvider(PageTimeRangePreferenceService), + mockProvider(FeatureStateResolver, { + getCombinedFeatureState: () => of(FeatureState.Enabled) + }) + ] + }); + + test('should not attempt to save time range when route does not have default range', () => { + spectator = createHost(``); + const timeRange = new RelativeTimeRange(new TimeDuration(2, TimeUnit.Hour)); + + const timeRangeComponent = spectator.query(TimeRangeComponent); + expect(timeRangeComponent).toExist(); + + spyOn(spectator.component, 'savePageTimeRange'); + spectator.component.onTimeRangeSelected(timeRange); + expect(spectator.component.savePageTimeRange).not.toHaveBeenCalled(); + }); + + test('should not attempt to save time range when route is not a first level route', () => { + spectator = createHost(``, { + providers: [ + mockProvider(NavigationService, { + getCurrentActivatedRoute: jest.fn().mockReturnValue({ + snapshot: { + data: { + defaultTimeRange: undefined + } + }, + pathFromRoot: { flatMap: jest.fn().mockReturnValue(['parent-path', 'child-path']) } + }) + }) + ] + }); + const timeRange = new RelativeTimeRange(new TimeDuration(2, TimeUnit.Hour)); + + const timeRangeComponent = spectator.query(TimeRangeComponent); + expect(timeRangeComponent).toExist(); + + spyOn(spectator.component, 'savePageTimeRange'); + spectator.component.onTimeRangeSelected(timeRange); + expect(spectator.component.savePageTimeRange).not.toHaveBeenCalled(); + }); + + test('should save time range when route is first level, and the defaultTimeRange property is present', () => { + spectator = createHost(``, { + providers: [ + mockProvider(NavigationService, { + getCurrentActivatedRoute: jest.fn().mockReturnValue({ + snapshot: { + data: { + defaultTimeRange: new RelativeTimeRange(new TimeDuration(30, TimeUnit.Minute)) + } + }, + pathFromRoot: { flatMap: () => [{ path: 'parent-path' }] as UrlSegment[] } + }) + }) + ] + }); + const selectedTimeRange = new RelativeTimeRange(new TimeDuration(2, TimeUnit.Hour)); + + const timeRangeComponent = spectator.query(TimeRangeComponent); + expect(timeRangeComponent).toExist(); + + spyOn(spectator.component, 'savePageTimeRange'); + spectator.component.onTimeRangeSelected(selectedTimeRange); + expect(spectator.component.savePageTimeRange).toHaveBeenCalled(); + + expect(spectator.component.savePageTimeRange).toHaveBeenCalledWith(selectedTimeRange, { + path: 'parent-path' + }); + }); +}); diff --git a/projects/components/src/public-api.ts b/projects/components/src/public-api.ts index 423fd7d26..8bfd68434 100644 --- a/projects/components/src/public-api.ts +++ b/projects/components/src/public-api.ts @@ -365,3 +365,7 @@ export { TooltipDirective } from './tooltip/tooltip.directive'; // Filter Url Service export * from './filtering/filter/filter-url.service'; + +// User Page Time Range +export * from './page-time-range/page-time-range.component'; +export * from './page-time-range/page-time-range.module'; diff --git a/projects/components/src/time-range/time-range.component.scss b/projects/components/src/time-range/time-range.component.scss index 0e5f8c486..b304b6a06 100644 --- a/projects/components/src/time-range/time-range.component.scss +++ b/projects/components/src/time-range/time-range.component.scss @@ -1,14 +1,28 @@ @import 'mixins'; +@mixin time-range-button-background { + background-color: white; + + &:hover { + background-color: $gray-1; + } +} + +@mixin time-range-buttons() { + @include time-range-button-background; + @include body-1-medium($gray-6); + border: 1px solid $gray-2; + height: 34px; + display: flex; + align-items: center; +} .time-range { display: flex; align-items: center; } .time-range-selector { - @include top-bar-dark-button-background; - @include top-bar-dark-button-foreground; - min-width: 200px; + @include time-range-buttons; border-radius: 6px; } @@ -42,14 +56,12 @@ .refresh { margin-left: 8px; - &:not(.emphasized) { - ::ng-deep { - button.button.solid { - @include top-bar-dark-button-background; - .label { - @include top-bar-dark-button-foreground; - } - } + background-color: white; + border-radius: 6px; + + ::ng-deep { + button.button.bordered { + height: 34px; } } } diff --git a/projects/components/src/time-range/time-range.component.test.ts b/projects/components/src/time-range/time-range.component.test.ts index c5096672d..7e54ccecf 100644 --- a/projects/components/src/time-range/time-range.component.test.ts +++ b/projects/components/src/time-range/time-range.component.test.ts @@ -2,7 +2,14 @@ import { HttpClientTestingModule } from '@angular/common/http/testing'; import { discardPeriodicTasks, fakeAsync } from '@angular/core/testing'; import { ActivatedRoute, convertToParamMap } from '@angular/router'; import { IconLibraryTestingModule } from '@hypertrace/assets-library'; -import { NavigationService, TimeDuration, TimeRangeService, TimeUnit } from '@hypertrace/common'; +import { + FixedTimeRange, + NavigationService, + RelativeTimeRange, + TimeDuration, + TimeRangeService, + TimeUnit +} from '@hypertrace/common'; import { createComponentFactory, mockProvider } from '@ngneat/spectator/jest'; import { of } from 'rxjs'; import { ButtonRole } from '../button/button'; @@ -22,6 +29,9 @@ describe('Time range component', () => { navigation$: of({ queryParamMap: of(convertToParamMap({})) } as ActivatedRoute) + }), + mockProvider(TimeRangeService, { + getTimeRangeAndChanges: jest.fn().mockReturnValue(of(new RelativeTimeRange(new TimeDuration(1, TimeUnit.Hour)))) }) ] }); @@ -38,7 +48,15 @@ describe('Time range component', () => { }); test('should show predefined time range when Last 15 minutes is selected', () => { - const spectator = createComponent(); + const spectator = createComponent({ + providers: [ + mockProvider(TimeRangeService, { + getTimeRangeAndChanges: jest + .fn() + .mockReturnValue(of(new RelativeTimeRange(new TimeDuration(15, TimeUnit.Minute)))) + }) + ] + }); spectator.click('.trigger'); spectator.click(spectator.queryAll('.popover-item', { root: true })[1]); expect(spectator.query('.trigger-label')).toHaveText('Last 15 minutes'); @@ -65,7 +83,15 @@ describe('Time range component', () => { }); test('should show custom time range when custom time applied ', () => { - const spectator = createComponent(); + const spectator = createComponent({ + providers: [ + mockProvider(TimeRangeService, { + getTimeRangeAndChanges: jest + .fn() + .mockReturnValue(of(new FixedTimeRange(new Date(1573255100253), new Date(1573255111159)))) + }) + ] + }); spectator.click('.trigger'); spectator.click(spectator.queryAll('.popover-item', { root: true })[0]); expect(spectator.query('.custom-time-range-selection', { root: true })).toExist(); @@ -83,7 +109,15 @@ describe('Time range component', () => { }); test('should not show refresh button when time range is fixed', () => { - const spectator = createComponent(); + const spectator = createComponent({ + providers: [ + mockProvider(TimeRangeService, { + getTimeRangeAndChanges: jest + .fn() + .mockReturnValue(of(new FixedTimeRange(new Date(1573255100253), new Date(1573255111159)))) + }) + ] + }); spectator.click('.trigger'); spectator.click(spectator.queryAll('.popover-item', { root: true })[0]); expect(spectator.query('.custom-time-range-selection', { root: true })).toExist(); @@ -106,7 +140,7 @@ describe('Time range component', () => { const spectator = createComponent(); const refreshButton = spectator.query('.refresh', { read: ButtonComponent })!; expect(refreshButton).toExist(); - expect(refreshButton.role).toBe(ButtonRole.Secondary); + expect(refreshButton.role).toBe(ButtonRole.Tertiary); spectator.tick(new TimeDuration(5, TimeUnit.Minute).toMillis()); expect(refreshButton.role).toBe(ButtonRole.Primary); expect(refreshButton.label).toBe('Refresh - updated 5m ago'); diff --git a/projects/components/src/time-range/time-range.component.ts b/projects/components/src/time-range/time-range.component.ts index 85cb7141b..caf457c55 100644 --- a/projects/components/src/time-range/time-range.component.ts +++ b/projects/components/src/time-range/time-range.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, Component, NgZone } from '@angular/core'; +import { ChangeDetectionStrategy, Component, EventEmitter, NgZone, Output } from '@angular/core'; import { IconType } from '@hypertrace/assets-library'; import { FixedTimeRange, @@ -24,7 +24,7 @@ import { PopoverRef } from '../popover/popover-ref';
- +
@@ -52,14 +52,13 @@ import { PopoverRef } from '../popover/popover-ref';
- @@ -75,6 +74,9 @@ export class TimeRangeComponent { public showCustom: boolean = false; + @Output() + public readonly timeRangeSelected: EventEmitter = new EventEmitter(); + public constructor(private readonly timeRangeService: TimeRangeService, private readonly ngZone: NgZone) {} public onPopoverOpen(popoverRef: PopoverRef): void { @@ -88,10 +90,12 @@ export class TimeRangeComponent { public setToRelativeTimeRange(timeRange: RelativeTimeRange): void { this.timeRangeService.setRelativeRange(timeRange.duration.value, timeRange.duration.unit); + this.timeRangeSelected.emit(timeRange); this.popoverRef!.close(); } public setToFixedTimeRange(timeRange: FixedTimeRange): void { + this.timeRangeSelected.emit(timeRange); this.timeRangeService.setFixedRange(timeRange.startTime, timeRange.endTime); this.popoverRef!.close(); } @@ -106,7 +110,7 @@ export class TimeRangeComponent { return concat( of({ text$: of('Refresh'), - role: ButtonRole.Secondary, + role: ButtonRole.Tertiary, isEmphasized: false, onClick: () => this.onRefresh(timeRange) }), diff --git a/projects/components/src/time-range/time-range.module.ts b/projects/components/src/time-range/time-range.module.ts index 7c194be6f..6c1700d7d 100644 --- a/projects/components/src/time-range/time-range.module.ts +++ b/projects/components/src/time-range/time-range.module.ts @@ -7,6 +7,7 @@ import { DatetimePickerModule } from '../datetime-picker/datetime-picker.module' import { IconModule } from '../icon/icon.module'; import { InputModule } from '../input/input.module'; import { LabelModule } from '../label/label.module'; +import { LayoutChangeModule } from '../layout/layout-change.module'; import { PopoverModule } from '../popover/popover.module'; import { TooltipModule } from '../tooltip/tooltip.module'; import { CustomTimeRangeSelectionComponent } from './custom-time-range-selection.component'; @@ -24,7 +25,8 @@ import { TimeRangeComponent } from './time-range.component'; PopoverModule, DatetimePickerModule, TooltipModule, - MemoizeModule + MemoizeModule, + LayoutChangeModule ], declarations: [TimeRangeComponent, PredefinedTimeRangeSelectionComponent, CustomTimeRangeSelectionComponent], exports: [TimeRangeComponent] diff --git a/projects/observability/src/pages/apis/api-detail/api-detail-breadcrumb.resolver.test.ts b/projects/observability/src/pages/apis/api-detail/api-detail-breadcrumb.resolver.test.ts index c401bfa55..d8bafc928 100644 --- a/projects/observability/src/pages/apis/api-detail/api-detail-breadcrumb.resolver.test.ts +++ b/projects/observability/src/pages/apis/api-detail/api-detail-breadcrumb.resolver.test.ts @@ -1,7 +1,7 @@ import { fakeAsync, flushMicrotasks } from '@angular/core/testing'; import { ActivatedRouteSnapshot, Router } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; -import { NavigationService } from '@hypertrace/common'; +import { NavigationService, RelativeTimeRange, TimeDuration, TimeRangeService, TimeUnit } from '@hypertrace/common'; import { GraphQlRequestCacheability, GraphQlRequestService } from '@hypertrace/graphql-client'; import { createServiceFactory, mockProvider, SpectatorService } from '@ngneat/spectator/jest'; import { EntityBreadcrumb } from './../../../shared/services/entity-breadcrumb/entity-breadcrumb.resolver'; @@ -31,6 +31,9 @@ describe('Api detail breadcrumb resolver', () => { }) ) }), + mockProvider(TimeRangeService, { + getTimeRangeAndChanges: jest.fn().mockReturnValue(of(new RelativeTimeRange(new TimeDuration(1, TimeUnit.Hour)))) + }), { provide: ENTITY_METADATA, useValue: new Map([ diff --git a/projects/observability/src/pages/apis/backend-detail/backend-detail-breadcrumb.resolver.test.ts b/projects/observability/src/pages/apis/backend-detail/backend-detail-breadcrumb.resolver.test.ts index 515586c36..9776f7300 100644 --- a/projects/observability/src/pages/apis/backend-detail/backend-detail-breadcrumb.resolver.test.ts +++ b/projects/observability/src/pages/apis/backend-detail/backend-detail-breadcrumb.resolver.test.ts @@ -1,7 +1,7 @@ import { fakeAsync, flushMicrotasks } from '@angular/core/testing'; import { ActivatedRouteSnapshot, Router } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; -import { NavigationService } from '@hypertrace/common'; +import { NavigationService, RelativeTimeRange, TimeDuration, TimeRangeService, TimeUnit } from '@hypertrace/common'; import { GraphQlRequestService } from '@hypertrace/graphql-client'; import { patchRouterNavigateForTest, runFakeRxjs } from '@hypertrace/test-utils'; import { createServiceFactory, mockProvider, SpectatorService } from '@ngneat/spectator/jest'; @@ -35,6 +35,9 @@ describe('Backend detail breadcrumb resolver', () => { }), mockProvider(EntityIconLookupService, { forBackendEntity: jest.fn().mockReturnValue(ObservabilityIconType.Mysql) + }), + mockProvider(TimeRangeService, { + getTimeRangeAndChanges: jest.fn().mockReturnValue(of(new RelativeTimeRange(new TimeDuration(1, TimeUnit.Hour)))) }) ], imports: [ diff --git a/projects/observability/src/pages/apis/service-detail/service-detail-breadcrumb.resolver.test.ts b/projects/observability/src/pages/apis/service-detail/service-detail-breadcrumb.resolver.test.ts index 0dba0f384..110186524 100644 --- a/projects/observability/src/pages/apis/service-detail/service-detail-breadcrumb.resolver.test.ts +++ b/projects/observability/src/pages/apis/service-detail/service-detail-breadcrumb.resolver.test.ts @@ -1,7 +1,7 @@ import { fakeAsync, flushMicrotasks } from '@angular/core/testing'; import { ActivatedRouteSnapshot, Router } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; -import { NavigationService } from '@hypertrace/common'; +import { NavigationService, RelativeTimeRange, TimeDuration, TimeRangeService, TimeUnit } from '@hypertrace/common'; import { GraphQlRequestCacheability, GraphQlRequestService } from '@hypertrace/graphql-client'; import { patchRouterNavigateForTest, runFakeRxjs } from '@hypertrace/test-utils'; import { createServiceFactory, mockProvider, SpectatorService } from '@ngneat/spectator/jest'; @@ -30,6 +30,9 @@ describe('Service detail breadcrumb resolver', () => { }), mockProvider(EntityIconLookupService, { forEntity: jest.fn().mockReturnValue(ObservabilityIconType.Service) + }), + mockProvider(TimeRangeService, { + getTimeRangeAndChanges: jest.fn().mockReturnValue(of(new RelativeTimeRange(new TimeDuration(1, TimeUnit.Hour)))) }) ], imports: [ diff --git a/projects/observability/src/pages/explorer/explorer.component.test.ts b/projects/observability/src/pages/explorer/explorer.component.test.ts index 03c94343a..d18a0d1e1 100644 --- a/projects/observability/src/pages/explorer/explorer.component.test.ts +++ b/projects/observability/src/pages/explorer/explorer.component.test.ts @@ -6,6 +6,7 @@ import { RouterTestingModule } from '@angular/router/testing'; import { IconLibraryTestingModule } from '@hypertrace/assets-library'; import { DEFAULT_COLOR_PALETTE, + FeatureStateResolver, LayoutChangeService, NavigationService, PreferenceService, @@ -85,6 +86,9 @@ describe('Explorer component', () => { mockProvider(GraphQlRequestService, { query: jest.fn().mockReturnValueOnce(of(mockAttributes)).mockReturnValue(EMPTY) }), + mockProvider(FeatureStateResolver, { + getFeatureState: jest.fn().mockReturnValue(of(true)) + }), mockProvider(TimeRangeService, { getCurrentTimeRange: () => testTimeRange, getTimeRangeAndChanges: () => NEVER.pipe(startWith(testTimeRange)) diff --git a/projects/observability/src/shared/components/explore-query-editor/interval/explore-query-interval-editor.component.test.ts b/projects/observability/src/shared/components/explore-query-editor/interval/explore-query-interval-editor.component.test.ts index a4bb589b1..c8d042e28 100644 --- a/projects/observability/src/shared/components/explore-query-editor/interval/explore-query-interval-editor.component.test.ts +++ b/projects/observability/src/shared/components/explore-query-editor/interval/explore-query-interval-editor.component.test.ts @@ -2,19 +2,25 @@ import { CommonModule } from '@angular/common'; import { HttpClientTestingModule } from '@angular/common/http/testing'; import { fakeAsync, flush } from '@angular/core/testing'; import { IconLibraryTestingModule } from '@hypertrace/assets-library'; -import { NavigationService, TimeDuration, TimeRangeService, TimeUnit } from '@hypertrace/common'; +import { NavigationService, RelativeTimeRange, TimeDuration, TimeRangeService, TimeUnit } from '@hypertrace/common'; import { byText, createHostFactory, mockProvider } from '@ngneat/spectator/jest'; -import { EMPTY } from 'rxjs'; +import { BehaviorSubject, EMPTY } from 'rxjs'; import { IntervalSelectModule } from '../../interval-select/interval-select.module'; import { ExploreQueryIntervalEditorComponent } from './explore-query-interval-editor.component'; describe('Explore Query Interval Editor component', () => { + const mockTimeRange = new RelativeTimeRange(new TimeDuration(1, TimeUnit.Hour)); + const mockTimeRangeSubject: BehaviorSubject = new BehaviorSubject(mockTimeRange); const hostBuilder = createHostFactory({ component: ExploreQueryIntervalEditorComponent, imports: [IntervalSelectModule, CommonModule, HttpClientTestingModule, IconLibraryTestingModule], providers: [ mockProvider(NavigationService, { navigation$: EMPTY + }), + mockProvider(TimeRangeService, { + getTimeRangeAndChanges: jest.fn().mockReturnValue(mockTimeRangeSubject.asObservable()), + getCurrentTimeRange: jest.fn().mockReturnValue(mockTimeRangeSubject.getValue()) }) ] }); @@ -76,10 +82,20 @@ describe('Explore Query Interval Editor component', () => { })); test('updates interval list when time range changes', fakeAsync(() => { + const timeRange = new RelativeTimeRange(new TimeDuration(1, TimeUnit.Hour)); + const timeRangeSubject: BehaviorSubject = new BehaviorSubject(timeRange); const spectator = hostBuilder( ` - ` + `, + { + providers: [ + mockProvider(TimeRangeService, { + getTimeRangeAndChanges: jest.fn().mockReturnValue(timeRangeSubject.asObservable()), + getCurrentTimeRange: jest.fn().mockReturnValue(timeRangeSubject.getValue()) + }) + ] + } ); spectator.tick(); @@ -87,7 +103,10 @@ describe('Explore Query Interval Editor component', () => { const options = spectator.queryAll('.select-option', { root: true }); expect(options.length).toBe(7); expect(options[1]).toHaveText('Auto (15s)'); - spectator.inject(TimeRangeService).setRelativeRange(3, TimeUnit.Hour); + + // To simulate setRelativeRange(3, TimeUnit.Hour) + timeRangeSubject.next(new RelativeTimeRange(new TimeDuration(3, TimeUnit.Hour))); + spectator.detectChanges(); const newOptions = spectator.queryAll('.select-option', { root: true }); expect(newOptions.length).toBe(8); @@ -112,7 +131,8 @@ describe('Explore Query Interval Editor component', () => { expect(spectator.element).toHaveText('15s'); - spectator.inject(TimeRangeService).setRelativeRange(3, TimeUnit.Hour); + // To simulate setRelativeRange(3, TimeUnit.Hour) + mockTimeRangeSubject.next(new RelativeTimeRange(new TimeDuration(3, TimeUnit.Hour))); expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenCalledWith('AUTO'); })); diff --git a/src/app/application-frame/application-frame.component.ts b/src/app/application-frame/application-frame.component.ts index b69f8fc0a..d1c66e0a9 100644 --- a/src/app/application-frame/application-frame.component.ts +++ b/src/app/application-frame/application-frame.component.ts @@ -1,7 +1,9 @@ import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { IconType } from '@hypertrace/assets-library'; -import { LayoutChangeService } from '@hypertrace/common'; +import { LayoutChangeService, TimeRange, TimeRangeService } from '@hypertrace/common'; import { IconSize } from '@hypertrace/components'; +import { Observable } from 'rxjs'; +import { take } from 'rxjs/operators'; import { UserTelemetryOrchestrationService } from '../shared/telemetry/user-telemetry-orchestration.service'; @Component({ selector: 'ht-application-frame', @@ -16,14 +18,21 @@ import { UserTelemetryOrchestrationService } from '../shared/telemetry/user-tele
-
+
` }) export class ApplicationFrameComponent implements OnInit { - public constructor(private readonly userTelemetryOrchestrationService: UserTelemetryOrchestrationService) {} + public readonly timeRangeHasInit$: Observable; + + public constructor( + private readonly userTelemetryOrchestrationService: UserTelemetryOrchestrationService, + private readonly timeRangeService: TimeRangeService + ) { + this.timeRangeHasInit$ = this.timeRangeService.getTimeRangeAndChanges().pipe(take(1)); + } public ngOnInit(): void { this.userTelemetryOrchestrationService.initialize(); diff --git a/src/app/application-frame/application-frame.module.ts b/src/app/application-frame/application-frame.module.ts index b065e8822..f568f5eda 100644 --- a/src/app/application-frame/application-frame.module.ts +++ b/src/app/application-frame/application-frame.module.ts @@ -1,3 +1,4 @@ +import { CommonModule } from '@angular/common'; import { HttpClientModule } from '@angular/common/http'; import { NgModule } from '@angular/core'; import { RouterModule } from '@angular/router'; @@ -6,7 +7,15 @@ import { NavigationModule } from '../shared/navigation/navigation.module'; import { ApplicationFrameComponent } from './application-frame.component'; @NgModule({ - imports: [ApplicationHeaderModule, NavigationModule, PageHeaderModule, HttpClientModule, RouterModule, IconModule], + imports: [ + CommonModule, + ApplicationHeaderModule, + NavigationModule, + PageHeaderModule, + HttpClientModule, + RouterModule, + IconModule + ], declarations: [ApplicationFrameComponent], exports: [ApplicationFrameComponent] }) diff --git a/src/app/routes/root-routing.module.ts b/src/app/routes/root-routing.module.ts index 63c396550..d5500aa18 100644 --- a/src/app/routes/root-routing.module.ts +++ b/src/app/routes/root-routing.module.ts @@ -1,7 +1,7 @@ import { NgModule } from '@angular/core'; import { PreloadAllModules, RouterModule } from '@angular/router'; import { IconType } from '@hypertrace/assets-library'; -import { ExternalUrlNavigator, HtRoute } from '@hypertrace/common'; +import { ExternalUrlNavigator, HtRoute, RelativeTimeRange, TimeDuration, TimeUnit } from '@hypertrace/common'; import { NotFoundComponent, NotFoundModule } from '@hypertrace/components'; import { ObservabilityIconType } from '@hypertrace/observability'; import { ApplicationFrameComponent } from '../application-frame/application-frame.component'; @@ -25,7 +25,8 @@ const ROUTE_CONFIG: HtRoute[] = [ breadcrumb: { icon: IconType.Dashboard, label: 'Dashboard' - } + }, + defaultTimeRange: new RelativeTimeRange(new TimeDuration(1, TimeUnit.Hour)) }, loadChildren: () => import('../home/home.module').then(module => module.HomeModule) }, @@ -35,7 +36,8 @@ const ROUTE_CONFIG: HtRoute[] = [ breadcrumb: { icon: ObservabilityIconType.ApplicationFlow, label: 'Application Flow' - } + }, + defaultTimeRange: new RelativeTimeRange(new TimeDuration(1, TimeUnit.Hour)) }, loadChildren: () => import('./application-flow/application-flow-routing.module').then( @@ -48,7 +50,8 @@ const ROUTE_CONFIG: HtRoute[] = [ breadcrumb: { icon: ObservabilityIconType.Backend, label: 'Backends' - } + }, + defaultTimeRange: new RelativeTimeRange(new TimeDuration(1, TimeUnit.Hour)) }, loadChildren: () => import('./backends/backends-routing.module').then(module => module.BackendsRoutingModule) @@ -59,7 +62,8 @@ const ROUTE_CONFIG: HtRoute[] = [ breadcrumb: { icon: ObservabilityIconType.Service, label: 'Services' - } + }, + defaultTimeRange: new RelativeTimeRange(new TimeDuration(1, TimeUnit.Hour)) }, loadChildren: () => import('./services/services-routing.module').then(module => module.ServicesRoutingModule) @@ -70,7 +74,8 @@ const ROUTE_CONFIG: HtRoute[] = [ breadcrumb: { icon: ObservabilityIconType.Api, label: 'Endpoints' - } + }, + defaultTimeRange: new RelativeTimeRange(new TimeDuration(1, TimeUnit.Hour)) }, loadChildren: () => import('./endpoints/endpoint-routing.module').then(module => module.EndpointRoutingModule) @@ -89,7 +94,8 @@ const ROUTE_CONFIG: HtRoute[] = [ breadcrumb: { icon: IconType.Search, label: 'Explorer' - } + }, + defaultTimeRange: new RelativeTimeRange(new TimeDuration(1, TimeUnit.Hour)) }, loadChildren: () => import('./explorer/explorer-routing.module').then(module => module.ExplorerRoutingModule) diff --git a/src/app/shared/navigation/navigation.component.test.ts b/src/app/shared/navigation/navigation.component.test.ts index 213d97b9c..3567b8be3 100644 --- a/src/app/shared/navigation/navigation.component.test.ts +++ b/src/app/shared/navigation/navigation.component.test.ts @@ -1,12 +1,26 @@ import { ActivatedRoute } from '@angular/router'; -import { NavigationService, PreferenceService } from '@hypertrace/common'; -import { LetAsyncModule, NavigationListComponent, NavigationListService } from '@hypertrace/components'; +import { + NavigationService, + PreferenceService, + RelativeTimeRange, + TimeDuration, + TimeRangeService, + TimeUnit +} from '@hypertrace/common'; +import { + LetAsyncModule, + NavigationListComponent, + NavigationListComponentService, + NavigationListService, + NavItemConfig +} from '@hypertrace/components'; import { createComponentFactory, mockProvider } from '@ngneat/spectator/jest'; import { MockComponent } from 'ng-mocks'; import { BehaviorSubject, of } from 'rxjs'; import { NavigationComponent } from './navigation.component'; describe('NavigationComponent', () => { + const mockTimeRange = () => new RelativeTimeRange(new TimeDuration(1, TimeUnit.Hour)); const createComponent = createComponentFactory({ component: NavigationComponent, shallow: true, @@ -20,6 +34,18 @@ describe('NavigationComponent', () => { } }) }), + mockProvider(NavigationListComponentService, { + resolveNavItemConfigTimeRanges: jest.fn().mockImplementation((navItems: NavItemConfig[]) => + of( + navItems.map(navItem => ({ + ...navItem, + pageLevelTimeRangeIsEnabled: true, + timeRangeResolver: mockTimeRange + })) + ) + ) + }), + mockProvider(TimeRangeService), mockProvider(NavigationListService, { decorateNavItem: jest.fn().mockImplementation(navItem => ({ ...navItem, features: ['example-feature'] })) }), @@ -45,4 +71,11 @@ describe('NavigationComponent', () => { spectator.triggerEventHandler(NavigationListComponent, 'collapsedChange', true); expect(spectator.inject(PreferenceService).set).toHaveBeenCalledWith('app-navigation.collapsed', true); }); + + test('should decorate the config list with time ranges', () => { + const spectator = createComponent(); + expect(spectator.query(NavigationListComponent)?.navItems).toContainEqual( + expect.objectContaining({ timeRangeResolver: mockTimeRange }) + ); + }); }); diff --git a/src/app/shared/navigation/navigation.component.ts b/src/app/shared/navigation/navigation.component.ts index 50d05a243..eabe574a0 100644 --- a/src/app/shared/navigation/navigation.component.ts +++ b/src/app/shared/navigation/navigation.component.ts @@ -1,9 +1,16 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { IconType } from '@hypertrace/assets-library'; -import { PreferenceService } from '@hypertrace/common'; -import { NavigationListService, NavItemConfig, NavItemType } from '@hypertrace/components'; +import { FixedTimeRange, PreferenceService, RelativeTimeRange, TimeRangeService } from '@hypertrace/common'; +import { + NavigationListComponentService, + NavigationListService, + NavItemConfig, + NavItemLinkConfig, + NavItemType +} from '@hypertrace/components'; import { ObservabilityIconType } from '@hypertrace/observability'; +import { isNil } from 'lodash-es'; import { Observable } from 'rxjs'; @Component({ @@ -13,17 +20,21 @@ import { Observable } from 'rxjs'; template: ` ` }) export class NavigationComponent { private static readonly COLLAPSED_PREFERENCE: string = 'app-navigation.collapsed'; - public readonly navItems: NavItemConfig[]; + + public navItems$?: Observable; + public readonly isCollapsed$: Observable; private readonly navItemDefinitions: NavItemConfig[] = [ @@ -76,14 +87,39 @@ export class NavigationComponent { public constructor( private readonly navigationListService: NavigationListService, private readonly preferenceService: PreferenceService, - private readonly activatedRoute: ActivatedRoute + private readonly navListComponentService: NavigationListComponentService, + private readonly activatedRoute: ActivatedRoute, + private readonly timeRangeService: TimeRangeService ) { - this.navItems = this.navItemDefinitions.map(definition => + const navItems = this.navItemDefinitions.map(definition => this.navigationListService.decorateNavItem(definition, this.activatedRoute) ); + // Decorate the nav items with the corresponding time ranges, depending on the FF state. + // The time ranges in nav items are streams that get the most recent value from page time range preference service + this.navItems$ = this.navListComponentService.resolveNavItemConfigTimeRanges(navItems); + this.isCollapsed$ = this.preferenceService.get(NavigationComponent.COLLAPSED_PREFERENCE, false); } + public setPageTimeRangeForSelectedNavItem(navItemLink: NavItemLinkConfig): void { + if (!isNil(navItemLink.timeRangeResolver) && navItemLink.pageLevelTimeRangeIsEnabled) { + const timeRange = navItemLink.timeRangeResolver(); + if (timeRange instanceof FixedTimeRange) { + this.timeRangeService.setFixedRange(timeRange.startTime, timeRange.endTime); + } else if (timeRange instanceof RelativeTimeRange) { + this.timeRangeService.setRelativeRange(timeRange.duration.value, timeRange.duration.unit); + } + } + } + + public updateDefaultTimeRangeIfUnset(activeItem: NavItemLinkConfig): void { + // Initialize the time range service + // Depending on FF status, the TR will be either global or page level for the init + if (!this.timeRangeService.isInitialized()) { + this.timeRangeService.setDefaultTimeRange(activeItem.timeRangeResolver!()); + } + } + public onViewToggle(collapsed: boolean): void { this.preferenceService.set(NavigationComponent.COLLAPSED_PREFERENCE, collapsed); }