You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@trafficcontrol.apache.org by oc...@apache.org on 2022/08/11 16:28:51 UTC
[trafficcontrol] branch master updated: TPv2 Add page for tenant details (#7000)
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 a67914f9e4 TPv2 Add page for tenant details (#7000)
a67914f9e4 is described below
commit a67914f9e4095b8a762ec4914fceee12d542306c
Author: Steve Hamrick <sh...@users.noreply.github.com>
AuthorDate: Thu Aug 11 10:28:45 2022 -0600
TPv2 Add page for tenant details (#7000)
* Add tenants add/edit
* More accurate test
* Code review feedback
* Wrong update
* Handle perms
* Dont need log
* Fix context menu items
* Update cursor
* Fix change logs test
* Update chromedriver
---
.../traffic-portal/nightwatch/globals/globals.ts | 32 +++-
.../nightwatch/page_objects/tenantDetail.ts | 39 +++++
.../nightwatch/page_objects/tenants.ts | 46 ++++++
.../nightwatch/tests/users/tenant/detail.spec.ts | 56 +++++++
.../nightwatch/tests/users/tenant/table.spec.ts | 26 +++
experimental/traffic-portal/package-lock.json | 14 +-
experimental/traffic-portal/package.json | 2 +-
.../src/app/api/testing/change-logs.service.ts | 3 +
.../src/app/api/testing/user.service.ts | 61 ++++++-
.../traffic-portal/src/app/api/user.service.ts | 57 ++++++-
.../traffic-portal/src/app/app.ui.module.ts | 4 +-
.../traffic-portal/src/app/core/core.module.ts | 6 +-
.../tenant-details/tenant-details.component.html | 33 ++++
.../tenant-details/tenant-details.component.scss | 27 ++++
.../tenant-details.component.spec.ts | 93 +++++++++++
.../tenant-details/tenant-details.component.ts | 176 +++++++++++++++++++++
.../app/core/users/tenants/tenants.component.html | 2 +-
.../app/core/users/tenants/tenants.component.ts | 62 +++++---
.../src/app/models/tree-select.model.ts | 24 +++
.../traffic-portal/src/app/shared/shared.module.ts | 5 +-
.../app/shared/tree-select/_tree-select-theme.scss | 22 +++
.../shared/tree-select/tree-select.component.html | 47 ++++++
.../shared/tree-select/tree-select.component.scss | 60 +++++++
.../tree-select/tree-select.component.spec.ts | 119 ++++++++++++++
.../shared/tree-select/tree-select.component.ts | 161 +++++++++++++++++++
experimental/traffic-portal/src/theme.scss | 2 +
26 files changed, 1133 insertions(+), 46 deletions(-)
diff --git a/experimental/traffic-portal/nightwatch/globals/globals.ts b/experimental/traffic-portal/nightwatch/globals/globals.ts
index 44c2a761c6..611d11cfff 100644
--- a/experimental/traffic-portal/nightwatch/globals/globals.ts
+++ b/experimental/traffic-portal/nightwatch/globals/globals.ts
@@ -23,6 +23,8 @@ import type {DeliveryServiceDetailPageObject} from "nightwatch/page_objects/deli
import type {DeliveryServiceInvalidPageObject} from "nightwatch/page_objects/deliveryServiceInvalidationJobs";
import type {LoginPageObject} from "nightwatch/page_objects/login";
import type {ServersPageObject} from "nightwatch/page_objects/servers";
+import { TenantDetailPageObject } from "nightwatch/page_objects/tenantDetail";
+import { TenantsPageObject } from "nightwatch/page_objects/tenants";
import type {UsersPageObject} from "nightwatch/page_objects/users";
import {
CDN,
@@ -30,7 +32,9 @@ import {
Protocol,
RequestDeliveryService,
ResponseCDN,
- ResponseDeliveryService
+ ResponseDeliveryService,
+ RequestTenant,
+ ResponseTenant
} from "trafficops-types";
declare module "nightwatch" {
@@ -45,6 +49,8 @@ declare module "nightwatch" {
deliveryServiceInvalidationJobs: () => DeliveryServiceInvalidPageObject;
login: () => LoginPageObject;
servers: () => ServersPageObject;
+ tenants: () => TenantsPageObject;
+ tenantDetail: () => TenantDetailPageObject;
users: () => UsersPageObject;
}
@@ -57,9 +63,19 @@ declare module "nightwatch" {
trafficOpsURL: string;
apiVersion: string;
uniqueString: string;
+ testData: CreatedData;
}
}
+/**
+ * Contains the data created by the client before the test suite runs.
+ */
+export interface CreatedData {
+ cdn: ResponseCDN;
+ ds: ResponseDeliveryService;
+ tenant: ResponseTenant;
+}
+
const globals = {
adminPass: "twelve12",
adminUser: "admin",
@@ -108,6 +124,8 @@ const globals = {
try {
let resp = await client.post(`${apiUrl}/cdns`, JSON.stringify(cdn));
respCDN = resp.data.response;
+ console.log(`Successfully created CDN ${respCDN.name}`);
+ (globals.testData as CreatedData).cdn = respCDN;
const ds: RequestDeliveryService = {
active: false,
@@ -147,6 +165,17 @@ const globals = {
resp = await client.post(`${apiUrl}/deliveryservices`, JSON.stringify(ds));
const respDS: ResponseDeliveryService = resp.data.response[0];
console.log(`Successfully created DS '${respDS.displayName}'`);
+ (globals.testData as CreatedData).ds = respDS;
+
+ const tenant: RequestTenant = {
+ active: true,
+ name: `testT${globals.uniqueString}`,
+ parentId: 1
+ };
+ resp = await client.post(`${apiUrl}/tenants`, JSON.stringify(tenant));
+ const respTenant: ResponseTenant = resp.data.response;
+ console.log(`Successfully created Tenant ${respTenant.name}`);
+ (globals.testData as CreatedData).tenant = respTenant;
} catch(e) {
console.error((e as AxiosError).message);
throw e;
@@ -162,6 +191,7 @@ const globals = {
done();
});
},
+ testData: {},
trafficOpsURL: "https://localhost:6443",
uniqueString: new Date().getTime().toString()
};
diff --git a/experimental/traffic-portal/nightwatch/page_objects/tenantDetail.ts b/experimental/traffic-portal/nightwatch/page_objects/tenantDetail.ts
new file mode 100644
index 0000000000..dcc65a0880
--- /dev/null
+++ b/experimental/traffic-portal/nightwatch/page_objects/tenantDetail.ts
@@ -0,0 +1,39 @@
+/*
+ * 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 { EnhancedPageObject } from "nightwatch";
+
+/**
+ * Defines the PageObject for Tenant Details.
+ */
+export type TenantDetailPageObject = EnhancedPageObject<{}, typeof tenantDetailPageObject.elements>;
+
+const tenantDetailPageObject = {
+ elements: {
+ active: {
+ selector: "input[name='active']",
+ },
+ name: {
+ selector: "input[name='name']"
+ },
+ parent: {
+ selector: "input[name='parentTenant-tree-select']"
+ },
+ saveBtn: {
+ selector: "button[type='submit']"
+ }
+ },
+};
+
+export default tenantDetailPageObject;
diff --git a/experimental/traffic-portal/nightwatch/page_objects/tenants.ts b/experimental/traffic-portal/nightwatch/page_objects/tenants.ts
new file mode 100644
index 0000000000..e2323bf18f
--- /dev/null
+++ b/experimental/traffic-portal/nightwatch/page_objects/tenants.ts
@@ -0,0 +1,46 @@
+/*
+ * 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 { EnhancedPageObject, EnhancedSectionInstance, NightwatchAPI } from "nightwatch";
+
+import { TABLE_COMMANDS, TableSectionCommands } from "../globals/tables";
+
+/**
+ * Defines the Tenants table commands
+ */
+type TenantsTableCommands = TableSectionCommands;
+
+/**
+ * Defines the Page Object for the Tenants page.
+ */
+export type TenantsPageObject = EnhancedPageObject<{}, {},
+EnhancedSectionInstance<TenantsTableCommands>>;
+
+const tenantsPageObject = {
+ api: {} as NightwatchAPI,
+ sections: {
+ tenantsTable: {
+ commands: {
+ ...TABLE_COMMANDS
+ },
+ elements: {},
+ selector: "mat-card"
+ }
+ },
+ url(): string {
+ return `${this.api.launchUrl}/core/tenants`;
+ }
+};
+
+export default tenantsPageObject;
diff --git a/experimental/traffic-portal/nightwatch/tests/users/tenant/detail.spec.ts b/experimental/traffic-portal/nightwatch/tests/users/tenant/detail.spec.ts
new file mode 100644
index 0000000000..46997f1ea6
--- /dev/null
+++ b/experimental/traffic-portal/nightwatch/tests/users/tenant/detail.spec.ts
@@ -0,0 +1,56 @@
+/*
+ * 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.
+ */
+
+describe("Tenant Detail Spec", () => {
+ it("Root tenant", () => {
+ const page = browser.page.tenantDetail();
+ browser.url(`${page.api.launchUrl}/core/tenants/1`, res => {
+ browser.assert.ok(res.status === 0);
+ page.waitForElementVisible("mat-card")
+ .assert.not.enabled("@active")
+ .assert.not.enabled("@name")
+ .assert.not.enabled("@parent")
+ .assert.not.enabled("@saveBtn")
+ .assert.value("@name", "root")
+ .assert.value("@active", "on");
+ });
+ });
+
+ it("Test tenant", () => {
+ const page = browser.page.tenantDetail();
+ browser.url(`${page.api.launchUrl}/core/tenants/${browser.globals.testData.tenant.id}`, res => {
+ browser.assert.ok(res.status === 0);
+ page.waitForElementVisible("mat-card")
+ .assert.enabled("@active")
+ .assert.enabled("@name")
+ .assert.enabled("@parent")
+ .assert.enabled("@saveBtn");
+ });
+ });
+
+ it("New tenant", () => {
+ const page = browser.page.tenantDetail();
+ browser.url(`${page.api.launchUrl}/core/tenants/new`, res => {
+ browser.assert.ok(res.status === 0);
+ page.waitForElementVisible("mat-card")
+ .assert.enabled("@active")
+ .assert.enabled("@name")
+ .assert.enabled("@parent")
+ .assert.enabled("@saveBtn")
+ .assert.containsText("@name", "")
+ .assert.value("@active", "on")
+ .assert.value("@parent", "");
+ });
+ });
+});
diff --git a/experimental/traffic-portal/nightwatch/tests/users/tenant/table.spec.ts b/experimental/traffic-portal/nightwatch/tests/users/tenant/table.spec.ts
new file mode 100644
index 0000000000..36f10599c4
--- /dev/null
+++ b/experimental/traffic-portal/nightwatch/tests/users/tenant/table.spec.ts
@@ -0,0 +1,26 @@
+/*
+ * 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 { NightwatchTypedCallbackResult } from "nightwatch";
+
+describe("Tenants Spec", () => {
+ it("Loads elements", async () => {
+ browser.page.tenants().navigate()
+ .waitForElementPresent("input[name=fuzzControl]");
+ browser.elements("css selector", "div.ag-row", rows => {
+ browser.assert.ok(rows.status === 0);
+ browser.assert.ok((rows as NightwatchTypedCallbackResult<{ELEMENT: string}[]>).value.length >= 2);
+ });
+ });
+});
diff --git a/experimental/traffic-portal/package-lock.json b/experimental/traffic-portal/package-lock.json
index 361d1a6d11..7b39be98f3 100644
--- a/experimental/traffic-portal/package-lock.json
+++ b/experimental/traffic-portal/package-lock.json
@@ -55,7 +55,7 @@
"@typescript-eslint/eslint-plugin": "^5.10.0",
"@typescript-eslint/parser": "^5.10.0",
"axios": "^0.27.2",
- "chromedriver": "^102.0.0",
+ "chromedriver": "^104.0.0",
"codelyzer": "^6.0.0",
"eslint": "^8.2.0",
"eslint-plugin-import": "^2.25.3",
@@ -6622,9 +6622,9 @@
}
},
"node_modules/chromedriver": {
- "version": "102.0.0",
- "resolved": "https://registry.npmjs.org/chromedriver/-/chromedriver-102.0.0.tgz",
- "integrity": "sha512-xer/0g1Oarkjc2e+4nyoLgZT4kJHYhcj3PcxD1nEoGJQYEllTjprN1uDpSb4BkgMGo0ydfIS1VDkszrr/J9OOg==",
+ "version": "104.0.0",
+ "resolved": "https://registry.npmjs.org/chromedriver/-/chromedriver-104.0.0.tgz",
+ "integrity": "sha512-zbHZutN2ATo19xA6nXwwLn+KueD/5w8ap5m4b6bCb8MIaRFnyDwMbFoy7oFAjlSMpCFL3KSaZRiWUwjj//N3yQ==",
"dev": true,
"hasInstallScript": true,
"dependencies": {
@@ -23687,9 +23687,9 @@
"dev": true
},
"chromedriver": {
- "version": "102.0.0",
- "resolved": "https://registry.npmjs.org/chromedriver/-/chromedriver-102.0.0.tgz",
- "integrity": "sha512-xer/0g1Oarkjc2e+4nyoLgZT4kJHYhcj3PcxD1nEoGJQYEllTjprN1uDpSb4BkgMGo0ydfIS1VDkszrr/J9OOg==",
+ "version": "104.0.0",
+ "resolved": "https://registry.npmjs.org/chromedriver/-/chromedriver-104.0.0.tgz",
+ "integrity": "sha512-zbHZutN2ATo19xA6nXwwLn+KueD/5w8ap5m4b6bCb8MIaRFnyDwMbFoy7oFAjlSMpCFL3KSaZRiWUwjj//N3yQ==",
"dev": true,
"requires": {
"@testim/chrome-version": "^1.1.2",
diff --git a/experimental/traffic-portal/package.json b/experimental/traffic-portal/package.json
index ec662e2782..d924e423ec 100644
--- a/experimental/traffic-portal/package.json
+++ b/experimental/traffic-portal/package.json
@@ -95,7 +95,7 @@
"@typescript-eslint/eslint-plugin": "^5.10.0",
"@typescript-eslint/parser": "^5.10.0",
"axios": "^0.27.2",
- "chromedriver": "^102.0.0",
+ "chromedriver": "^104.0.0",
"codelyzer": "^6.0.0",
"eslint": "^8.2.0",
"eslint-plugin-import": "^2.25.3",
diff --git a/experimental/traffic-portal/src/app/api/testing/change-logs.service.ts b/experimental/traffic-portal/src/app/api/testing/change-logs.service.ts
index 80d827dfe9..beb713d743 100644
--- a/experimental/traffic-portal/src/app/api/testing/change-logs.service.ts
+++ b/experimental/traffic-portal/src/app/api/testing/change-logs.service.ts
@@ -60,6 +60,9 @@ export class ChangeLogsService {
if("user" in params) {
return this.changeLogs.filter(cl => cl.user === params.user);
}
+ if("days" in params) {
+ return this.changeLogs;
+ }
throw new Error(`unknown params ${params}`);
}
}
diff --git a/experimental/traffic-portal/src/app/api/testing/user.service.ts b/experimental/traffic-portal/src/app/api/testing/user.service.ts
index b0980a7e45..7b2f5db1b5 100644
--- a/experimental/traffic-portal/src/app/api/testing/user.service.ts
+++ b/experimental/traffic-portal/src/app/api/testing/user.service.ts
@@ -14,7 +14,13 @@
import { HttpResponse } from "@angular/common/http";
import { Injectable } from "@angular/core";
-import type { GetResponseUser, PostRequestUser, PutOrPostResponseUser } from "trafficops-types";
+import type {
+ GetResponseUser,
+ PostRequestUser,
+ PutOrPostResponseUser,
+ RequestTenant,
+ ResponseTenant
+} from "trafficops-types";
import type { Role, Capability, CurrentUser, Tenant } from "src/app/models";
@@ -88,13 +94,20 @@ export class UserService {
name: "PARAMETER-SECURE:READ"
}
];
- private readonly tenants = [
+ private readonly tenants: Array<ResponseTenant> = [
{
active: true,
id: 1,
lastUpdated: new Date(),
name: "root",
parentId: null
+ },
+ {
+ active: true,
+ id: 2,
+ lastUpdated: new Date(),
+ name: "test",
+ parentId: 1
}
];
@@ -396,6 +409,50 @@ export class UserService {
}
return this.tenants;
}
+ /**
+ * Creates a new tenant.
+ *
+ * @param tenant The Tenant to create.
+ * @returns The created tenant.
+ */
+ public async createTenant(tenant: RequestTenant): Promise<ResponseTenant> {
+ const resp = {
+ ...tenant,
+ id: ++this.lastID,
+ lastUpdated: new Date()
+ };
+ this.tenants.push(resp);
+ return resp;
+ }
+
+ /**
+ * Updates an existing tenant.
+ *
+ * @param tenant The tenant to update.
+ * @returns The updated tenant.
+ */
+ public async updateTenant(tenant: ResponseTenant): Promise<ResponseTenant> {
+ const id = this.tenants.findIndex(t => t.id === tenant.id);
+ if (id < 0) {
+ throw new Error(`no such Tenant: ${tenant.id}`);
+ }
+ this.tenants[id] = tenant;
+ return tenant;
+ }
+
+ /**
+ * Deletes an existing tenant.
+ *
+ * @param id Id of the tenant to delete.
+ * @returns The deleted tenant.
+ */
+ public async deleteTenant(id: number): Promise<ResponseTenant> {
+ const index = this.tenants.findIndex(t => t.id === id);
+ if (index < 0) {
+ throw new Error(`no such Tenant: ${id}`);
+ }
+ return this.tenants.splice(index, 1)[0];
+ }
/** Fetches the User Capability (Permission) with the given name. */
public async getCapabilities (name: string): Promise<Capability>;
diff --git a/experimental/traffic-portal/src/app/api/user.service.ts b/experimental/traffic-portal/src/app/api/user.service.ts
index 108068763a..d6c4e16594 100644
--- a/experimental/traffic-portal/src/app/api/user.service.ts
+++ b/experimental/traffic-portal/src/app/api/user.service.ts
@@ -14,7 +14,13 @@
import { HttpClient, HttpResponse } from "@angular/common/http";
import { Injectable } from "@angular/core";
-import type { GetResponseUser, PostRequestUser, PutOrPostResponseUser } from "trafficops-types";
+import type {
+ GetResponseUser,
+ PostRequestUser,
+ PutOrPostResponseUser,
+ RequestTenant,
+ ResponseTenant
+} from "trafficops-types";
import {
type Role,
@@ -299,14 +305,14 @@ export class UserService extends APIService {
*
* @returns All Tenants visible to the requesting user's Tenant.
*/
- public async getTenants(): Promise<Array<Tenant>>;
+ public async getTenants(): Promise<Array<ResponseTenant>>;
/**
* Retrieves a Tenant from Traffic Ops.
*
* @param nameOrID Either the name or ID of the desired Tenant.
* @returns The Tenant identified by `nameOrID`.
*/
- public async getTenants(nameOrID: string | number): Promise<Tenant>;
+ public async getTenants(nameOrID: string | number): Promise<ResponseTenant>;
/**
* Retrieves one or all Tenants from Traffic Ops.
*
@@ -314,7 +320,7 @@ export class UserService extends APIService {
* @returns The Tenant identified by `nameOrID` if given, otherwise all
* Tenants visible to the requesting user's Tenant.
*/
- public async getTenants(nameOrID?: string | number): Promise<Array<Tenant> | Tenant> {
+ public async getTenants(nameOrID?: string | number): Promise<Array<ResponseTenant> | ResponseTenant> {
const path = "tenants";
if (nameOrID !== undefined) {
let params;
@@ -325,10 +331,49 @@ export class UserService extends APIService {
case "number":
params = {id: String(nameOrID)};
}
- const resp = await this.get<[Tenant]>(path, undefined, params).toPromise();
+ const resp = await this.get<[ResponseTenant]>(path, undefined, params).toPromise();
return resp[0];
}
- return this.get<Array<Tenant>>(path).toPromise();
+ return this.get<Array<ResponseTenant>>(path).toPromise();
+ }
+
+ /**
+ * Creates a new tenant.
+ *
+ * @param tenant The Tenant to create.
+ * @returns The created tenant.
+ */
+ public async createTenant(tenant: RequestTenant): Promise<ResponseTenant> {
+ const response = await this.post<ResponseTenant>("tenants", tenant).toPromise();
+ return {
+ ...response,
+ lastUpdated: new Date((response.lastUpdated as unknown as string).replace(" ", "T").replace("+00", "Z"))
+ };
+ }
+
+ /**
+ * Updates an existing tenant.
+ *
+ * @param tenant The tenant to update.
+ * @returns The updated tenant.
+ */
+ public async updateTenant(tenant: ResponseTenant): Promise<ResponseTenant> {
+ const response = await this.put<ResponseTenant>(`tenants/${tenant.id}`, tenant).toPromise();
+
+ return {
+ ...response,
+ lastUpdated: new Date((response.lastUpdated as unknown as string).replace(" ", "T").replace("+00", "Z"))
+ };
+ }
+
+ /**
+ * Deletes an existing tenant.
+ *
+ * @param id Id of the tenant to delete.
+ * @returns The deleted tenant.
+ */
+ public async deleteTenant(id: number): Promise<ResponseTenant> {
+ return this.delete<ResponseTenant>(`tenants/${id}`).toPromise();
}
/** Fetches the User Capability (Permission) with the given name. */
diff --git a/experimental/traffic-portal/src/app/app.ui.module.ts b/experimental/traffic-portal/src/app/app.ui.module.ts
index 195ee2bcbd..88399b98ac 100644
--- a/experimental/traffic-portal/src/app/app.ui.module.ts
+++ b/experimental/traffic-portal/src/app/app.ui.module.ts
@@ -34,6 +34,7 @@ import { MatSelectModule } from "@angular/material/select";
import { MatSnackBarModule } from "@angular/material/snack-bar";
import { MatStepperModule } from "@angular/material/stepper";
import { MatToolbarModule } from "@angular/material/toolbar";
+import { MatTreeModule } from "@angular/material/tree";
import { FontAwesomeModule } from "@fortawesome/angular-fontawesome";
import { AgGridModule } from "ag-grid-angular";
@@ -70,7 +71,8 @@ import { AgGridModule } from "ag-grid-angular";
MatSelectModule,
MatSnackBarModule,
MatStepperModule,
- MatToolbarModule
+ MatToolbarModule,
+ MatTreeModule
]
})
export class AppUIModule {}
diff --git a/experimental/traffic-portal/src/app/core/core.module.ts b/experimental/traffic-portal/src/app/core/core.module.ts
index a152607b2f..a5001f9fcf 100644
--- a/experimental/traffic-portal/src/app/core/core.module.ts
+++ b/experimental/traffic-portal/src/app/core/core.module.ts
@@ -39,6 +39,7 @@ import { NewDeliveryServiceComponent } from "./new-delivery-service/new-delivery
import { ServerDetailsComponent } from "./servers/server-details/server-details.component";
import { ServersTableComponent } from "./servers/servers-table/servers-table.component";
import { UpdateStatusComponent } from "./servers/update-status/update-status.component";
+import { TenantDetailsComponent } from "./users/tenants/tenant-details/tenant-details.component";
import { TenantsComponent } from "./users/tenants/tenants.component";
import { UserDetailsComponent } from "./users/user-details/user-details.component";
import { UserRegistrationDialogComponent } from "./users/user-registration-dialog/user-registration-dialog.component";
@@ -56,7 +57,8 @@ export const ROUTES: Routes = [
{ canActivate: [AuthenticatedGuard], component: NewDeliveryServiceComponent, path: "new.Delivery.Service" },
{ canActivate: [AuthenticatedGuard], component: CacheGroupTableComponent, path: "cache-groups" },
{ canActivate: [AuthenticatedGuard], component: TenantsComponent, path: "tenants"},
- { canActivate: [AuthenticatedGuard], component: ChangeLogsComponent, path: "change-logs" }
+ { canActivate: [AuthenticatedGuard], component: ChangeLogsComponent, path: "change-logs" },
+ { canActivate: [AuthenticatedGuard], component: TenantDetailsComponent, path: "tenants/:id"}
];
/**
@@ -79,6 +81,8 @@ export const ROUTES: Routes = [
UpdateStatusComponent,
UserDetailsComponent,
TenantsComponent,
+ UserRegistrationDialogComponent,
+ TenantDetailsComponent,
ChangeLogsComponent,
LastDaysComponent,
UserRegistrationDialogComponent
diff --git a/experimental/traffic-portal/src/app/core/users/tenants/tenant-details/tenant-details.component.html b/experimental/traffic-portal/src/app/core/users/tenants/tenant-details/tenant-details.component.html
new file mode 100644
index 0000000000..4ffba80d52
--- /dev/null
+++ b/experimental/traffic-portal/src/app/core/users/tenants/tenant-details/tenant-details.component.html
@@ -0,0 +1,33 @@
+<!--
+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.
+-->
+<mat-card>
+ <tp-loading *ngIf="!tenant"></tp-loading>
+ <form ngNativeValidate (ngSubmit)="submit($event)" *ngIf="tenant">
+ <mat-card-content>
+ <mat-form-field>
+ <mat-label>Name</mat-label>
+ <input [disabled]="disabled" matInput type="text" name="name" required [(ngModel)]="tenant.name" />
+ </mat-form-field>
+ <mat-checkbox [disabled]="disabled" matInput name="active" [checked]="tenant.active">
+ Active
+ </mat-checkbox>
+ <tp-tree-select [handle]="'parentTenant'" [disabled]="disabled" (nodeSelected)="update($event)" [initialValue]="tenant.parentId" [label]="'Parent Tenant'" [treeData]="[displayTenant]"></tp-tree-select>
+ </mat-card-content>
+ <mat-card-actions align="end">
+ <button mat-raised-button type="button" *ngIf="!new" [disabled]="disabled" color="warn" (click)="deleteTenant()">Delete</button>
+ <button mat-raised-button [disabled]="disabled" color="primary" type="submit">Save</button>
+ </mat-card-actions>
+ </form>
+</mat-card>
+
diff --git a/experimental/traffic-portal/src/app/core/users/tenants/tenant-details/tenant-details.component.scss b/experimental/traffic-portal/src/app/core/users/tenants/tenant-details/tenant-details.component.scss
new file mode 100644
index 0000000000..85b09c7c4c
--- /dev/null
+++ b/experimental/traffic-portal/src/app/core/users/tenants/tenant-details/tenant-details.component.scss
@@ -0,0 +1,27 @@
+/*
+* 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.
+*/
+
+mat-card {
+ margin: 1em auto;
+ width: 80%;
+ min-width: 350px;
+
+ mat-card-content {
+ display: grid;
+ grid-template-columns: 1fr;
+ grid-row-gap: 2em;
+ margin: 1em auto 50px;
+ }
+}
+
diff --git a/experimental/traffic-portal/src/app/core/users/tenants/tenant-details/tenant-details.component.spec.ts b/experimental/traffic-portal/src/app/core/users/tenants/tenant-details/tenant-details.component.spec.ts
new file mode 100644
index 0000000000..33c094895b
--- /dev/null
+++ b/experimental/traffic-portal/src/app/core/users/tenants/tenant-details/tenant-details.component.spec.ts
@@ -0,0 +1,93 @@
+/*
+* 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 { ComponentFixture, TestBed } from "@angular/core/testing";
+import { ActivatedRoute } from "@angular/router";
+import { RouterTestingModule } from "@angular/router/testing";
+
+import { APITestingModule } from "src/app/api/testing";
+
+import { TenantDetailsComponent } from "./tenant-details.component";
+
+describe("TenantDetailsComponent", () => {
+ let component: TenantDetailsComponent;
+ let fixture: ComponentFixture<TenantDetailsComponent>;
+ let route: ActivatedRoute;
+ let paramMap: jasmine.Spy;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ declarations: [ TenantDetailsComponent ],
+ imports: [ APITestingModule, RouterTestingModule ],
+ })
+ .compileComponents();
+
+ route = TestBed.inject(ActivatedRoute);
+ paramMap = spyOn(route.snapshot.paramMap, "get");
+ paramMap.and.returnValue(null);
+ fixture = TestBed.createComponent(TenantDetailsComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it("should create", async () => {
+ expect(component).toBeTruthy();
+ expect(paramMap).toHaveBeenCalled();
+ expect(component.tenants.length).toBe(0);
+ });
+
+ it("new tenant", async () => {
+ paramMap.and.returnValue("new");
+
+ fixture = TestBed.createComponent(TenantDetailsComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ await fixture.whenStable();
+ expect(paramMap).toHaveBeenCalled();
+ expect(component.tenants.length).toBe(2);
+ expect(component.tenant.name).toBe("");
+ expect(component.new).toBeTrue();
+ expect(component.displayTenant.name).toBe("root");
+ expect(component.disabled).toBeFalse();
+ });
+
+ it("existing root tenant", async () => {
+ paramMap.and.returnValue("1");
+
+ fixture = TestBed.createComponent(TenantDetailsComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ await fixture.whenStable();
+ expect(paramMap).toHaveBeenCalled();
+ expect(component.tenants.length).toBe(2);
+ expect(component.tenant.name).toBe("root");
+ expect(component.new).toBeFalse();
+ expect(component.displayTenant.name).toBe("root");
+ expect(component.disabled).toBeTrue();
+ });
+
+ it("existing non-root tenant", async () => {
+ paramMap.and.returnValue("2");
+
+ fixture = TestBed.createComponent(TenantDetailsComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ await fixture.whenStable();
+ expect(paramMap).toHaveBeenCalled();
+ expect(component.tenants.length).toBe(2);
+ expect(component.tenant.name).toBe("test");
+ expect(component.new).toBeFalse();
+ expect(component.displayTenant.name).toBe("root");
+ expect(component.disabled).toBeFalse();
+ });
+});
diff --git a/experimental/traffic-portal/src/app/core/users/tenants/tenant-details/tenant-details.component.ts b/experimental/traffic-portal/src/app/core/users/tenants/tenant-details/tenant-details.component.ts
new file mode 100644
index 0000000000..d16c8d89f8
--- /dev/null
+++ b/experimental/traffic-portal/src/app/core/users/tenants/tenant-details/tenant-details.component.ts
@@ -0,0 +1,176 @@
+/*
+* 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 { Location } from "@angular/common";
+import { Component, OnInit } from "@angular/core";
+import { ActivatedRoute } from "@angular/router";
+import { RequestTenant, ResponseTenant, Tenant } from "trafficops-types";
+
+import { UserService } from "src/app/api";
+import { TreeData } from "src/app/models/tree-select.model";
+
+/**
+ * TenantsDetailsComponent is the controller for thee tenant add/edit form.
+ */
+@Component({
+ selector: "tp-tenant-details",
+ styleUrls: ["./tenant-details.component.scss"],
+ templateUrl: "./tenant-details.component.html"
+})
+export class TenantDetailsComponent implements OnInit {
+ public new = false;
+ public disabled = false;
+ public tenant!: Tenant;
+ public tenants = new Array<ResponseTenant>();
+ public displayTenant: TreeData;
+
+ constructor(private readonly route: ActivatedRoute, private readonly userService: UserService,
+ private readonly location: Location) {
+ this.displayTenant = {
+ children: [],
+ id: -1,
+ name: ""
+ };
+ }
+
+ /**
+ * Catches when tree-select outputs an update event
+ *
+ * @param evt The TreeData selected
+ */
+ public update(evt: TreeData): void {
+ const tenant = this.tenants.find(t => t.id === evt.id);
+ if (tenant === undefined) {
+ console.error(`Unknown tenant selected ${evt.id}`);
+ return;
+ }
+ this.tenant.parentId = tenant.id;
+ }
+
+ /**
+ * Recursively fills out a nodes children.
+ *
+ * @param tenantByParentId All tenants grouped by parent id.
+ * @param currentTenant The tenant to populate.
+ */
+ public breakTenantNode(tenantByParentId: Map<number, Array<TreeData>>, currentTenant: TreeData): void {
+ currentTenant.children = (tenantByParentId.get(currentTenant.id) ?? []).map(t => ({...t, children: []} as TreeData));
+
+ currentTenant.children.forEach(t => {
+ this.breakTenantNode(tenantByParentId, t);
+ });
+ }
+
+ /**
+ * Converts the tenants list into the tree-data structure needed by the tree-select component.
+ */
+ public constructTreeData(): void {
+ const tenantByParentId = new Map<number, Array<TreeData>>();
+ this.tenants.forEach(t => {
+ if (t.parentId === null) {
+ return;
+ }
+ let children = tenantByParentId.get(t.parentId);
+ if(!children) {
+ children = [];
+ }
+ children.push({...t, children: []});
+ tenantByParentId.set(t.parentId, children);
+ });
+ const rootTenant = this.tenants.find(t => t.parentId === null);
+ if (rootTenant === undefined) {
+ return;
+ }
+ const rootNode = {...rootTenant, children: []} as TreeData;
+ this.breakTenantNode(tenantByParentId, rootNode);
+
+ this.displayTenant = rootNode;
+ }
+
+ /**
+ * Angular lifecycle hook.
+ */
+ public async ngOnInit(): Promise<void> {
+ const ID = this.route.snapshot.paramMap.get("id");
+ if (ID === null) {
+ console.error("missing required route parameter 'id'");
+ return;
+ }
+
+ this.tenants = await this.userService.getTenants();
+ this.constructTreeData();
+
+ if (ID === "new") {
+ this.new = true;
+ this.tenant = {
+ active: true,
+ name: "",
+ } as RequestTenant;
+ return;
+ }
+ const numID = parseInt(ID, 10);
+ if (Number.isNaN(numID)) {
+ console.error("route parameter 'id' was non-number:", ID);
+ return;
+ }
+ const tenant = this.tenants.find(t => t.id === numID);
+ if (!tenant) {
+ console.error(`Unable to find tenant with id ${numID}`);
+ return;
+ }
+ this.tenant = tenant;
+ this.disabled = this.isRoot();
+
+ }
+
+ /**
+ * Submits new/changed tenant.
+ *
+ * @param e Html event generated from click
+ */
+ public async submit(e: Event): Promise<void> {
+ e.preventDefault();
+ e.stopPropagation();
+ if (this.tenant.parentId === undefined) {
+ return;
+ }
+ if (this.new) {
+ this.tenant = await this.userService.createTenant(this.tenant as RequestTenant);
+ this.new = false;
+ } else {
+ this.tenant = await this.userService.updateTenant(this.tenant as ResponseTenant);
+ }
+ }
+
+ /**
+ * Deletes the current tenant.
+ */
+ public async deleteTenant(): Promise<void> {
+ if (this.new) {
+ console.error("Unable to delete new tenant");
+ return;
+ }
+ await this.userService.deleteTenant((this.tenant as ResponseTenant).id);
+ this.location.back();
+ }
+
+ /**
+ * Determines if the current tenant is the root tenant.
+ *
+ * @returns if a tenant is root
+ */
+ public isRoot(): boolean {
+ return this.tenant && this.tenant.name === "root";
+ }
+
+}
diff --git a/experimental/traffic-portal/src/app/core/users/tenants/tenants.component.html b/experimental/traffic-portal/src/app/core/users/tenants/tenants.component.html
index 843d65d5e8..794bd7c26c 100644
--- a/experimental/traffic-portal/src/app/core/users/tenants/tenants.component.html
+++ b/experimental/traffic-portal/src/app/core/users/tenants/tenants.component.html
@@ -27,4 +27,4 @@ limitations under the License.
<div id="loading" *ngIf="loading"><tp-loading></tp-loading></div>
</mat-card>
-<button class="page-fab" mat-fab title="Create a new Tenant" disabled><mat-icon>add</mat-icon></button>
+<button class="page-fab" mat-fab title="Create a new Tenant" *ngIf="auth.hasPermission('TENANT:CREATE')" routerLink="new"><mat-icon>add</mat-icon></button>
diff --git a/experimental/traffic-portal/src/app/core/users/tenants/tenants.component.ts b/experimental/traffic-portal/src/app/core/users/tenants/tenants.component.ts
index 7b59f51ae0..9f8192310d 100644
--- a/experimental/traffic-portal/src/app/core/users/tenants/tenants.component.ts
+++ b/experimental/traffic-portal/src/app/core/users/tenants/tenants.component.ts
@@ -78,15 +78,7 @@ export class TenantsComponent implements OnInit, OnDestroy {
}
];
- public readonly contextMenuItems: ContextMenuItem<Readonly<Tenant>>[] = [
- {
- action: "viewDetails",
- name: "View Details"
- },
- {
- action: "openInNewTab",
- name: "Open in New Tab"
- }
+ public contextMenuItems: ContextMenuItem<Readonly<Tenant>>[] = [
];
public loading = true;
@@ -94,12 +86,45 @@ export class TenantsComponent implements OnInit, OnDestroy {
constructor(
private readonly userService: UserService,
- private readonly auth: CurrentUserService,
+ public readonly auth: CurrentUserService,
private readonly headerSvc: TpHeaderService
) {
this.headerSvc.headerTitle.next("Tenant");
}
+ /**
+ * Loads the context menu items for the grid.
+ *
+ * @private
+ */
+ private loadContextMenuItems(): void {
+ this.contextMenuItems = [];
+ if (this.auth.hasPermission("USER:READ")) {
+ this.contextMenuItems.push({
+ action: "viewUsers",
+ multiRow: true,
+ name: "View Users"
+ });
+ }
+ if (this.auth.hasPermission("TENANT:UPDATE")) {
+ this.contextMenuItems.push({
+ action: "disable",
+ disabled: (ts): boolean => ts.some(t=>t.name === "root" || t.id === this.auth.currentUser?.tenantId),
+ multiRow: true,
+ name: "Disable"
+ });
+ this.contextMenuItems.push({
+ href: (t: Tenant): string => `core/tenants/${t.id}`,
+ name: "View Details"
+ });
+ this.contextMenuItems.push({
+ href: (t: Tenant): string => `core/tenants/${t.id}`,
+ name: "Open in New Tab",
+ newTab: true
+ });
+ }
+ }
+
/**
* Angular lifecycle hook; fetches API data.
*/
@@ -108,23 +133,10 @@ export class TenantsComponent implements OnInit, OnDestroy {
this.tenantMap = Object.fromEntries((this.tenants).map(t => [t.id, t]));
this.subscription = this.auth.userChanged.subscribe(
() => {
- if (this.auth.hasPermission("USER:READ")) {
- this.contextMenuItems.push({
- action: "viewUsers",
- multiRow: true,
- name: "View Users"
- });
- }
- if (this.auth.hasPermission("TENANT:UPDATE")) {
- this.contextMenuItems.push({
- action: "disable",
- disabled: (ts): boolean => ts.some(t=>t.name === "root" || t.id === this.auth.currentUser?.tenantId),
- multiRow: true,
- name: "Disable"
- });
- }
+ this.loadContextMenuItems();
}
);
+ this.loadContextMenuItems();
this.loading = false;
}
diff --git a/experimental/traffic-portal/src/app/models/tree-select.model.ts b/experimental/traffic-portal/src/app/models/tree-select.model.ts
new file mode 100644
index 0000000000..7bc6d9f7da
--- /dev/null
+++ b/experimental/traffic-portal/src/app/models/tree-select.model.ts
@@ -0,0 +1,24 @@
+/*
+ * 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.
+ */
+
+/**
+ * Defines the data-structure used for the mat-tree
+ */
+export interface TreeData {
+ name: string;
+ id: number;
+ visible?: boolean;
+ containerNeeded?: boolean;
+ children: TreeData[];
+}
diff --git a/experimental/traffic-portal/src/app/shared/shared.module.ts b/experimental/traffic-portal/src/app/shared/shared.module.ts
index 3da8ed1891..518c1c2628 100644
--- a/experimental/traffic-portal/src/app/shared/shared.module.ts
+++ b/experimental/traffic-portal/src/app/shared/shared.module.ts
@@ -34,6 +34,7 @@ import { SSHCellRendererComponent } from "./table-components/ssh-cell-renderer/s
import { TelephoneCellRendererComponent } from "./table-components/telephone-cell-renderer/telephone-cell-renderer.component";
import { UpdateCellRendererComponent } from "./table-components/update-cell-renderer/update-cell-renderer.component";
import { TpHeaderComponent } from "./tp-header/tp-header.component";
+import { TreeSelectComponent } from "./tree-select/tree-select.component";
import { CustomvalidityDirective } from "./validation/customvalidity.directive";
/**
@@ -52,7 +53,8 @@ import { CustomvalidityDirective } from "./validation/customvalidity.directive";
SSHCellRendererComponent,
EmailCellRendererComponent,
TelephoneCellRendererComponent,
- ObscuredTextInputComponent
+ ObscuredTextInputComponent,
+ TreeSelectComponent
],
exports: [
AlertComponent,
@@ -64,6 +66,7 @@ import { CustomvalidityDirective } from "./validation/customvalidity.directive";
CustomvalidityDirective,
LinechartDirective,
ObscuredTextInputComponent,
+ TreeSelectComponent
],
imports: [
AppUIModule,
diff --git a/experimental/traffic-portal/src/app/shared/tree-select/_tree-select-theme.scss b/experimental/traffic-portal/src/app/shared/tree-select/_tree-select-theme.scss
new file mode 100644
index 0000000000..ba8f0fa4bf
--- /dev/null
+++ b/experimental/traffic-portal/src/app/shared/tree-select/_tree-select-theme.scss
@@ -0,0 +1,22 @@
+/*
+* 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.
+*/
+@use '@angular/material' as mat;
+@use "sass:map";
+
+@mixin tree-select-theme($color) {
+ $background: map.get($color, background);
+ div.tree-select-root div.tree-select-content {
+ background-color: mat.get-color-from-palette($background, card);
+ }
+}
diff --git a/experimental/traffic-portal/src/app/shared/tree-select/tree-select.component.html b/experimental/traffic-portal/src/app/shared/tree-select/tree-select.component.html
new file mode 100644
index 0000000000..f899dfef3e
--- /dev/null
+++ b/experimental/traffic-portal/src/app/shared/tree-select/tree-select.component.html
@@ -0,0 +1,47 @@
+<!--
+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.
+-->
+<div class="tree-select-root" role="combobox">
+ <mat-form-field>
+ <mat-label>{{label}}</mat-label>
+ <input type="text" class="tree-selection" id="{{handle}}-tree-select" name="{{handle}}-tree-select" matInput required readonly (click)="toggle($event)" [(ngModel)]="selected.name" [disabled]="disabled"/>
+ </mat-form-field>
+ <div class="tree-select-content" *ngIf="shown">
+ <mat-form-field appearance="fill" (click)="$event.stopPropagation()">
+ <mat-label>Filter</mat-label>
+ <input type="search" (input)="filterChanged($event)" id="filter-{{handle}}" name="filter-{{handle}}" matInput/>
+ </mat-form-field>
+ <mat-tree [dataSource]="treeData" [treeControl]="treeControl">
+ <mat-tree-node *matTreeNodeDef="let node" matTreeNodeToggle [style.display]="!isVisible(node) ? 'none' : 'block'">
+ <div mat-menu-item (click)="select(node)">
+ {{node.name}}
+ </div>
+ </mat-tree-node>
+ <mat-nested-tree-node *matTreeNodeDef="let node; when: hasChild">
+ <div class="mat-tree-node" *ngIf="isVisible(node)">
+ <button mat-icon-button matTreeNodeToggle [attr.aria-label]="'Toggle ' + node.name" type="button">
+ <mat-icon class="mat-icon-rt1-mirror">
+ {{treeControl.isExpanded(node) ? 'expanded_more' : 'chevron_right' }}
+ </mat-icon>
+ </button>
+ <div mat-menu-item (click)="select(node)">
+ {{node.name}}
+ </div>
+ </div>
+ <div [style.display]="(!treeControl.isExpanded(node) && !node.containerNeeded) ? 'none' : 'block'" role="group">
+ <ng-container matTreeNodeOutlet></ng-container>
+ </div>
+ </mat-nested-tree-node>
+ </mat-tree>
+ </div>
+</div>
diff --git a/experimental/traffic-portal/src/app/shared/tree-select/tree-select.component.scss b/experimental/traffic-portal/src/app/shared/tree-select/tree-select.component.scss
new file mode 100644
index 0000000000..ee2a303209
--- /dev/null
+++ b/experimental/traffic-portal/src/app/shared/tree-select/tree-select.component.scss
@@ -0,0 +1,60 @@
+/*
+* 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.
+*/
+
+div.tree-select-root {
+ display: block;
+ position: relative;
+
+ mat-form-field {
+ width: 100%;
+ }
+
+ .tree-selection {
+ cursor: pointer;
+ }
+
+ div.tree-select-content {
+ border: 1px solid black;
+ position: absolute;
+ width: 100%;
+ z-index: 10;
+
+ mat-tree {
+ overflow-y: scroll;
+ max-height: 500px;
+ padding-bottom: 5px;
+
+ .mat-menu-item {
+ width: 100%;
+ }
+
+ ul, li {
+ margin-top: 0;
+ margin-bottom: 0;
+ list-style-type: none;
+ }
+
+ .mat-nested-tree-node div[role=group] {
+ padding-left: 5px;
+ }
+
+ div[role=group] > .mat-tree-node {
+ padding-left: 40px;
+ }
+ }
+ }
+}
+
+
+
diff --git a/experimental/traffic-portal/src/app/shared/tree-select/tree-select.component.spec.ts b/experimental/traffic-portal/src/app/shared/tree-select/tree-select.component.spec.ts
new file mode 100644
index 0000000000..c99f4ad58b
--- /dev/null
+++ b/experimental/traffic-portal/src/app/shared/tree-select/tree-select.component.spec.ts
@@ -0,0 +1,119 @@
+/*
+* 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 { ComponentFixture, TestBed } from "@angular/core/testing";
+
+import { TreeData } from "src/app/models/tree-select.model";
+
+import { TreeSelectComponent } from "./tree-select.component";
+
+const treeData: Array<TreeData> = [{
+ children: [{
+ children: [],
+ id: 11,
+ name: "n11"
+ }],
+ id: 1,
+ name: "n1",
+}, {
+ children: [{
+ children: [{
+ children: [],
+ id: 211,
+ name: "n211"
+ }],
+ id: 21,
+ name: "n21"
+ }],
+ id: 2,
+ name: "n2"
+}];
+
+/**
+ * Returns all nodes in the component.
+ *
+ * @param component The component to get nodes from
+ * @returns Every node in the tree
+ */
+function allNodes(component: TreeSelectComponent): Array<TreeData> {
+ const ret = new Array<TreeData>();
+ component.treeData.forEach(root => {
+ ret.push(root);
+ component.treeControl.getDescendants(root).forEach(node => {
+ ret.push(node);
+ });
+ });
+ return ret;
+}
+
+/**
+ * Returns all nodes in the component that are visible (or unset).
+ *
+ * @param component Component to get nodes from
+ * @returns All visible nodes
+ */
+function visibleData(component: TreeSelectComponent): Array<TreeData> {
+ return allNodes(component).filter(node => node.visible === undefined || node.visible);
+}
+
+describe("TreeSelectComponent", () => {
+ let component: TreeSelectComponent;
+ let fixture: ComponentFixture<TreeSelectComponent>;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ declarations: [TreeSelectComponent]
+ })
+ .compileComponents();
+
+ fixture = TestBed.createComponent(TreeSelectComponent);
+ component = fixture.componentInstance;
+ component.treeData = treeData;
+ fixture.detectChanges();
+ });
+
+ it("should create", async () => {
+ expect(component).toBeTruthy();
+ component.filter.next("");
+
+ expect(component.selected.id).toBe(-1);
+ expect(visibleData(component).length).toBe(5);
+ });
+
+ it("should filter", async () => {
+ component.filter.next("n211");
+ expect(visibleData(component).length).toBe(1);
+ component.filter.next("");
+ expect(visibleData(component).length).toBe(5);
+ component.filter.next("n21");
+ expect(visibleData(component).length).toBe(2);
+ });
+
+ it("should select initial value", () => {
+ fixture = TestBed.createComponent(TreeSelectComponent);
+ component = fixture.componentInstance;
+ component.treeData = treeData;
+ component.initialValue = treeData[1].id;
+ fixture.detectChanges();
+
+ expect(component.selected.id).toBe(treeData[1].id);
+
+ fixture = TestBed.createComponent(TreeSelectComponent);
+ component = fixture.componentInstance;
+ component.treeData = treeData;
+ component.initialValue = treeData[0].children[0].id;
+ fixture.detectChanges();
+
+ expect(component.selected.id).toBe(treeData[0].children[0].id);
+ });
+});
diff --git a/experimental/traffic-portal/src/app/shared/tree-select/tree-select.component.ts b/experimental/traffic-portal/src/app/shared/tree-select/tree-select.component.ts
new file mode 100644
index 0000000000..a786756731
--- /dev/null
+++ b/experimental/traffic-portal/src/app/shared/tree-select/tree-select.component.ts
@@ -0,0 +1,161 @@
+/*
+* 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 { NestedTreeControl } from "@angular/cdk/tree";
+import { Component, EventEmitter, HostListener, Input, OnInit, Output } from "@angular/core";
+import { MatTreeNestedDataSource } from "@angular/material/tree";
+import { Subject } from "rxjs";
+
+import { TreeData } from "src/app/models/tree-select.model";
+import { fuzzyScore } from "src/app/utils";
+
+/**
+ * TreeSelectComponent is the controller for a tree select input
+ */
+@Component({
+ selector: "tp-tree-select",
+ styleUrls: ["./tree-select.component.scss"],
+ templateUrl: "./tree-select.component.html"
+})
+export class TreeSelectComponent implements OnInit {
+ @Input() public treeData = new Array<TreeData>();
+ @Input() public label = "";
+ @Input() public handle = "tree-select";
+ @Input() public disabled = false;
+ @Input() public initialValue = -1;
+ @Output() public nodeSelected = new EventEmitter<TreeData>();
+ public shown = false;
+ public dataSource = new MatTreeNestedDataSource<TreeData>();
+ public treeControl = new NestedTreeControl<TreeData>(node => node.children);
+ public selected: TreeData = {children: [], id: -1, name: ""};
+ public filter = new Subject<string>();
+
+ /**
+ * Used by angular to determine if this node should be a nested tree node.
+ *
+ * @param _ Index of the current node.
+ * @param node Node to test.
+ * @returns If the node has children.
+ */
+ public hasChild(_: number, node: TreeData): boolean {
+ return node.children !== undefined && node.children.length > 0;
+ }
+
+ /**
+ * Used by angular to determine a node's visible property
+ *
+ * @param node The node to test.
+ * @returns Visible value, unset means visible.
+ */
+ public isVisible(node: TreeData): boolean {
+ return node?.visible ?? true;
+ }
+
+ /**
+ * Used by angular when the search input is changed
+ *
+ * @param $event The html input event.
+ */
+ public filterChanged($event: Event): void {
+ this.filter.next(($event.target as HTMLInputElement).value);
+ }
+
+ /**
+ * Listens for clicks outside this component to close the drop down.
+ */
+ @HostListener("document:click", ["$event"])
+ public documentClick(): void {
+ if (this.shown) {
+ this.shown = false;
+ }
+ }
+
+ /**
+ * Called when a tree node is selected.
+ *
+ * @param node The selected node.
+ */
+ public select(node: TreeData): void {
+ this.shown = false;
+ this.selected = node;
+ this.nodeSelected.emit(node);
+ }
+
+ /**
+ * Called to toggle if the tree select drop down is visible.
+ *
+ * @param evt DOM event
+ */
+ public toggle(evt: Event): void {
+ evt.stopPropagation();
+ evt.preventDefault();
+ this.shown = !this.shown;
+ }
+
+ /**
+ * Angular lifecycle hook.
+ */
+ public ngOnInit(): void {
+ this.dataSource.data = this.treeData;
+
+ for (const data of this.treeData) {
+ if (data.id === this.initialValue) {
+ this.selected = data;
+ break;
+ }
+ const res = this.treeControl.getDescendants(data).find(desc => desc.id === this.initialValue);
+ if (res !== undefined) {
+ this.selected = res;
+ break;
+ }
+ }
+
+ this.filter.subscribe(value => {
+ this.treeData.forEach(node => {
+ if(value === "") {
+ node.visible = true;
+ node.containerNeeded = false;
+ this.treeControl.getDescendants(node).forEach(desc => {
+ desc.visible = true;
+ desc.containerNeeded = false;
+ });
+ } else {
+ this.filterNode(node, value);
+ }
+ });
+ });
+ }
+
+ /**
+ * Recursively fuzzy filter a node on its name.
+ *
+ * @param node The node to filter.
+ * @param value The filter value.
+ * @returns If the node passes the filter.
+ */
+ public filterNode(node: TreeData, value: string): boolean {
+ let score: number;
+ if(value === "") {
+ score = 0;
+ } else {
+ score = fuzzyScore(node.name.toLocaleLowerCase(), value.toLocaleLowerCase());
+ }
+ node.visible = (score !== Infinity);
+ if(node.containerNeeded) {
+ node.containerNeeded = false;
+ }
+ this.treeControl.getDescendants(node).forEach(desc => node.containerNeeded = node.containerNeeded || this.filterNode(desc, value));
+ return node.visible || (node.containerNeeded ?? false);
+ }
+
+}
diff --git a/experimental/traffic-portal/src/theme.scss b/experimental/traffic-portal/src/theme.scss
index 1072644b9f..5a803097ae 100644
--- a/experimental/traffic-portal/src/theme.scss
+++ b/experimental/traffic-portal/src/theme.scss
@@ -15,6 +15,7 @@
@use "sass:map";
@import "./app/core/servers/server-details/server-details-theme";
+@import "./app/shared/tree-select/tree-select-theme";
@import "../node_modules/ag-grid-community/src/styles/ag-grid.scss";
@import "../node_modules/ag-grid-community/src/styles/ag-theme-material/sass/ag-theme-material-mixin";
@@ -109,6 +110,7 @@
@include theme-color($color);
@include grid-theme($theme);
@include server-details-theme($color);
+ @include tree-select-theme($color);
}
@if $typography != null {
@include theme-typography($typography);