diff --git a/projects/components/src/navigation/navigation-list-component.service.test.ts b/projects/components/src/navigation/navigation-list-component.service.test.ts
new file mode 100644
index 000000000..11929127c
--- /dev/null
+++ b/projects/components/src/navigation/navigation-list-component.service.test.ts
@@ -0,0 +1,56 @@
+import { FeatureState, FeatureStateResolver } from '@hypertrace/common';
+import { NavItemConfig, NavItemType } from '@hypertrace/components';
+import { runFakeRxjs } from '@hypertrace/test-utils';
+import { createServiceFactory, mockProvider } from '@ngneat/spectator/jest';
+import { of } from 'rxjs';
+import { NavigationListComponentService } from './navigation-list-component.service';
+import { NavItemHeaderConfig } from './navigation.config';
+
+describe('Navigation List Component Service', () => {
+ const navItems: 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 createService = createServiceFactory({
+ service: NavigationListComponentService,
+ providers: [
+ mockProvider(FeatureStateResolver, {
+ getCombinedFeatureState: jest.fn().mockReturnValue(of(FeatureState.Enabled))
+ })
+ ]
+ });
+
+ test('should return correct visibility for both headers', () => {
+ const spectator = createService();
+ const resolvedItems = spectator.service.resolveFeaturesAndUpdateVisibilityForNavItems(navItems);
+
+ runFakeRxjs(({ expectObservable }) => {
+ expectObservable((resolvedItems[0] as NavItemHeaderConfig).isVisible$!).toBe('(x|)', {
+ x: true
+ });
+ expectObservable((resolvedItems[3] as NavItemHeaderConfig).isVisible$!).toBe('(x|)', {
+ x: false
+ });
+ });
+ });
+});
diff --git a/projects/components/src/navigation/navigation-list-component.service.ts b/projects/components/src/navigation/navigation-list-component.service.ts
new file mode 100644
index 000000000..92c5e402d
--- /dev/null
+++ b/projects/components/src/navigation/navigation-list-component.service.ts
@@ -0,0 +1,49 @@
+import { Injectable } from '@angular/core';
+import { FeatureState, FeatureStateResolver } from '@hypertrace/common';
+import { isEmpty } from 'lodash-es';
+import { combineLatest, Observable, of } from 'rxjs';
+import { map } from 'rxjs/operators';
+import { NavItemConfig, NavItemHeaderConfig, NavItemLinkConfig, NavItemType } from './navigation.config';
+
+@Injectable({ providedIn: 'root' })
+export class NavigationListComponentService {
+ public constructor(private readonly featureStateResolver: FeatureStateResolver) {}
+
+ public resolveFeaturesAndUpdateVisibilityForNavItems(navItems: NavItemConfig[]): NavItemConfig[] {
+ const updatedItems = this.updateLinkNavItemsVisibility(navItems);
+ let linkItemsForThisSection: NavItemLinkConfig[] = [];
+ for (let i = updatedItems.length - 1; i >= 0; i--) {
+ if (updatedItems[i].type === NavItemType.Header) {
+ (updatedItems[i] as NavItemHeaderConfig).isVisible$ = this.updateHeaderNavItemsVisibility(
+ linkItemsForThisSection
+ );
+ linkItemsForThisSection = [];
+ } else if (updatedItems[i].type === NavItemType.Link) {
+ linkItemsForThisSection.push(updatedItems[i] as NavItemLinkConfig);
+ }
+ }
+
+ return updatedItems;
+ }
+
+ private updateHeaderNavItemsVisibility(navItems: NavItemLinkConfig[]): Observable
{
+ return isEmpty(navItems)
+ ? of(false)
+ : combineLatest(navItems.map(navItem => navItem.featureState$!)).pipe(
+ map(states => states.some(state => state !== FeatureState.Disabled))
+ );
+ }
+
+ private updateLinkNavItemsVisibility(navItems: NavItemConfig[]): NavItemConfig[] {
+ return navItems.map(navItem => {
+ if (navItem.type === NavItemType.Link) {
+ return {
+ ...navItem,
+ featureState$: this.featureStateResolver.getCombinedFeatureState(navItem.features ?? [])
+ };
+ }
+
+ return navItem;
+ });
+ }
+}
diff --git a/projects/components/src/navigation/navigation-list.component.test.ts b/projects/components/src/navigation/navigation-list.component.test.ts
index b29e3800d..9d7d2880c 100644
--- a/projects/components/src/navigation/navigation-list.component.test.ts
+++ b/projects/components/src/navigation/navigation-list.component.test.ts
@@ -8,7 +8,9 @@ import { IconComponent } from '../icon/icon.component';
import { LetAsyncModule } from '../let-async/let-async.module';
import { LinkComponent } from './../link/link.component';
import { NavItemComponent } from './nav-item/nav-item.component';
-import { FooterItemConfig, NavigationListComponent, NavItemConfig, NavItemType } from './navigation-list.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 = {
@@ -21,6 +23,13 @@ describe('Navigation List Component', () => {
imports: [LetAsyncModule, MemoizeModule],
providers: [
mockProvider(ActivatedRoute, activatedRoute),
+ mockProvider(NavigationListComponentService, {
+ resolveFeaturesAndUpdateVisibilityForNavItems: jest
+ .fn()
+ .mockImplementation((navItems: NavItemConfig[]) =>
+ navItems.map(item => (item.type !== NavItemType.Header ? item : { ...item, isVisible$: of(true) }))
+ )
+ }),
mockProvider(NavigationService, {
navigation$: EMPTY,
navigateWithinApp: jest.fn(),
@@ -78,4 +87,42 @@ describe('Navigation List Component', () => {
expect(spectator.query('.navigation-list')).not.toHaveClass('expanded');
expect(spectator.query(IconComponent)?.icon).toEqual(IconType.TriangleRight);
});
+
+ test('should only show one header 1', () => {
+ const navItems: NavItemConfig[] = [
+ {
+ type: NavItemType.Header,
+ label: 'header 1',
+ isVisible$: of(true)
+ },
+ {
+ type: NavItemType.Link,
+ icon: 'icon',
+ label: 'label-2',
+ matchPaths: ['']
+ },
+ {
+ type: NavItemType.Header,
+ label: 'header 2',
+ isVisible$: of(false)
+ }
+ ];
+
+ spectator = createHost(``, {
+ hostProps: { navItems: navItems },
+ providers: [
+ mockProvider(ActivatedRoute, activatedRoute),
+ mockProvider(NavigationListComponentService, {
+ resolveFeaturesAndUpdateVisibilityForNavItems: jest.fn().mockReturnValue(navItems)
+ }),
+ mockProvider(NavigationService, {
+ navigation$: EMPTY,
+ navigateWithinApp: jest.fn(),
+ getCurrentActivatedRoute: jest.fn().mockReturnValue(of(activatedRoute))
+ })
+ ]
+ });
+ expect(spectator.queryAll('.nav-header')).toHaveLength(1);
+ expect(spectator.queryAll('.nav-header .label')[0]).toHaveText('header 1');
+ });
});
diff --git a/projects/components/src/navigation/navigation-list.component.ts b/projects/components/src/navigation/navigation-list.component.ts
index d68af073f..925e6819c 100644
--- a/projects/components/src/navigation/navigation-list.component.ts
+++ b/projects/components/src/navigation/navigation-list.component.ts
@@ -1,10 +1,12 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { IconType } from '@hypertrace/assets-library';
-import { Color, NavigationService } from '@hypertrace/common';
+import { NavigationService } from '@hypertrace/common';
import { Observable } from 'rxjs';
import { map, startWith } from 'rxjs/operators';
import { IconSize } from '../icon/icon-size';
+import { NavigationListComponentService } from './navigation-list-component.service';
+import { FooterItemConfig, NavItemConfig, NavItemLinkConfig, NavItemType } from './navigation.config';
@Component({
selector: 'ht-navigation-list',
@@ -13,13 +15,15 @@ import { IconSize } from '../icon/icon-size';
template: `