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 <[email protected]>
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);

Reply via email to