Skip to content

Commit 978312f

Browse files
fix: hide navigation header if nothing underneath (#1035)
1 parent c4f70a4 commit 978312f

8 files changed

+220
-54
lines changed

projects/components/src/navigation/nav-item/nav-item.component.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { BetaTagComponent, IconComponent, LinkComponent } from '@hypertrace/comp
1111
import { createHostFactory, mockProvider, SpectatorHost } from '@ngneat/spectator/jest';
1212
import { MockComponent } from 'ng-mocks';
1313
import { EMPTY, of } from 'rxjs';
14-
import { NavItemConfig, NavItemType } from '../navigation-list.component';
14+
import { NavItemConfig, NavItemType } from '../navigation.config';
1515
import { FeatureConfigCheckModule } from './../../feature-check/feature-config-check.module';
1616
import { NavItemComponent } from './nav-item.component';
1717

@@ -43,7 +43,8 @@ describe('Navigation Item Component', () => {
4343
type: NavItemType.Link,
4444
icon: IconType.TriangleLeft,
4545
label: 'Foo Label',
46-
matchPaths: ['foo', 'bar']
46+
matchPaths: ['foo', 'bar'],
47+
featureState$: of(FeatureState.Enabled)
4748
};
4849
spectator = createHost(`<ht-nav-item [config]="navItem"></ht-nav-item>`, {
4950
hostProps: { navItem: navItem }

projects/components/src/navigation/nav-item/nav-item.component.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
22
import { ActivatedRoute } from '@angular/router';
33
import { FeatureState, NavigationParams, NavigationParamsType } from '@hypertrace/common';
44
import { IconSize } from '../../icon/icon-size';
5-
import { NavItemLinkConfig } from '../navigation-list.component';
5+
import { NavItemLinkConfig } from '../navigation.config';
66

77
@Component({
88
selector: 'ht-nav-item',
@@ -11,7 +11,7 @@ import { NavItemLinkConfig } from '../navigation-list.component';
1111
template: `
1212
<ht-link *ngIf="this.config" [paramsOrUrl]="buildNavigationParam | htMemoize: this.config">
1313
<div
14-
*htIfFeature="this.config.features | htFeature as featureState"
14+
*htIfFeature="this.config.featureState$ | async as featureState"
1515
class="nav-item"
1616
[ngClass]="{ active: this.active }"
1717
>
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { FeatureState, FeatureStateResolver } from '@hypertrace/common';
2+
import { NavItemConfig, NavItemType } from '@hypertrace/components';
3+
import { runFakeRxjs } from '@hypertrace/test-utils';
4+
import { createServiceFactory, mockProvider } from '@ngneat/spectator/jest';
5+
import { of } from 'rxjs';
6+
import { NavigationListComponentService } from './navigation-list-component.service';
7+
import { NavItemHeaderConfig } from './navigation.config';
8+
9+
describe('Navigation List Component Service', () => {
10+
const navItems: NavItemConfig[] = [
11+
{
12+
type: NavItemType.Header,
13+
label: 'header 1'
14+
},
15+
{
16+
type: NavItemType.Link,
17+
icon: 'icon',
18+
label: 'label-1',
19+
features: ['feature'],
20+
matchPaths: ['']
21+
},
22+
{
23+
type: NavItemType.Link,
24+
icon: 'icon',
25+
label: 'label-2',
26+
matchPaths: ['']
27+
},
28+
{
29+
type: NavItemType.Header,
30+
label: 'header 2'
31+
}
32+
];
33+
34+
const createService = createServiceFactory({
35+
service: NavigationListComponentService,
36+
providers: [
37+
mockProvider(FeatureStateResolver, {
38+
getCombinedFeatureState: jest.fn().mockReturnValue(of(FeatureState.Enabled))
39+
})
40+
]
41+
});
42+
43+
test('should return correct visibility for both headers', () => {
44+
const spectator = createService();
45+
const resolvedItems = spectator.service.resolveFeaturesAndUpdateVisibilityForNavItems(navItems);
46+
47+
runFakeRxjs(({ expectObservable }) => {
48+
expectObservable((resolvedItems[0] as NavItemHeaderConfig).isVisible$!).toBe('(x|)', {
49+
x: true
50+
});
51+
expectObservable((resolvedItems[3] as NavItemHeaderConfig).isVisible$!).toBe('(x|)', {
52+
x: false
53+
});
54+
});
55+
});
56+
});
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { Injectable } from '@angular/core';
2+
import { FeatureState, FeatureStateResolver } from '@hypertrace/common';
3+
import { isEmpty } from 'lodash-es';
4+
import { combineLatest, Observable, of } from 'rxjs';
5+
import { map } from 'rxjs/operators';
6+
import { NavItemConfig, NavItemHeaderConfig, NavItemLinkConfig, NavItemType } from './navigation.config';
7+
8+
@Injectable({ providedIn: 'root' })
9+
export class NavigationListComponentService {
10+
public constructor(private readonly featureStateResolver: FeatureStateResolver) {}
11+
12+
public resolveFeaturesAndUpdateVisibilityForNavItems(navItems: NavItemConfig[]): NavItemConfig[] {
13+
const updatedItems = this.updateLinkNavItemsVisibility(navItems);
14+
let linkItemsForThisSection: NavItemLinkConfig[] = [];
15+
for (let i = updatedItems.length - 1; i >= 0; i--) {
16+
if (updatedItems[i].type === NavItemType.Header) {
17+
(updatedItems[i] as NavItemHeaderConfig).isVisible$ = this.updateHeaderNavItemsVisibility(
18+
linkItemsForThisSection
19+
);
20+
linkItemsForThisSection = [];
21+
} else if (updatedItems[i].type === NavItemType.Link) {
22+
linkItemsForThisSection.push(updatedItems[i] as NavItemLinkConfig);
23+
}
24+
}
25+
26+
return updatedItems;
27+
}
28+
29+
private updateHeaderNavItemsVisibility(navItems: NavItemLinkConfig[]): Observable<boolean> {
30+
return isEmpty(navItems)
31+
? of(false)
32+
: combineLatest(navItems.map(navItem => navItem.featureState$!)).pipe(
33+
map(states => states.some(state => state !== FeatureState.Disabled))
34+
);
35+
}
36+
37+
private updateLinkNavItemsVisibility(navItems: NavItemConfig[]): NavItemConfig[] {
38+
return navItems.map(navItem => {
39+
if (navItem.type === NavItemType.Link) {
40+
return {
41+
...navItem,
42+
featureState$: this.featureStateResolver.getCombinedFeatureState(navItem.features ?? [])
43+
};
44+
}
45+
46+
return navItem;
47+
});
48+
}
49+
}

projects/components/src/navigation/navigation-list.component.test.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import { IconComponent } from '../icon/icon.component';
88
import { LetAsyncModule } from '../let-async/let-async.module';
99
import { LinkComponent } from './../link/link.component';
1010
import { NavItemComponent } from './nav-item/nav-item.component';
11-
import { FooterItemConfig, NavigationListComponent, NavItemConfig, NavItemType } from './navigation-list.component';
11+
import { NavigationListComponentService } from './navigation-list-component.service';
12+
import { NavigationListComponent } from './navigation-list.component';
13+
import { FooterItemConfig, NavItemConfig, NavItemType } from './navigation.config';
1214
describe('Navigation List Component', () => {
1315
let spectator: SpectatorHost<NavigationListComponent>;
1416
const activatedRoute = {
@@ -21,6 +23,13 @@ describe('Navigation List Component', () => {
2123
imports: [LetAsyncModule, MemoizeModule],
2224
providers: [
2325
mockProvider(ActivatedRoute, activatedRoute),
26+
mockProvider(NavigationListComponentService, {
27+
resolveFeaturesAndUpdateVisibilityForNavItems: jest
28+
.fn()
29+
.mockImplementation((navItems: NavItemConfig[]) =>
30+
navItems.map(item => (item.type !== NavItemType.Header ? item : { ...item, isVisible$: of(true) }))
31+
)
32+
}),
2433
mockProvider(NavigationService, {
2534
navigation$: EMPTY,
2635
navigateWithinApp: jest.fn(),
@@ -78,4 +87,42 @@ describe('Navigation List Component', () => {
7887
expect(spectator.query('.navigation-list')).not.toHaveClass('expanded');
7988
expect(spectator.query(IconComponent)?.icon).toEqual(IconType.TriangleRight);
8089
});
90+
91+
test('should only show one header 1', () => {
92+
const navItems: NavItemConfig[] = [
93+
{
94+
type: NavItemType.Header,
95+
label: 'header 1',
96+
isVisible$: of(true)
97+
},
98+
{
99+
type: NavItemType.Link,
100+
icon: 'icon',
101+
label: 'label-2',
102+
matchPaths: ['']
103+
},
104+
{
105+
type: NavItemType.Header,
106+
label: 'header 2',
107+
isVisible$: of(false)
108+
}
109+
];
110+
111+
spectator = createHost(`<ht-navigation-list [navItems]="navItems"></ht-navigation-list>`, {
112+
hostProps: { navItems: navItems },
113+
providers: [
114+
mockProvider(ActivatedRoute, activatedRoute),
115+
mockProvider(NavigationListComponentService, {
116+
resolveFeaturesAndUpdateVisibilityForNavItems: jest.fn().mockReturnValue(navItems)
117+
}),
118+
mockProvider(NavigationService, {
119+
navigation$: EMPTY,
120+
navigateWithinApp: jest.fn(),
121+
getCurrentActivatedRoute: jest.fn().mockReturnValue(of(activatedRoute))
122+
})
123+
]
124+
});
125+
expect(spectator.queryAll('.nav-header')).toHaveLength(1);
126+
expect(spectator.queryAll('.nav-header .label')[0]).toHaveText('header 1');
127+
});
81128
});

projects/components/src/navigation/navigation-list.component.ts

Lines changed: 13 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core';
22
import { ActivatedRoute } from '@angular/router';
33
import { IconType } from '@hypertrace/assets-library';
4-
import { Color, NavigationService } from '@hypertrace/common';
4+
import { NavigationService } from '@hypertrace/common';
55
import { Observable } from 'rxjs';
66
import { map, startWith } from 'rxjs/operators';
77
import { IconSize } from '../icon/icon-size';
8+
import { NavigationListComponentService } from './navigation-list-component.service';
9+
import { FooterItemConfig, NavItemConfig, NavItemLinkConfig, NavItemType } from './navigation.config';
810

911
@Component({
1012
selector: 'ht-navigation-list',
@@ -13,13 +15,15 @@ import { IconSize } from '../icon/icon-size';
1315
template: `
1416
<nav class="navigation-list" [ngClass]="{ expanded: !this.collapsed }">
1517
<div class="content" *htLetAsync="this.activeItem$ as activeItem" [htLayoutChangeTrigger]="this.collapsed">
16-
<ng-container *ngFor="let item of this.navItems">
18+
<ng-container *ngFor="let item of this.navItems; let id = index">
1719
<ng-container [ngSwitch]="item.type">
1820
<div *ngIf="!this.collapsed">
19-
<div *ngSwitchCase="'${NavItemType.Header}'" class="nav-header">
20-
<div class="label">{{ item.label }}</div>
21-
<ht-beta-tag *ngIf="item.isBeta" class="beta"></ht-beta-tag>
22-
</div>
21+
<ng-container *ngSwitchCase="'${NavItemType.Header}'">
22+
<div *ngIf="item.isVisible$ | async" class="nav-header">
23+
<div class="label">{{ item.label }}</div>
24+
<ht-beta-tag *ngIf="item.isBeta" class="beta"></ht-beta-tag>
25+
</div>
26+
</ng-container>
2327
</div>
2428
2529
<hr *ngSwitchCase="'${NavItemType.Divider}'" class="nav-divider" />
@@ -68,10 +72,12 @@ export class NavigationListComponent implements OnChanges {
6872

6973
public constructor(
7074
private readonly navigationService: NavigationService,
71-
private readonly activatedRoute: ActivatedRoute
75+
private readonly activatedRoute: ActivatedRoute,
76+
private readonly navListComponentService: NavigationListComponentService
7277
) {}
7378

7479
public ngOnChanges(): void {
80+
this.navItems = this.navListComponentService.resolveFeaturesAndUpdateVisibilityForNavItems(this.navItems);
7581
this.activeItem$ = this.navigationService.navigation$.pipe(
7682
startWith(this.navigationService.getCurrentActivatedRoute()),
7783
map(() => this.findActiveItem(this.navItems))
@@ -99,45 +105,3 @@ export class NavigationListComponent implements OnChanges {
99105
);
100106
}
101107
}
102-
103-
export type NavItemConfig = NavItemLinkConfig | NavItemHeaderConfig | NavItemDividerConfig;
104-
105-
export interface NavItemLinkConfig {
106-
type: NavItemType.Link;
107-
icon: string;
108-
iconSize?: IconSize;
109-
label: string;
110-
matchPaths: string[]; // For now, default path is index 0
111-
features?: string[];
112-
replaceCurrentHistory?: boolean;
113-
isBeta?: boolean;
114-
trailingIcon?: string;
115-
trailingIconTooltip?: string;
116-
trailingIconColor?: Color;
117-
}
118-
119-
export type FooterItemConfig = FooterItemLinkConfig;
120-
121-
export interface FooterItemLinkConfig {
122-
url: string;
123-
label: string;
124-
icon: string;
125-
}
126-
127-
export interface NavItemHeaderConfig {
128-
type: NavItemType.Header;
129-
label: string;
130-
isBeta?: boolean;
131-
}
132-
133-
export interface NavItemDividerConfig {
134-
type: NavItemType.Divider;
135-
}
136-
137-
// Must be exported to be used by AOT compiler in template
138-
export const enum NavItemType {
139-
Header = 'header',
140-
Link = 'link',
141-
Divider = 'divider',
142-
Footer = 'footer'
143-
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { Color, FeatureState } from '@hypertrace/common';
2+
import { Observable } from 'rxjs';
3+
import { IconSize } from '../icon/icon-size';
4+
5+
export type NavItemConfig = NavItemLinkConfig | NavItemHeaderConfig | NavItemDividerConfig;
6+
7+
export interface NavItemLinkConfig {
8+
type: NavItemType.Link;
9+
icon: string;
10+
iconSize?: IconSize;
11+
label: string;
12+
matchPaths: string[]; // For now, default path is index 0
13+
features?: string[];
14+
replaceCurrentHistory?: boolean;
15+
isBeta?: boolean;
16+
trailingIcon?: string;
17+
trailingIconTooltip?: string;
18+
trailingIconColor?: Color;
19+
featureState$?: Observable<FeatureState>;
20+
}
21+
22+
export type FooterItemConfig = FooterItemLinkConfig;
23+
24+
export interface FooterItemLinkConfig {
25+
url: string;
26+
label: string;
27+
icon: string;
28+
}
29+
30+
export interface NavItemHeaderConfig {
31+
type: NavItemType.Header;
32+
label: string;
33+
isBeta?: boolean;
34+
isVisible$?: Observable<boolean>;
35+
}
36+
37+
export interface NavItemDividerConfig {
38+
type: NavItemType.Divider;
39+
}
40+
41+
// Must be exported to be used by AOT compiler in template
42+
export const enum NavItemType {
43+
Header = 'header',
44+
Link = 'link',
45+
Divider = 'divider',
46+
Footer = 'footer'
47+
}

projects/components/src/public-api.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,8 @@ export { LayoutChangeModule } from './layout/layout-change.module';
154154
export * from './navigation/navigation-list.component';
155155
export * from './navigation/navigation-list.module';
156156
export * from './navigation/nav-item/nav-item.component';
157+
export * from './navigation/navigation.config';
158+
export * from './navigation/navigation-list-component.service';
157159

158160
// Let async
159161
export { LetAsyncDirective } from './let-async/let-async.directive';

0 commit comments

Comments
 (0)