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"