Skip to content
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
"graphql": "^15.5.0",
"graphql-tag": "^2.12.3",
"iso8601-duration": "^1.3.0",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"rxjs": "~6.6.7",
"tslib": "^2.2.0",
Expand Down Expand Up @@ -101,7 +102,6 @@
"jest-html-reporter": "^3.2.0",
"jest-junit": "^12.0.0",
"jest-preset-angular": "^8.4.0",
"lodash": "^4.17.21",
"ng-mocks": "^11.10.0",
"ng-packagr": "^11.2.4",
"prettier": "^2.2.1",
Expand Down
19 changes: 18 additions & 1 deletion projects/common/src/external/external-url-navigator.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ActivatedRouteSnapshot, convertToParamMap } from '@angular/router';
import { ActivatedRouteSnapshot, convertToParamMap, Params } from '@angular/router';
import { createServiceFactory, mockProvider, SpectatorService } from '@ngneat/spectator/jest';
import {
ExternalNavigationPathParams,
Expand Down Expand Up @@ -57,4 +57,21 @@ describe('External URL navigator', () => {
expect(window.open).toHaveBeenNthCalledWith(2, 'https://www.bing.com', undefined);
expect(spectator.inject(NavigationService).navigateBack).not.toHaveBeenCalled();
});

test('navigates when a url is provided with query params', () => {
const queryParams: Params = {
time: '1h'
};
// tslint:disable-next-line: no-object-literal-type-assertion
spectator.service.canActivate({
paramMap: convertToParamMap({
[ExternalNavigationPathParams.Url]: 'https://www.bing.com',
[ExternalNavigationPathParams.WindowHandling]: ExternalNavigationWindowHandling.NewWindow
}),
queryParams: queryParams
} as ActivatedRouteSnapshot);

expect(window.open).toHaveBeenCalledWith('https://www.bing.com?time=1h', undefined);
expect(spectator.inject(NavigationService).navigateBack).not.toHaveBeenCalled();
});
});
7 changes: 6 additions & 1 deletion projects/common/src/external/external-url-navigator.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,24 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate } from '@angular/router';
import { isEmpty } from 'lodash-es';
import { Observable, of } from 'rxjs';
import {
ExternalNavigationPathParams,
ExternalNavigationWindowHandling,
NavigationService
} from '../navigation/navigation.service';
import { assertUnreachable } from '../utilities/lang/lang-utils';
import { getQueryParamStringFromObject } from '../utilities/url/url-utilities';

@Injectable({ providedIn: 'root' })
export class ExternalUrlNavigator implements CanActivate {
public constructor(private readonly navService: NavigationService) {}

public canActivate(route: ActivatedRouteSnapshot): Observable<boolean> {
const encodedUrl = route.paramMap.get(ExternalNavigationPathParams.Url);
const url = route.paramMap.get(ExternalNavigationPathParams.Url);
const queryParamString = getQueryParamStringFromObject(route.queryParams ?? {});
const encodedUrl = isEmpty(url) ? url : `${url}${queryParamString ? `?${queryParamString}` : ``}`;

const windowHandling = route.paramMap.has(ExternalNavigationPathParams.WindowHandling)
? (route.paramMap.get(ExternalNavigationPathParams.WindowHandling) as ExternalNavigationWindowHandling)
: undefined;
Expand Down
3 changes: 2 additions & 1 deletion projects/common/src/navigation/navigation.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,8 @@ export class NavigationService {
}
],
extras: {
skipLocationChange: true // Don't bother showing the updated location, we're going external anyway
skipLocationChange: true, // Don't bother showing the updated location, we're going external anyway
queryParams: params.queryParams
}
};
}
Expand Down
3 changes: 3 additions & 0 deletions projects/common/src/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,6 @@ export * from './time/time';

// Validators
export * from './utilities/validators/email-validator';

// URL Utilities
export * from './utilities/url/url-utilities';
13 changes: 11 additions & 2 deletions projects/common/src/time/time-range.service.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { Injectable } from '@angular/core';
import { Dictionary } from 'lodash';
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 { ReplayObservable } from '../utilities/rxjs/rxjs-utils';
import { getQueryParamStringFromObject } from '../utilities/url/url-utilities';
import { FixedTimeRange } from './fixed-time-range';
import { RelativeTimeRange } from './relative-time-range';
import { TimeDuration } from './time-duration';
Expand All @@ -30,15 +32,22 @@ export class TimeRangeService {
}

public getShareableCurrentUrl(): string {
const timeRangeParamValue = this.navigationService.getQueryParameter(TimeRangeService.TIME_RANGE_QUERY_PARAM, '');
const timeRangeParam = `${TimeRangeService.TIME_RANGE_QUERY_PARAM}=${timeRangeParamValue}`;
const timeRangeParam = getQueryParamStringFromObject(this.getTimeRangeParams());
const timeRange = this.getCurrentTimeRange();
const fixedTimeRange: FixedTimeRange = TimeRangeService.toFixedTimeRange(timeRange.startTime, timeRange.endTime);
const newTimeRangeParam = `${TimeRangeService.TIME_RANGE_QUERY_PARAM}=${fixedTimeRange.toUrlString()}`;

return this.navigationService.getAbsoluteCurrentUrl().replace(timeRangeParam, newTimeRangeParam);
}

public getTimeRangeParams(): Dictionary<string> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this need to be public?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need the time range param to generate the new tab URL

const timeRangeParamValue = this.navigationService.getQueryParameter(TimeRangeService.TIME_RANGE_QUERY_PARAM, '');

return {
[TimeRangeService.TIME_RANGE_QUERY_PARAM]: timeRangeParamValue
};
}

public getTimeRangeAndChanges(): ReplayObservable<TimeRange> {
return this.timeRangeSubject$.asObservable();
}
Expand Down
42 changes: 42 additions & 0 deletions projects/common/src/utilities/url/url-utilities.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Params } from '@angular/router';
import { getQueryParamObjectFromString, getQueryParamStringFromObject } from './url-utilities';

describe('getQueryParamStringFromObject', () => {
it('should return string from a string dictionary', () => {
const params: Params = {};
expect(getQueryParamStringFromObject(params)).toBe('');
params.a = undefined;
expect(getQueryParamStringFromObject(params)).toBe('');
params.a = true;
expect(getQueryParamStringFromObject(params)).toBe(`a=true`);
params.a = false;
expect(getQueryParamStringFromObject(params)).toBe(`a=false`);
params.b = false;
expect(getQueryParamStringFromObject(params)).toBe(`a=false&b=false`);
params.a = 'value_1';
params.b = 'value_2';
expect(getQueryParamStringFromObject(params)).toBe(`a=value_1&b=value_2`);
});
});

describe('getQueryParamObjectFromString', () => {
it('should return object from a string', () => {
let queryParam = '?queryparam1=value';
expect(getQueryParamObjectFromString(queryParam)).toHaveProperty('queryparam1');
expect(getQueryParamObjectFromString(queryParam).queryparam1).toBe('value');

queryParam = '?queryparam1=';
expect(getQueryParamObjectFromString(queryParam)).toHaveProperty('queryparam1');
expect(getQueryParamObjectFromString(queryParam).queryparam1).toBe('');

queryParam = 'queryparam1=value';
expect(getQueryParamObjectFromString(queryParam)).toHaveProperty('queryparam1');
expect(getQueryParamObjectFromString(queryParam).queryparam1).toBe('value');

queryParam = '?queryparam1=value1&queryparam2=value2';
expect(getQueryParamObjectFromString(queryParam)).toHaveProperty('queryparam1');
expect(getQueryParamObjectFromString(queryParam).queryparam1).toBe('value1');
expect(getQueryParamObjectFromString(queryParam)).toHaveProperty('queryparam2');
expect(getQueryParamObjectFromString(queryParam).queryparam2).toBe('value2');
});
});
29 changes: 29 additions & 0 deletions projects/common/src/utilities/url/url-utilities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Dictionary, isEmpty } from 'lodash';

export const getQueryParamStringFromObject = (params: Dictionary<string | number>): string => {
try {
const paramString: string = Object.entries(params)
.map(([key, value]) => `${key}=${value}`)
.filter(item => item)
.join('&');

return isEmpty(paramString) ? '' : paramString;
} catch (error) {
return ``;
}
};

export const getQueryParamObjectFromString = (query: string): Dictionary<string> => {
try {
const queryString = query[0] === '?' ? query.substring(1) : query;

return queryString.split('&').reduce((acc: Dictionary<string>, item: string): Dictionary<string> => {
const [key, value] = item.split('=');
acc[key] = value;

return acc;
}, {});
} catch (error) {
return {};
}
};
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
import { fakeAsync } from '@angular/core/testing';
import {
ExternalNavigationWindowHandling,
NavigationParamsType,
NavigationService,
TimeRangeService
} from '@hypertrace/common';
import { NavigationService, TimeRangeService } from '@hypertrace/common';
import { createHostFactory, mockProvider, Spectator } from '@ngneat/spectator/jest';
import { MockComponent } from 'ng-mocks';
import { ButtonComponent } from '../button/button.component';
import { IconSize } from '../icon/icon-size';
import { IconComponent } from '../icon/icon.component';
import { LinkComponent } from '../link/link.component';
import { OpenInNewTabComponent } from './open-in-new-tab.component';

describe('Open in new tab component', () => {
Expand All @@ -16,42 +13,60 @@ describe('Open in new tab component', () => {
const createHost = createHostFactory({
shallow: true,
component: OpenInNewTabComponent,
declarations: [MockComponent(ButtonComponent)],
declarations: [MockComponent(LinkComponent), MockComponent(IconComponent)],
providers: [
mockProvider(TimeRangeService, {
getShareableCurrentUrl: () => 'url-from-timerangeservice'
}),
mockProvider(NavigationService)
mockProvider(NavigationService, {
buildNavigationParams: jest.fn().mockReturnValue({
path: [
'/external',
{
url: 'http://test.hypertrace.ai',
navType: 'same_window'
}
],
extras: { skipLocationChange: true }
})
})
]
});

test('should call navigate as expected when URL input is not specified', fakeAsync(() => {
spectator = createHost('<ht-open-in-new-tab></ht-open-in-new-tab>');

spectator.click('.open-in-new-tab-button');
spectator.tick();

expect(spectator.inject(NavigationService).navigate).toHaveBeenCalledWith({
navType: NavigationParamsType.External,
windowHandling: ExternalNavigationWindowHandling.NewWindow,
url: 'url-from-timerangeservice'
test('Open in new tab component should not be displayed if paramsOrUrl is undefined', () => {
spectator = createHost(`<ht-open-in-new-tab [paramsOrUrl]="paramsOrUrl"></ht-open-in-new-tab>`, {
hostProps: {
paramsOrUrl: undefined
}
});
}));
expect(spectator.query('.open-in-new-tab')).not.toExist();
});

test('should call navigate as expected when URL input is specified', fakeAsync(() => {
spectator = createHost('<ht-open-in-new-tab [url]="url"></ht-open-in-new-tab>', {
test(`Open in new tab component should exist if paramsOrUrl is not undefined`, fakeAsync(() => {
spectator = createHost(`<ht-open-in-new-tab [paramsOrUrl]="paramsOrUrl"></ht-open-in-new-tab>`, {
hostProps: {
url: 'input-url'
paramsOrUrl: {}
}
});
expect(spectator.query('.open-in-new-tab')).toExist();
expect(spectator.query('ht-link')).toExist();
// Default value of icon size
expect(spectator.component.iconSize).toBe(IconSize.Medium);
}));

spectator.click('.open-in-new-tab-button');
spectator.tick();

expect(spectator.inject(NavigationService).navigate).toHaveBeenCalledWith({
navType: NavigationParamsType.External,
windowHandling: ExternalNavigationWindowHandling.NewWindow,
url: 'input-url'
});
test(`Open in new tab component should contain icon of passed size`, fakeAsync(() => {
spectator = createHost(
`<ht-open-in-new-tab [paramsOrUrl]="paramsOrUrl" [iconSize]="iconSize" ></ht-open-in-new-tab>`,
{
hostProps: {
paramsOrUrl: {},
iconSize: IconSize.Small
}
}
);
expect(spectator.query('.open-in-new-tab')).toExist();
expect(spectator.query('ht-link')).toExist();
// Expected value of icon size if passed
expect(spectator.component.iconSize).toBe(IconSize.Small);
}));
});
Original file line number Diff line number Diff line change
@@ -1,46 +1,20 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { IconType } from '@hypertrace/assets-library';
import {
ExternalNavigationWindowHandling,
NavigationParamsType,
NavigationService,
TimeRangeService
} from '@hypertrace/common';
import { ButtonSize, ButtonStyle } from '../button/button';
import { IconSize } from '../icon/icon-size';
import { LinkComponent } from '../link/link.component';

@Component({
selector: 'ht-open-in-new-tab',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="open-in-new-tab" htTooltip="Open in a new tab">
<ht-button
class="open-in-new-tab-button"
display="${ButtonStyle.Outlined}"
[size]="this.size"
icon="${IconType.OpenInNewTab}"
(click)="this.openInNewTab()"
></ht-button>
<div *ngIf="this.navigationPath" class="open-in-new-tab" htTooltip="Open in a new tab">
<ht-link [paramsOrUrl]="this.paramsOrUrl">
<ht-icon icon="${IconType.OpenInNewTab}" [size]="this.iconSize" ></ht-icon>
</ht-link>
</div>
`
})
export class OpenInNewTabComponent {
export class OpenInNewTabComponent extends LinkComponent {
@Input()
public size?: ButtonSize = ButtonSize.Small;

@Input()
public url?: string;

public constructor(
private readonly navigationService: NavigationService,
private readonly timeRangeService: TimeRangeService
) {}

public openInNewTab(): void {
this.navigationService.navigate({
navType: NavigationParamsType.External,
windowHandling: ExternalNavigationWindowHandling.NewWindow,
// Use input url if available. Else construct a shareable URL for the page
url: this.url ?? this.timeRangeService.getShareableCurrentUrl()
});
}
public iconSize: IconSize = IconSize.Medium;
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { ButtonModule } from '../button/button.module';
import { IconModule } from '../icon/icon.module';
import { LinkModule } from '../link/link.module';
import { TooltipModule } from '../tooltip/tooltip.module';
import { OpenInNewTabComponent } from './open-in-new-tab.component';

@NgModule({
declarations: [OpenInNewTabComponent],
exports: [OpenInNewTabComponent],
imports: [CommonModule, ButtonModule, TooltipModule]
imports: [CommonModule, TooltipModule, LinkModule, IconModule]
})
export class OpenInNewTabModule {}