This is an automated email from the ASF dual-hosted git repository.

ocket8888 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/trafficcontrol.git


The following commit(s) were added to refs/heads/master by this push:
     new 957085ec11 Add Ability to Extend TPv2 (#6914)
957085ec11 is described below

commit 957085ec11b9c1c51cb02af4db3f453f2f00a389
Author: Steve Hamrick <[email protected]>
AuthorDate: Mon Jul 18 13:40:11 2022 -0600

    Add Ability to Extend TPv2 (#6914)
    
    * Update to angular 14
    
    * Add custom module
    
    * s/ancor/anchor
    
    * DRY
    
    * Code review changes
    
    * Code review changes
    
    * Update ag-grid
    
    * Fix test
    
    * Fix test
    
    * Fix merge
    
    * Add tenant button
---
 experimental/traffic-portal/README.md              |   7 +
 experimental/traffic-portal/package-lock.json      |  30 ++--
 experimental/traffic-portal/package.json           |   4 +-
 .../traffic-portal/src/app/app-routing.module.ts   |  16 +-
 .../traffic-portal/src/app/core/core.module.ts     |   4 +-
 .../server-details.component.spec.ts               |  11 +-
 .../traffic-portal/src/app/custom/README.md        |  22 +++
 .../traffic-portal/src/app/custom/custom.module.ts |  44 ++++++
 .../generic-table/generic-table.component.html     |   3 +-
 .../generic-table/generic-table.component.ts       |  14 +-
 .../boolean-filter.component.spec.ts               |  16 +-
 .../boolean-filter/boolean-filter.component.ts     |  18 ++-
 .../app/shared/tp-header/tp-header.component.html  |  32 ++--
 .../shared/tp-header/tp-header.component.spec.ts   |  26 +---
 .../app/shared/tp-header/tp-header.component.ts    |  60 +++++---
 .../app/shared/tp-header/tp-header.service.spec.ts |  40 ++++-
 .../src/app/shared/tp-header/tp-header.service.ts  | 161 ++++++++++++++++++++-
 .../src/environments/environment.prod.ts           |   1 +
 .../traffic-portal/src/environments/environment.ts |  12 +-
 experimental/traffic-portal/tsconfig.json          |   2 +-
 20 files changed, 414 insertions(+), 109 deletions(-)

diff --git a/experimental/traffic-portal/README.md 
b/experimental/traffic-portal/README.md
index 98ee864a97..d89a3be855 100644
--- a/experimental/traffic-portal/README.md
+++ b/experimental/traffic-portal/README.md
@@ -164,6 +164,13 @@ installed prior to running the tests.
 End-to-end testing uses NightwatchJS and can be run by using `ng e2e`. More
 detailed instructions can be found in the `nightwatch/` folder
 
+## Extending Traffic Portal
+Traffic Portal supports extending functionality through the use of Angular 
modules. 
+The `Custom` module (located at `src/app/custom/`) contains the code to do so 
and any additional
+functionality should be added here as you would to any other Angular module. 
By default,
+this module is not built or included in the main bundle, to enable this modify 
the environment 
+(`src/environments`) variable `customModule` to true. 
+
 ## Contributing
 This project uses `eslint` and an `.editorconfig` file for maintaining code
 style. If your editor doesn't support `.editorconfig` (VS Code does
diff --git a/experimental/traffic-portal/package-lock.json 
b/experimental/traffic-portal/package-lock.json
index a378244206..c54f035f74 100644
--- a/experimental/traffic-portal/package-lock.json
+++ b/experimental/traffic-portal/package-lock.json
@@ -25,8 +25,8 @@
         "@fortawesome/free-regular-svg-icons": "^6.0.0",
         "@fortawesome/free-solid-svg-icons": "^6.0.0",
         "@nguniversal/express-engine": "^14.0.1",
-        "ag-grid-angular": "^26.2.0",
-        "ag-grid-community": "^26.2.0",
+        "ag-grid-angular": "^27.3.0",
+        "ag-grid-community": "^27.3.0",
         "argparse": "^2.0.1",
         "chart.js": "^2.9.4",
         "express": "^4.15.2",
@@ -5219,16 +5219,16 @@
       }
     },
     "node_modules/ag-grid-angular": {
-      "version": "26.2.0",
-      "resolved": 
"https://registry.npmjs.org/ag-grid-angular/-/ag-grid-angular-26.2.0.tgz";,
-      "integrity": 
"sha512-IJYNniJkQXQhEMdsZ50MFMY80K3PQGsh4Jh1Nu7G1Det5Pq2QNPgZ/FwNucsxYDPn32VICVVlUEQTwtEl63FZQ==",
+      "version": "27.3.0",
+      "resolved": 
"https://registry.npmjs.org/ag-grid-angular/-/ag-grid-angular-27.3.0.tgz";,
+      "integrity": 
"sha512-OWONQN3N0x17j4WJOAeHhJ+FGSQzjsbtKkUZFGdhW7SkwhKENJBhGHyrEEuaS+jDoj3GarJooWDNjvirpimUUA==",
       "dependencies": {
         "tslib": "^1.10.0"
       },
       "peerDependencies": {
         "@angular/common": ">= 8.0.0",
         "@angular/core": ">= 8.0.0",
-        "ag-grid-community": "~26.2.0"
+        "ag-grid-community": "~27.3.0"
       }
     },
     "node_modules/ag-grid-angular/node_modules/tslib": {
@@ -5237,9 +5237,9 @@
       "integrity": 
"sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="
     },
     "node_modules/ag-grid-community": {
-      "version": "26.2.1",
-      "resolved": 
"https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-26.2.1.tgz";,
-      "integrity": 
"sha512-aChSGNdPkBda4BhOUUEAmAkRlIG7rFU8UTXx3NPStavrCOHKLDRV90djIKuiXfM6ONBqKmeqw2as0yuLnSN8dw=="
+      "version": "27.3.0",
+      "resolved": 
"https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-27.3.0.tgz";,
+      "integrity": 
"sha512-R5oZMXEHXnOLrmhn91J8lR0bv6IAnRcU6maO+wKLMJxffRWaAYFAuw1jt7bdmcKCv8c65F6LEBx4ykSOALa9vA=="
     },
     "node_modules/agent-base": {
       "version": "6.0.2",
@@ -22639,9 +22639,9 @@
       "dev": true
     },
     "ag-grid-angular": {
-      "version": "26.2.0",
-      "resolved": 
"https://registry.npmjs.org/ag-grid-angular/-/ag-grid-angular-26.2.0.tgz";,
-      "integrity": 
"sha512-IJYNniJkQXQhEMdsZ50MFMY80K3PQGsh4Jh1Nu7G1Det5Pq2QNPgZ/FwNucsxYDPn32VICVVlUEQTwtEl63FZQ==",
+      "version": "27.3.0",
+      "resolved": 
"https://registry.npmjs.org/ag-grid-angular/-/ag-grid-angular-27.3.0.tgz";,
+      "integrity": 
"sha512-OWONQN3N0x17j4WJOAeHhJ+FGSQzjsbtKkUZFGdhW7SkwhKENJBhGHyrEEuaS+jDoj3GarJooWDNjvirpimUUA==",
       "requires": {
         "tslib": "^1.10.0"
       },
@@ -22654,9 +22654,9 @@
       }
     },
     "ag-grid-community": {
-      "version": "26.2.1",
-      "resolved": 
"https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-26.2.1.tgz";,
-      "integrity": 
"sha512-aChSGNdPkBda4BhOUUEAmAkRlIG7rFU8UTXx3NPStavrCOHKLDRV90djIKuiXfM6ONBqKmeqw2as0yuLnSN8dw=="
+      "version": "27.3.0",
+      "resolved": 
"https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-27.3.0.tgz";,
+      "integrity": 
"sha512-R5oZMXEHXnOLrmhn91J8lR0bv6IAnRcU6maO+wKLMJxffRWaAYFAuw1jt7bdmcKCv8c65F6LEBx4ykSOALa9vA=="
     },
     "agent-base": {
       "version": "6.0.2",
diff --git a/experimental/traffic-portal/package.json 
b/experimental/traffic-portal/package.json
index a4f50e658b..5a26196c4a 100644
--- a/experimental/traffic-portal/package.json
+++ b/experimental/traffic-portal/package.json
@@ -65,8 +65,8 @@
     "@fortawesome/free-regular-svg-icons": "^6.0.0",
     "@fortawesome/free-solid-svg-icons": "^6.0.0",
     "@nguniversal/express-engine": "^14.0.1",
-    "ag-grid-angular": "^26.2.0",
-    "ag-grid-community": "^26.2.0",
+    "ag-grid-angular": "^27.3.0",
+    "ag-grid-community": "^27.3.0",
     "argparse": "^2.0.1",
     "chart.js": "^2.9.4",
     "express": "^4.15.2",
diff --git a/experimental/traffic-portal/src/app/app-routing.module.ts 
b/experimental/traffic-portal/src/app/app-routing.module.ts
index 0d081d9fa2..1e85f59766 100644
--- a/experimental/traffic-portal/src/app/app-routing.module.ts
+++ b/experimental/traffic-portal/src/app/app-routing.module.ts
@@ -14,7 +14,10 @@
 import { NgModule, type Type } from "@angular/core";
 import { RouterModule, Routes } from "@angular/router";
 
-import type { CoreModule } from "./core/core.module";
+import { type CoreModule } from "src/app/core/core.module";
+import { type CustomModule } from "src/app/custom/custom.module";
+import { environment } from "src/environments/environment";
+
 import { AuthenticatedGuard } from "./guards/authenticated-guard.service";
 import { LoginComponent } from "./login/login.component";
 
@@ -32,6 +35,17 @@ const routes: Routes = [
        {path: "", pathMatch: "full", redirectTo: "login"}
 ];
 
+if (environment.customModule) {
+       routes.push({
+               children: [{
+                       loadChildren: async (): Promise<Type<CustomModule>> =>
+                               import("./custom/custom.module").then(mod => 
mod.CustomModule),
+                       path: ""
+               }],
+               path: "custom"
+       });
+}
+
 /**
  * AppRoutingModule provides routing configuration for the app.
  */
diff --git a/experimental/traffic-portal/src/app/core/core.module.ts 
b/experimental/traffic-portal/src/app/core/core.module.ts
index ed858d037f..eece0bace3 100644
--- a/experimental/traffic-portal/src/app/core/core.module.ts
+++ b/experimental/traffic-portal/src/app/core/core.module.ts
@@ -40,7 +40,7 @@ import { TenantsComponent } from 
"./users/tenants/tenants.component";
 import { UserDetailsComponent } from 
"./users/user-details/user-details.component";
 import { UsersComponent } from "./users/users.component";
 
-const routes: Routes = [
+export const ROUTES: Routes = [
        { canActivate: [AuthenticatedGuard], component: DashboardComponent, 
path: "" },
        { canActivate: [AuthenticatedGuard], component: UsersComponent, path: 
"users" },
        { canActivate: [AuthenticatedGuard], component: UserDetailsComponent, 
path: "users/:id"},
@@ -81,7 +81,7 @@ const routes: Routes = [
                SharedModule,
                AppUIModule,
                CommonModule,
-               RouterModule.forChild(routes)
+               RouterModule.forChild(ROUTES)
        ]
 })
 export class CoreModule { }
diff --git 
a/experimental/traffic-portal/src/app/core/servers/server-details/server-details.component.spec.ts
 
b/experimental/traffic-portal/src/app/core/servers/server-details/server-details.component.spec.ts
index 73bb4f07c5..1dd80876fa 100644
--- 
a/experimental/traffic-portal/src/app/core/servers/server-details/server-details.component.spec.ts
+++ 
b/experimental/traffic-portal/src/app/core/servers/server-details/server-details.component.spec.ts
@@ -21,10 +21,12 @@ import { MatSelectModule } from "@angular/material/select";
 import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
 import { RouterTestingModule } from "@angular/router/testing";
 import { faToggleOff, faToggleOn } from "@fortawesome/free-solid-svg-icons";
+import { of } from "rxjs";
 
 import { ServerService } from "src/app/api";
 import { APITestingModule } from "src/app/api/testing";
 import { defaultServer } from "src/app/models";
+import { CurrentUserService } from 
"src/app/shared/currentUser/current-user.service";
 
 import { ServerDetailsComponent } from "./server-details.component";
 
@@ -33,8 +35,10 @@ describe("ServerDetailsComponent", () => {
        let fixture: ComponentFixture<ServerDetailsComponent>;
 
        beforeEach(async () => {
+               const mockCurrentUserService = jasmine.createSpyObj(
+                       ["updateCurrentUser", "hasPermission", "login", 
"logout"], {userChanged: of(null)});
                await TestBed.configureTestingModule({
-                       declarations: [ ServerDetailsComponent ],
+                       declarations: [ServerDetailsComponent],
                        imports: [
                                HttpClientModule,
                                RouterTestingModule.withRoutes([
@@ -49,6 +53,9 @@ describe("ServerDetailsComponent", () => {
                                BrowserAnimationsModule,
                                APITestingModule
                        ],
+                       providers: [
+                               {provide: CurrentUserService, useValue: 
mockCurrentUserService},
+                       ]
                }).compileComponents();
                fixture = TestBed.createComponent(ServerDetailsComponent);
                const service = TestBed.inject(ServerService);
@@ -132,7 +139,7 @@ describe("ServerDetailsComponent", () => {
                component.changeStatus(new MouseEvent("click"));
                expect(component.changeStatusDialogOpen).toBeTrue();
                component.isNew = true;
-               expect(()=>component.changeStatus(new 
MouseEvent("click"))).toThrow();
+               expect(() => component.changeStatus(new 
MouseEvent("click"))).toThrow();
        });
 
        it("closes the 'change status' dialog when done", () => {
diff --git a/experimental/traffic-portal/src/app/custom/README.md 
b/experimental/traffic-portal/src/app/custom/README.md
new file mode 100644
index 0000000000..87fa2f5fdf
--- /dev/null
+++ b/experimental/traffic-portal/src/app/custom/README.md
@@ -0,0 +1,22 @@
+<!--
+    Licensed to the Apache Software Foundation (ASF) under one
+    or more contributor license agreements.  See the NOTICE file
+    distributed with this work for additional information
+    regarding copyright ownership.  The ASF licenses this file
+    to you under the Apache License, Version 2.0 (the
+    "License"); you may not use this file except in compliance
+    with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+    Unless required by applicable law or agreed to in writing,
+    software distributed under the License is distributed on an
+    "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+    KIND, either express or implied.  See the License for the
+    specific language governing permissions and limitations
+    under the License.
+-->
+
+# Custom Module
+This contains the code necessary to extend Traffic Portal with new/replaced 
functionality. 
+See `traffic_control/experimental/traffic-portal/README.md` for more 
information.
diff --git a/experimental/traffic-portal/src/app/custom/custom.module.ts 
b/experimental/traffic-portal/src/app/custom/custom.module.ts
new file mode 100644
index 0000000000..2e4c333aef
--- /dev/null
+++ b/experimental/traffic-portal/src/app/custom/custom.module.ts
@@ -0,0 +1,44 @@
+/**
+ * @module src/app/core
+ * The "Core" module consists of all TP functionality and components that 
aren't
+ * needed/useful until the user is authenticated.
+ *
+ * @license Apache-2.0
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { CommonModule } from "@angular/common";
+import { NgModule } from "@angular/core";
+import { RouterModule, type Routes } from "@angular/router";
+
+import { AppUIModule } from "../app.ui.module";
+import { SharedModule } from "../shared/shared.module";
+
+const ROUTES: Routes = [
+];
+
+/**
+ * Custom module contains code used for adding new non-OS TPv2 features.
+ */
+@NgModule({
+       declarations: [
+       ],
+       exports: [
+       ],
+       imports: [
+               SharedModule,
+               AppUIModule,
+               CommonModule,
+               RouterModule.forChild(ROUTES)
+       ]
+})
+export class CustomModule { }
diff --git 
a/experimental/traffic-portal/src/app/shared/generic-table/generic-table.component.html
 
b/experimental/traffic-portal/src/app/shared/generic-table/generic-table.component.html
index 4fe739bd55..4d46b194c4 100644
--- 
a/experimental/traffic-portal/src/app/shared/generic-table/generic-table.component.html
+++ 
b/experimental/traffic-portal/src/app/shared/generic-table/generic-table.component.html
@@ -36,7 +36,8 @@ limitations under the License.
        [frameworkComponents]="components"
        [gridOptions]="gridOptions"
        (gridReady)="setAPI($event)"
-       (sortChanged)="storeSort()"
+       (filterChanged)="storeFilter()"
+       (sortChanged)="storeColumns()"
        (columnMoved)="storeColumns()"
        (columnVisible)="storeColumns(true)"
        (cellContextMenu)="onCellContextMenu($event)"
diff --git 
a/experimental/traffic-portal/src/app/shared/generic-table/generic-table.component.ts
 
b/experimental/traffic-portal/src/app/shared/generic-table/generic-table.component.ts
index aec0e73349..b586ec2714 100644
--- 
a/experimental/traffic-portal/src/app/shared/generic-table/generic-table.component.ts
+++ 
b/experimental/traffic-portal/src/app/shared/generic-table/generic-table.component.ts
@@ -328,19 +328,19 @@ export class GenericTableComponent<T> implements OnInit, 
OnDestroy {
                }
 
                try {
-                       const storedSort = 
localStorage.getItem(`${this.context}_table_sort`);
-                       if (storedSort) {
-                               
this.gridAPI.setSortModel(JSON.parse(storedSort));
+                       const filterState = 
localStorage.getItem(`${this.context}_table_filter`);
+                       if (filterState) {
+                               
this.gridAPI.setFilterModel(JSON.parse(filterState));
                        }
                } catch (e) {
-                       console.error("Failure to load stored sort state:", e);
+                       console.error(`Failure to retrieve stored column sort 
info from localStorage (key=${this.context}_table_filter:`, e);
                }
        }
 
-       /** When sorting changes, stores the sorting state if a context was 
provided. */
-       public storeSort(): void {
+       /** When filter changes, stores the filter state if a context was 
provided. */
+       public storeFilter(): void {
                if (this.context && this.gridAPI) {
-                       localStorage.setItem(`${this.context}_table_sort`, 
JSON.stringify(this.gridAPI.getSortModel()));
+                       localStorage.setItem(`${this.context}_table_filter`, 
JSON.stringify(this.gridAPI.getFilterModel()));
                }
        }
 
diff --git 
a/experimental/traffic-portal/src/app/shared/table-components/boolean-filter/boolean-filter.component.spec.ts
 
b/experimental/traffic-portal/src/app/shared/table-components/boolean-filter/boolean-filter.component.spec.ts
index 701b9cda79..c8f3b2a7d1 100644
--- 
a/experimental/traffic-portal/src/app/shared/table-components/boolean-filter/boolean-filter.component.spec.ts
+++ 
b/experimental/traffic-portal/src/app/shared/table-components/boolean-filter/boolean-filter.component.spec.ts
@@ -34,8 +34,11 @@ describe("BooleanFilterComponent", () => {
                component = fixture.componentInstance;
                fixture.detectChanges();
                component.agInit({
+                       colDef: {
+                               field: "test"
+                       },
                        filterChangedCallback,
-                       valueGetter: (n: RowNode): boolean => n.data,
+                       valueGetter: (n: RowNode): boolean => n.data
                } as unknown as IFilterParams);
        });
 
@@ -78,14 +81,17 @@ describe("BooleanFilterComponent", () => {
 
        it("knows if a filter passes", () => {
                const node = {data: false} as RowNode;
+               const data = {test: false};
                component.onChange(true, "should");
                expect(component.isFilterActive()).toBeTrue();
-               expect(component.doesFilterPass({data: null, node})).toBeTrue();
+               expect(component.doesFilterPass({data, node})).toBeTrue();
                node.data = true;
-               expect(component.doesFilterPass({data: null, 
node})).toBeFalse();
+               data.test = true;
+               expect(component.doesFilterPass({data, node})).toBeFalse();
                component.onChange(true, "value");
-               expect(component.doesFilterPass({data: null, node})).toBeTrue();
+               expect(component.doesFilterPass({data, node})).toBeTrue();
                node.data = false;
-               expect(component.doesFilterPass({data: null, 
node})).toBeFalse();
+               data.test = false;
+               expect(component.doesFilterPass({data, node})).toBeFalse();
        });
 });
diff --git 
a/experimental/traffic-portal/src/app/shared/table-components/boolean-filter/boolean-filter.component.ts
 
b/experimental/traffic-portal/src/app/shared/table-components/boolean-filter/boolean-filter.component.ts
index bbb6a536e7..f025709c37 100644
--- 
a/experimental/traffic-portal/src/app/shared/table-components/boolean-filter/boolean-filter.component.ts
+++ 
b/experimental/traffic-portal/src/app/shared/table-components/boolean-filter/boolean-filter.component.ts
@@ -39,6 +39,9 @@ export class BooleanFilterComponent implements 
AgFilterComponent {
        /** Describes which boolean value to match (if filtering is performed). 
*/
        public value = false;
 
+       /** Stores the column name */
+       private field = "";
+
        /** Initialization parameters. */
        private params!: IFilterParams;
 
@@ -58,7 +61,15 @@ export class BooleanFilterComponent implements 
AgFilterComponent {
         * @returns 'true' if the row matches the filter state, 'false' if it 
should be filtered out.
         */
        public doesFilterPass(params: IDoesFilterPassParams): boolean {
-               return this.params.valueGetter(params.node) === this.value;
+               if (!params.node) {
+                       return false;
+               }
+
+               let colValue = params.data[this.field];
+               if (colValue === undefined) {
+                       colValue = false;
+               }
+               return colValue === this.value;
        }
 
        /**
@@ -112,6 +123,11 @@ export class BooleanFilterComponent implements 
AgFilterComponent {
         */
        public agInit(params: IFilterParams): void {
                this.params = params;
+               if (!params.colDef.field) {
+                       console.error("No column name found on boolean-filter 
parameters");
+                       return;
+               }
+               this.field = params.colDef.field;
        }
 
 }
diff --git 
a/experimental/traffic-portal/src/app/shared/tp-header/tp-header.component.html 
b/experimental/traffic-portal/src/app/shared/tp-header/tp-header.component.html
index 34328717ee..e8befcd058 100644
--- 
a/experimental/traffic-portal/src/app/shared/tp-header/tp-header.component.html
+++ 
b/experimental/traffic-portal/src/app/shared/tp-header/tp-header.component.html
@@ -19,19 +19,20 @@ limitations under the License.
        <div></div>
        <nav id="expanded">
                <ul>
-                       <li><a mat-button routerLink="/core/">Home</a></li>
-                       <li *ngIf="hasPermission('USER:READ') || 
hasPermission('TENANT:READ')">
-                               <button mat-button type="button" 
[matMenuTriggerFor]="usersMenu">Users</button>
+                       <li *ngFor="let nav of horizNavs">
+                               <a *ngIf="navShown(nav, 'anchor')" mat-button 
[routerLink]="navRouterLink(nav)">{{nav.text}}</a>
+                               <button *ngIf="navShown(nav, 'button')" 
mat-button (click)="navClick(nav)">{{nav.text}}</button>
                        </li>
-                       <li *ngIf="hasPermission('SERVER:READ')"><a mat-button 
routerLink="/core/servers">Servers</a></li>
                        <li>
                                <button mat-icon-button 
[matMenuTriggerFor]="expandedMenu">
                                        <mat-icon>manage_accounts</mat-icon>
                                </button>
                                <mat-menu #expandedMenu="matMenu">
-                                       <a mat-menu-item 
routerLink="/core/me">Profile</a>
-                                       <button mat-menu-item 
(click)="logout()">Logout</button>
                                        <button mat-menu-item 
[matMenuTriggerFor]="themeMenu">Theme</button>
+                                       <div *ngFor="let nav of vertNavs">
+                                               <a *ngIf="navShown(nav, 
'anchor')" mat-menu-item [routerLink]="navRouterLink(nav)">{{nav.text}}</a>
+                                               <button *ngIf="navShown(nav, 
'button')" mat-menu-item (click)="navClick(nav)">{{nav.text}}</button>
+                                       </div>
                                </mat-menu>
                        </li>
                </ul>
@@ -39,21 +40,18 @@ limitations under the License.
        <nav id="collapsed">
                <button mat-icon-button 
[matMenuTriggerFor]="collapsedMenu"><mat-icon>menu</mat-icon></button>
                <mat-menu #collapsedMenu="matMenu">
-                       <a mat-menu-item routerLink="/core/">Home</a>
-                       <button type="button" 
*ngIf="hasPermission('USER:READ')||hasPermission('TENANT:READ')" mat-menu-item 
[matMenuTriggerFor]="usersMenu">Users</button>
-                       <a *ngIf="hasPermission('SERVER:READ')" mat-menu-item 
routerLink="/core/servers">Servers</a>
-                       <a mat-menu-item routerLink="/core/me">Profile</a>
-                       <button mat-menu-item (click)="logout()">Logout</button>
+                       <li *ngFor="let nav of horizNavs">
+                               <a *ngIf="navShown(nav, 'anchor')" 
mat-menu-item [routerLink]="navRouterLink(nav)">{{nav.text}}</a>
+                               <button *ngIf="navShown(nav, 'button')" 
mat-menu-item (click)="navClick(nav)">{{nav.text}}</button>
+                       </li>
                        <button mat-menu-item 
[matMenuTriggerFor]="themeMenu">Theme</button>
+                       <li *ngFor="let nav of vertNavs">
+                               <a *ngIf="navShown(nav, 'anchor')" 
mat-menu-item [routerLink]="navRouterLink(nav)">{{nav.text}}</a>
+                               <button *ngIf="navShown(nav, 'button')" 
mat-menu-item (click)="navClick(nav)">{{nav.text}}</button>
+                       </li>
                </mat-menu>
        </nav>
        <mat-menu #themeMenu="matMenu">
                <button mat-menu-item *ngFor="let theme of themeSvc.themes" 
(click)="themeSvc.loadTheme(theme)">{{theme.name}}</button>
        </mat-menu>
-       <mat-menu #usersMenu="matMenu">
-               <a mat-menu-item routerLink="/core/users" 
*ngIf="hasPermission('USER:READ')">View Users</a>
-               <button disabled mat-button type="button" 
*ngIf="!hasPermission('USER:READ')">View Users</button>
-               <a mat-menu-item routerLink="/core/tenants" 
*ngIf="hasPermission('TENANT:READ')">View Tenants</a>
-               <button disabled mat-button type="button" 
*ngIf="!hasPermission('TENANT:READ')">View Tenants</button>
-       </mat-menu>
 </mat-toolbar>
diff --git 
a/experimental/traffic-portal/src/app/shared/tp-header/tp-header.component.spec.ts
 
b/experimental/traffic-portal/src/app/shared/tp-header/tp-header.component.spec.ts
index 8edfd007bb..fa19af20cc 100644
--- 
a/experimental/traffic-portal/src/app/shared/tp-header/tp-header.component.spec.ts
+++ 
b/experimental/traffic-portal/src/app/shared/tp-header/tp-header.component.spec.ts
@@ -17,23 +17,23 @@ import {MatMenuModule} from "@angular/material/menu";
 import { RouterTestingModule } from "@angular/router/testing";
 import {of, ReplaySubject} from "rxjs";
 
-import { UserService } from "src/app/api";
 import { APITestingModule } from "src/app/api/testing";
 import { CurrentUserService } from 
"src/app/shared/currentUser/current-user.service";
-import {TpHeaderService} from "src/app/shared/tp-header/tp-header.service";
+import {HeaderNavigation, TpHeaderService} from 
"src/app/shared/tp-header/tp-header.service";
 
 import { TpHeaderComponent } from "./tp-header.component";
 
 describe("TpHeaderComponent", () => {
        let component: TpHeaderComponent;
        let fixture: ComponentFixture<TpHeaderComponent>;
-       let logOutSpy: jasmine.Spy;
 
        beforeEach(waitForAsync(() => {
                const mockCurrentUserService = jasmine.createSpyObj(
                        ["updateCurrentUser", "hasPermission", "login", 
"logout"], {userChanged: of(null)});
-               logOutSpy = mockCurrentUserService.logout;
-               const headerSvc = jasmine.createSpyObj([],{headerHidden: new 
ReplaySubject<boolean>(), headerTitle: new ReplaySubject<string>()});
+               const headerSvc = jasmine.createSpyObj(["addHorizontalNav", 
"addVerticalNav"],
+                       {headerHidden: new ReplaySubject<boolean>(), 
headerTitle: new ReplaySubject<string>(),
+                               horizontalNavsUpdated: new 
ReplaySubject<Array<HeaderNavigation>>(),
+                               verticalNavsUpdated: new 
ReplaySubject<Array<HeaderNavigation>>()});
                TestBed.configureTestingModule({
                        declarations: [ TpHeaderComponent ],
                        imports: [ APITestingModule, HttpClientModule, 
RouterTestingModule, MatMenuModule ],
@@ -54,22 +54,6 @@ describe("TpHeaderComponent", () => {
                expect(component).toBeTruthy();
        });
 
-       it("logs the user out", async () => {
-               expect(logOutSpy).not.toHaveBeenCalled();
-               await component.logout();
-               expect(logOutSpy).toHaveBeenCalled();
-       });
-
-       it("clears front-end user data even if server-side logout fails", async 
() => {
-               const userService = TestBed.inject(UserService);
-               const userSpy = spyOn(userService, "logout");
-               userSpy.and.returnValue(new Promise(r=>r(null)));
-               expect(userSpy).not.toHaveBeenCalled();
-               await component.logout();
-               expect(userSpy).toHaveBeenCalled();
-               expect(logOutSpy).toHaveBeenCalled();
-       });
-
        afterAll(() => {
                try{
                        TestBed.resetTestingModule();
diff --git 
a/experimental/traffic-portal/src/app/shared/tp-header/tp-header.component.ts 
b/experimental/traffic-portal/src/app/shared/tp-header/tp-header.component.ts
index 399d81ea9b..bb5c5735d0 100644
--- 
a/experimental/traffic-portal/src/app/shared/tp-header/tp-header.component.ts
+++ 
b/experimental/traffic-portal/src/app/shared/tp-header/tp-header.component.ts
@@ -13,10 +13,8 @@
 */
 import {Component, OnInit} from "@angular/core";
 
-import { UserService } from "src/app/api";
-import { CurrentUserService } from 
"src/app/shared/currentUser/current-user.service";
 import {ThemeManagerService} from 
"src/app/shared/theme-manager/theme-manager.service";
-import {TpHeaderService} from "src/app/shared/tp-header/tp-header.service";
+import {HeaderNavigation, HeaderNavType, TpHeaderService} from 
"src/app/shared/tp-header/tp-header.service";
 
 /**
  * TpHeaderComponent is the controller for the standard Traffic Portal header.
@@ -37,6 +35,11 @@ export class TpHeaderComponent implements OnInit {
 
        public hidden = false;
 
+       // Will try to display each of these navs on the header, space allowing.
+       public horizNavs: Array<HeaderNavigation> = new 
Array<HeaderNavigation>();
+       // Navs that are not directly displayed on the header.
+       public vertNavs: Array<HeaderNavigation> = new 
Array<HeaderNavigation>();
+
        /**
         * Angular lifecycle hook
         */
@@ -44,35 +47,56 @@ export class TpHeaderComponent implements OnInit {
                this.headerSvc.headerTitle.subscribe(title => {
                        this.title = title;
                });
-
                this.headerSvc.headerHidden.subscribe(hidden => {
                        this.hidden = hidden;
                });
+               this.headerSvc.horizontalNavsUpdated.subscribe(navs => {
+                       this.horizNavs = navs;
+               });
+               this.headerSvc.verticalNavsUpdated.subscribe(navs => {
+                       this.vertNavs = navs;
+               });
        }
 
-       constructor(private readonly auth: CurrentUserService, private readonly 
api: UserService,
-               public readonly themeSvc: ThemeManagerService, private readonly 
headerSvc: TpHeaderService) {
+       constructor(public readonly themeSvc: ThemeManagerService, private 
readonly headerSvc: TpHeaderService) {
        }
 
        /**
-        * Checks for a Permission afforded to the currently authenticated user.
+        * Calls a navs click function, throws an error if null
         *
-        * @param perm The Permission for which to check.
-        * @returns Whether the currently authenticated user has the Permission
-        * `perm`.
+        * @param nav nav to process
         */
-       public hasPermission(perm: string): boolean {
-               return this.auth.hasPermission(perm);
+       public navClick(nav: HeaderNavigation): void {
+               if(nav.click === undefined) {
+                       throw new Error(`nav ${nav.text} does not have a click 
function`);
+               } else {
+                       nav?.click();
+               }
        }
 
        /**
-        * Handles when the user clicks the "Logout" button by using the API to
-        * invalidate their session before redirecting them to the login page.
+        * Gets a navs routerLink, logs an error if null
+        *
+        * @param nav nav to process
+        * @returns routerLink
         */
-       public async logout(): Promise<void> {
-               if (!(await this.api.logout())) {
-                       console.warn("Failed to log out - clearing user data 
anyway!");
+       public navRouterLink(nav: HeaderNavigation): string {
+               if(nav.routerLink === undefined) {
+                       console.error(`nav ${nav.text} does not have a 
routerLink`);
+                       return "";
                }
-               this.auth.logout();
+               return nav.routerLink;
+
+       }
+
+       /**
+        * Checks if a nav is shown
+        *
+        * @param nav nav to check
+        * @param type which type of nav to check for
+        * @returns If the nav should be rendered
+        */
+       public navShown(nav: HeaderNavigation, type: HeaderNavType): boolean {
+               return nav.type === type && (nav.visible === undefined || 
nav.visible());
        }
 }
diff --git 
a/experimental/traffic-portal/src/app/shared/tp-header/tp-header.service.spec.ts
 
b/experimental/traffic-portal/src/app/shared/tp-header/tp-header.service.spec.ts
index 987d0a4bf1..f4d0cfcc4d 100644
--- 
a/experimental/traffic-portal/src/app/shared/tp-header/tp-header.service.spec.ts
+++ 
b/experimental/traffic-portal/src/app/shared/tp-header/tp-header.service.spec.ts
@@ -11,22 +11,36 @@
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
+import { HttpClientModule } from "@angular/common/http";
 import { TestBed } from "@angular/core/testing";
+import { MatButtonModule } from "@angular/material/button";
+import { MatMenuModule } from "@angular/material/menu";
+import { RouterTestingModule } from "@angular/router/testing";
+import { of } from "rxjs";
 
-import {TpHeaderComponent} from "src/app/shared/tp-header/tp-header.component";
+import { UserService } from "src/app/api";
+import { APITestingModule } from "src/app/api/testing";
+import { CurrentUserService } from 
"src/app/shared/currentUser/current-user.service";
+import { type TpHeaderComponent } from 
"src/app/shared/tp-header/tp-header.component";
 
-import { TpHeaderService } from "./tp-header.service";
+import { HeaderNavigation, TpHeaderService } from "./tp-header.service";
 
 describe("TpHeaderService", () => {
        let service: TpHeaderService;
        let mockHeaderComp: jasmine.SpyObj<TpHeaderComponent>;
+       let logOutSpy: jasmine.Spy;
 
        beforeEach(() => {
+               const mockCurrentUserService = jasmine.createSpyObj(
+                       ["updateCurrentUser", "hasPermission", "login", 
"logout"], {userChanged: of(null)});
+               logOutSpy = mockCurrentUserService.logout;
                mockHeaderComp = jasmine.createSpyObj<TpHeaderComponent>([], 
{hidden: false, title: ""});
                TestBed.configureTestingModule({
+                       imports: [APITestingModule, HttpClientModule, 
RouterTestingModule, MatMenuModule, MatButtonModule],
                        providers: [
-                               TpHeaderService
-                       ]
+                               TpHeaderService,
+                               {provide: CurrentUserService, useValue: 
mockCurrentUserService},
+                       ],
                });
                service = TestBed.inject(TpHeaderService);
        });
@@ -35,6 +49,22 @@ describe("TpHeaderService", () => {
                expect(service).toBeTruthy();
        });
 
+       it("clears front-end user data even if server-side logout fails", async 
() => {
+               const userService = TestBed.inject(UserService);
+               const userSpy = spyOn(userService, "logout");
+               userSpy.and.returnValue(new Promise(r => r(null)));
+               expect(userSpy).not.toHaveBeenCalled();
+               await service.logout();
+               expect(userSpy).toHaveBeenCalled();
+               expect(logOutSpy).toHaveBeenCalled();
+       });
+
+       it("logs the user out", async () => {
+               expect(logOutSpy).not.toHaveBeenCalled();
+               await service.logout();
+               expect(logOutSpy).toHaveBeenCalled();
+       });
+
        it("set header component", () => {
                expect(mockHeaderComp).toBeTruthy();
                expect(mockHeaderComp?.hidden).toBeFalse();
@@ -42,5 +72,7 @@ describe("TpHeaderService", () => {
 
                service.headerHidden.next(true);
                service.headerTitle.next("something else");
+               service.horizontalNavsUpdated.next(new 
Array<HeaderNavigation>());
+               service.verticalNavsUpdated.next(new Array<HeaderNavigation>());
        });
 });
diff --git 
a/experimental/traffic-portal/src/app/shared/tp-header/tp-header.service.ts 
b/experimental/traffic-portal/src/app/shared/tp-header/tp-header.service.ts
index a464282f67..48c7a6e157 100644
--- a/experimental/traffic-portal/src/app/shared/tp-header/tp-header.service.ts
+++ b/experimental/traffic-portal/src/app/shared/tp-header/tp-header.service.ts
@@ -12,7 +12,27 @@
 * limitations under the License.
 */
 import { Injectable } from "@angular/core";
-import {ReplaySubject} from "rxjs";
+import { ReplaySubject } from "rxjs";
+
+import { UserService } from "src/app/api";
+
+import { CurrentUserService } from "../currentUser/current-user.service";
+
+/**
+ * Defines the type of the header nav
+ */
+export declare type HeaderNavType = "anchor" | "button";
+
+/**
+ * Specifies the setting for the nav
+ */
+export interface HeaderNavigation {
+       type: HeaderNavType;
+       visible?: () => boolean;
+       routerLink?: string;
+       click?: () => Promise<void>;
+       text: string;
+}
 
 /**
  *
@@ -23,10 +43,147 @@ import {ReplaySubject} from "rxjs";
 export class TpHeaderService {
        public readonly headerTitle: ReplaySubject<string>;
        public readonly headerHidden: ReplaySubject<boolean>;
+       public readonly horizontalNavsUpdated: 
ReplaySubject<Array<HeaderNavigation>>;
+       public readonly verticalNavsUpdated: 
ReplaySubject<Array<HeaderNavigation>>;
+
+       private readonly horizontalNavs: Map<string, HeaderNavigation>;
+       private readonly verticalNavs: Map<string, HeaderNavigation>;
 
-       constructor() {
+       constructor(private readonly auth: CurrentUserService, private readonly 
api: UserService) {
+               this.horizontalNavs = new Map<string, HeaderNavigation>([
+                       ["Home", {
+                               routerLink: "/core",
+                               text: "Home",
+                               type: "anchor",
+                       }],
+                       ["Users", {
+                               routerLink: "/core/users",
+                               text: "Users",
+                               type: "anchor",
+                               visible: (): boolean => 
this.hasPermission("USER:READ"),
+                       }],
+                       ["Servers", {
+                               routerLink: "/core/servers",
+                               text: "Servers",
+                               type: "anchor",
+                               visible: (): boolean => 
this.hasPermission("SERVER:READ"),
+                       }],
+               ]);
+               this.verticalNavs = new Map<string, HeaderNavigation>([
+                       ["Profile",
+                               {
+                                       routerLink: "/core/me",
+                                       text: "Profile",
+                                       type: "anchor"
+                               }],
+                       ["Tenants",
+                               {
+                                       routerLink: "/core/tenants",
+                                       text: "Tenants",
+                                       type: "anchor",
+                                       visible: (): boolean => 
this.hasPermission("TENANT:READ"),
+                               }],
+                       ["Logout",
+                               {
+                                       click: async (): Promise<void> => 
this.logout(),
+                                       text: "Logout",
+                                       type: "button"
+                               }],
+               ]);
+               this.horizontalNavsUpdated = new ReplaySubject(1);
+               this.verticalNavsUpdated = new ReplaySubject(1);
                this.headerTitle = new ReplaySubject(1);
                this.headerHidden = new ReplaySubject(1);
                this.headerHidden.next(false);
+               this.horizontalNavsUpdated.next(this.buildHorizontalNavs());
+               this.verticalNavsUpdated.next(this.buildVerticalNavs());
+       }
+
+       /**
+        * Builds the horizontal header navigation array for consumption.
+        *
+        * @returns Header Navs
+        */
+       private buildHorizontalNavs(): Array<HeaderNavigation> {
+               return Array.from(this.horizontalNavs.values());
+       }
+
+       /**
+        * Builds the vertical header navigation array for consumption.
+        *
+        * @returns Header Navs
+        */
+       private buildVerticalNavs(): Array<HeaderNavigation> {
+               return Array.from(this.verticalNavs.values());
+       }
+
+       /**
+        * Removes a nav from the list. Does not throw an exception if not 
exists
+        *
+        * @param key key to delete by
+        * @returns boolean indicating if a nav was deleted
+        */
+       public removeHorizontalNav(key: string): boolean {
+               return this.horizontalNavs.delete(key);
+       }
+
+       /**
+        * Removes a nav from the list. Does not throw an exception if not 
exists
+        *
+        * @param key key to delete by
+        * @returns boolean indicating if a nav was deleted
+        */
+       public removeVerticalNav(key: string): boolean {
+               return this.verticalNavs.delete(key);
+       }
+
+       /**
+        * Handles when the user clicks the "Logout" button by using the API to
+        * invalidate their session before redirecting them to the login page.
+        */
+       public async logout(): Promise<void> {
+               if (!(await this.api.logout())) {
+                       console.warn("Failed to log out - clearing user data 
anyway!");
+               }
+               this.auth.logout();
+       }
+
+       /**
+        * Checks for a Permission afforded to the currently authenticated user.
+        *
+        * @param perm The Permission for which to check.
+        * @returns Whether the currently authenticated user has the Permission
+        * `perm`.
+        */
+       public hasPermission(perm: string): boolean {
+               return this.auth.hasPermission(perm);
+       }
+
+       /**
+        * Adds to the horizontal nav list
+        *
+        * @param hn nav element to add
+        * @param key key to use for determining uniqueness
+        * @returns boolean indicating if a nav was replaced.
+        */
+       public addHorizontalNav(hn: HeaderNavigation, key: string): boolean {
+               const present = this.horizontalNavs.has(key);
+               this.horizontalNavs.set(key, hn);
+               this.horizontalNavsUpdated.next(this.buildHorizontalNavs());
+               return present;
+       }
+
+       /**
+        * Adds to the vertical nav list
+        *
+        * @param hn nav element to add
+        * @param key key to use for determining uniqueness
+        * @returns boolean indicating if a nav was replaced.
+        */
+       public addVerticalNav(hn: HeaderNavigation, key: string): boolean {
+               const present = this.verticalNavs.has(key);
+               this.verticalNavs.set(key, hn);
+               this.verticalNavsUpdated.next(this.buildVerticalNavs());
+               return present;
        }
 }
diff --git a/experimental/traffic-portal/src/environments/environment.prod.ts 
b/experimental/traffic-portal/src/environments/environment.prod.ts
index c7bfb07f07..625ea76b8c 100644
--- a/experimental/traffic-portal/src/environments/environment.prod.ts
+++ b/experimental/traffic-portal/src/environments/environment.prod.ts
@@ -18,5 +18,6 @@
  */
 export const environment = {
        apiVersion: "3.0",
+       customModule: false,
        production: true
 };
diff --git a/experimental/traffic-portal/src/environments/environment.ts 
b/experimental/traffic-portal/src/environments/environment.ts
index 5be1a4d253..535acb2907 100644
--- a/experimental/traffic-portal/src/environments/environment.ts
+++ b/experimental/traffic-portal/src/environments/environment.ts
@@ -22,14 +22,6 @@
  */
 export const environment = {
        apiVersion: "3.0",
-       production: false
+       customModule: false,
+       production: false,
 };
-
-/*
- * For easier debugging in development mode, you can import the following file
- * to ignore zone related error stack frames such as `zone.run`, 
`zoneDelegate.invokeTask`.
- *
- * This import should be commented out in production mode because it will have 
a negative impact
- * on performance if an error is thrown.
- */
-// import 'zone.js/plugins/zone-error';  // Included with Angular CLI.
diff --git a/experimental/traffic-portal/tsconfig.json 
b/experimental/traffic-portal/tsconfig.json
index 4f1cb8f966..f585a2df47 100644
--- a/experimental/traffic-portal/tsconfig.json
+++ b/experimental/traffic-portal/tsconfig.json
@@ -16,7 +16,7 @@
                "noUnusedParameters": true,
                "outDir": "./dist/out-tsc",
                "strict": true,
-               "target": "es2017",
+               "target": "es2020",
                "lib": [
                        "es2020",
                        "dom"

Reply via email to