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);