Skip to content
23 changes: 21 additions & 2 deletions 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 All @@ -22,7 +22,7 @@ describe('External URL navigator', () => {
test('goes back when unable to detect a url on navigation', () => {
// tslint:disable-next-line: no-object-literal-type-assertion
spectator.service.canActivate({
paramMap: convertToParamMap({})
paramMap: convertToParamMap({}),
} as ActivatedRouteSnapshot);

expect(spectator.inject(NavigationService).navigateBack).toHaveBeenCalledTimes(1);
Expand Down Expand Up @@ -57,4 +57,23 @@ 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
} as ActivatedRouteSnapshot);

expect(window.open).toHaveBeenCalledWith('https://www.bing.com?time=1h', undefined);
expect(spectator.inject(NavigationService).navigateBack).not.toHaveBeenCalled();
});


});
68 changes: 36 additions & 32 deletions projects/common/src/external/external-url-navigator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,45 +2,49 @@ import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate } from '@angular/router';
import { Observable, of } from 'rxjs';
import {
ExternalNavigationPathParams,
ExternalNavigationWindowHandling,
NavigationService
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 constructor(private readonly navService: NavigationService) { }

public canActivate(route: ActivatedRouteSnapshot): Observable<boolean> {
const encodedUrl = route.paramMap.get(ExternalNavigationPathParams.Url);
const windowHandling = route.paramMap.has(ExternalNavigationPathParams.WindowHandling)
? (route.paramMap.get(ExternalNavigationPathParams.WindowHandling) as ExternalNavigationWindowHandling)
: undefined;
if (encodedUrl !== null && encodedUrl.length > 0) {
this.navigateToUrl(encodedUrl, windowHandling);
} else {
this.navService.navigateBack();
}
public canActivate(route: ActivatedRouteSnapshot): Observable<boolean> {
const url = route.paramMap.get(ExternalNavigationPathParams.Url);
const queryParamString = getQueryParamStringFromObject(route.queryParams ?? {});
const encodedUrl = url ? `${url}${queryParamString ? `?${queryParamString}` : ``}` : url;

return of(false); // Can't navigate, but we've already navigated anyway
}
const windowHandling = route.paramMap.has(ExternalNavigationPathParams.WindowHandling)
? (route.paramMap.get(ExternalNavigationPathParams.WindowHandling) as ExternalNavigationWindowHandling)
: undefined;
if (encodedUrl !== null && encodedUrl.length > 0) {
this.navigateToUrl(encodedUrl, windowHandling);
} else {
this.navService.navigateBack();
}

private navigateToUrl(
url: string,
windowHandling: ExternalNavigationWindowHandling = ExternalNavigationWindowHandling.SameWindow
): void {
window.open(url, this.asWindowName(windowHandling));
}
return of(false); // Can't navigate, but we've already navigated anyway
}

private asWindowName(windowHandling: ExternalNavigationWindowHandling): string | undefined {
switch (windowHandling) {
case ExternalNavigationWindowHandling.SameWindow:
return '_self';
case ExternalNavigationWindowHandling.NewWindow:
return undefined;
default:
assertUnreachable(windowHandling);
}
}
private navigateToUrl(
url: string,
windowHandling: ExternalNavigationWindowHandling = ExternalNavigationWindowHandling.SameWindow
): void {
window.open(url, this.asWindowName(windowHandling));
}

private asWindowName(windowHandling: ExternalNavigationWindowHandling): string | undefined {
switch (windowHandling) {
case ExternalNavigationWindowHandling.SameWindow:
return '_self';
case ExternalNavigationWindowHandling.NewWindow:
return undefined;
default:
assertUnreachable(windowHandling);
}
}
}
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';
12 changes: 10 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,21 @@ 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
47 changes: 47 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,47 @@
import { Params } from '@angular/router';
import {
getQueryParamStringFromObject,
getQueryParamObjectFromString
} 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 = null;
expect(getQueryParamStringFromObject(params)).toBe(`a=null`);
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');
});
});
26 changes: 26 additions & 0 deletions projects/common/src/utilities/url/url-utilities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Dictionary, isEmpty } from "lodash"

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

export const getQueryParamObjectFromString = (string: string): Dictionary<string> => {
try {
string = string[0] === '?' ? string.substring(1) : string;
return string.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,13 @@
import { fakeAsync } from '@angular/core/testing';
import {
ExternalNavigationWindowHandling,
NavigationParamsType,
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 { IconComponent } from '../icon/icon.component';
import { IconSize } from '../icon/icon-size';
import { LinkComponent } from '../link/link.component';
import { OpenInNewTabComponent } from './open-in-new-tab.component';

describe('Open in new tab component', () => {
Expand All @@ -16,42 +16,59 @@ 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);
}));

});
Loading