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 2023/03/03 06:10:17 UTC

[trafficcontrol] branch master updated: Adding Types table and details (#7355)

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 9ae015a09e Adding Types table and details (#7355)
9ae015a09e is described below

commit 9ae015a09e27b8b50e9d8e17a699efee1fdb51ad
Author: Srijeet Chatterjee <30...@users.noreply.github.com>
AuthorDate: Thu Mar 2 23:10:10 2023 -0700

    Adding Types table and details (#7355)
    
    * wip
    
    * wip details
    
    * removing extra right click option
    
    * unit tests passing
    
    * fix lint issues, add e2e tests
    
    * remove unneeded commmit
    
    * lint, e2e test fixes
    
    * fix e2e tests
    
    * adding id
    
    * fix e2e tests, hopefully
    
    * change div to type
    
    * address code review
---
 .../traffic-portal/nightwatch/globals/globals.ts   |  23 ++-
 .../nightwatch/page_objects/types/typeDetail.ts    |  45 ++++++
 .../nightwatch/page_objects/types/typesTable.ts    |  45 ++++++
 .../nightwatch/tests/types/detail.spec.ts          |  46 ++++++
 .../nightwatch/tests/types/table.spec.ts           |  24 +++
 .../src/app/api/testing/type.service.ts            |  34 ++++-
 .../traffic-portal/src/app/api/type.service.ts     |  34 ++++-
 .../traffic-portal/src/app/core/core.module.ts     |   8 +-
 .../core/types/detail/type-detail.component.html   |  45 ++++++
 .../core/types/detail/type-detail.component.scss   |  26 ++++
 .../types/detail/type-detail.component.spec.ts     |  78 ++++++++++
 .../app/core/types/detail/type-detail.component.ts | 109 ++++++++++++++
 .../core/types/table/types-table.component.html    |  29 ++++
 .../core/types/table/types-table.component.scss    |  14 ++
 .../core/types/table/types-table.component.spec.ts | 166 +++++++++++++++++++++
 .../app/core/types/table/types-table.component.ts  | 133 +++++++++++++++++
 .../app/shared/navigation/navigation.service.ts    |   6 +
 17 files changed, 860 insertions(+), 5 deletions(-)

diff --git a/experimental/traffic-portal/nightwatch/globals/globals.ts b/experimental/traffic-portal/nightwatch/globals/globals.ts
index c346aaf6bd..0491929be4 100644
--- a/experimental/traffic-portal/nightwatch/globals/globals.ts
+++ b/experimental/traffic-portal/nightwatch/globals/globals.ts
@@ -54,9 +54,13 @@ import {
 	RequestCacheGroup,
 	ResponseCacheGroup,
 	ResponsePhysicalLocation,
-	RequestPhysicalLocation
+	RequestPhysicalLocation,
+	RequestType
 } from "trafficops-types";
 
+import {TypeDetailPageObject} from "../page_objects/types/typeDetail";
+import {TypesPageObject} from "../page_objects/types/typesTable";
+
 declare module "nightwatch" {
 	/**
 	 * Defines the global nightwatch browser type with our types mixed in.
@@ -88,6 +92,10 @@ declare module "nightwatch" {
 			tenantDetail: () => TenantDetailPageObject;
 			users: () => UsersPageObject;
 		};
+		types: {
+			typesTable: () => TypesPageObject;
+			typeDetail: () => TypeDetailPageObject;
+		};
 	}
 
 	/**
@@ -116,6 +124,7 @@ export interface CreatedData {
 	region: ResponseRegion;
 	steeringDS: ResponseDeliveryService;
 	tenant: ResponseTenant;
+	type: TypeFromResponse;
 }
 
 const testData = {};
@@ -321,6 +330,18 @@ const globals = {
 			respPhysLoc.region = respRegion.name;
 			console.log(`Successfully created Phys Loc ${respPhysLoc.name}`);
 			data.physLoc = respPhysLoc;
+
+			const type: RequestType = {
+				description: "blah",
+				name: `type${globals.uniqueString}`,
+				useInTable: "server"
+			};
+			url = `${apiUrl}/types`;
+			resp = await client.post(url, JSON.stringify(type));
+			const respType: TypeFromResponse = resp.data.response;
+			console.log(`Successfully created Type ${respType.name}`);
+			data.type = respType;
+
 		} catch(e) {
 			console.error("Request for", url, "failed:", (e as AxiosError).message);
 			throw e;
diff --git a/experimental/traffic-portal/nightwatch/page_objects/types/typeDetail.ts b/experimental/traffic-portal/nightwatch/page_objects/types/typeDetail.ts
new file mode 100644
index 0000000000..598025c0fb
--- /dev/null
+++ b/experimental/traffic-portal/nightwatch/page_objects/types/typeDetail.ts
@@ -0,0 +1,45 @@
+/*
+ * 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 Type Details.
+ */
+export type TypeDetailPageObject = EnhancedPageObject<{}, typeof typeDetailPageObject.elements>;
+
+const typeDetailPageObject = {
+	elements: {
+		description: {
+			selector: "input[name='description']"
+		},
+		id: {
+			selector: "input[name='id']"
+		},
+		lastUpdated: {
+			selector: "input[name='lastUpdated']"
+		},
+		name: {
+			selector: "input[name='name']"
+		},
+		saveBtn: {
+			selector: "button[type='submit']"
+		},
+		useInTable: {
+			selector: "input[name='useInTable']"
+		}
+	},
+};
+
+export default typeDetailPageObject;
diff --git a/experimental/traffic-portal/nightwatch/page_objects/types/typesTable.ts b/experimental/traffic-portal/nightwatch/page_objects/types/typesTable.ts
new file mode 100644
index 0000000000..a0cca5735a
--- /dev/null
+++ b/experimental/traffic-portal/nightwatch/page_objects/types/typesTable.ts
@@ -0,0 +1,45 @@
+/*
+ * 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 Types table commands
+ */
+type TypesTableCommands = TableSectionCommands;
+
+/**
+ * Defines the Page Object for the Types page.
+ */
+export type TypesPageObject = EnhancedPageObject<{}, {}, EnhancedSectionInstance<TypesTableCommands>>;
+
+const typesPageObject = {
+	api: {} as NightwatchAPI,
+	sections: {
+		typesTable: {
+			commands: {
+				...TABLE_COMMANDS
+			},
+			elements: {},
+			selector: "mat-card"
+		}
+	},
+	url(): string {
+		return `${this.api.launchUrl}/core/types`;
+	}
+};
+
+export default typesPageObject;
diff --git a/experimental/traffic-portal/nightwatch/tests/types/detail.spec.ts b/experimental/traffic-portal/nightwatch/tests/types/detail.spec.ts
new file mode 100644
index 0000000000..884c810061
--- /dev/null
+++ b/experimental/traffic-portal/nightwatch/tests/types/detail.spec.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.
+ */
+
+describe("Type Detail Spec", () => {
+	it("Test type", () => {
+		const page = browser.page.types.typeDetail();
+		browser.url(`${page.api.launchUrl}/core/types/${browser.globals.testData.type.id}`, res => {
+			browser.assert.ok(res.status === 0);
+			page.waitForElementVisible("mat-card")
+				.assert.enabled("@name")
+				.assert.enabled("@description")
+				.assert.not.enabled("@useInTable")
+				.assert.enabled("@saveBtn")
+				.assert.not.enabled("@id")
+				.assert.not.enabled("@lastUpdated")
+				.assert.valueEquals("@name", browser.globals.testData.type.name)
+				.assert.valueEquals("@id", String(browser.globals.testData.type.id));
+		});
+	});
+
+	it("New type", () => {
+		const page = browser.page.types.typeDetail();
+		browser.url(`${page.api.launchUrl}/core/types/new`, res => {
+			browser.assert.ok(res.status === 0);
+			page.waitForElementVisible("mat-card")
+				.assert.enabled("@name")
+				.assert.enabled("@description")
+				.assert.not.enabled("@useInTable")
+				.assert.enabled("@saveBtn")
+				.assert.not.elementPresent("@id")
+				.assert.not.elementPresent("@lastUpdated")
+				.assert.valueEquals("@name", "");
+		});
+	});
+});
diff --git a/experimental/traffic-portal/nightwatch/tests/types/table.spec.ts b/experimental/traffic-portal/nightwatch/tests/types/table.spec.ts
new file mode 100644
index 0000000000..68c74917fa
--- /dev/null
+++ b/experimental/traffic-portal/nightwatch/tests/types/table.spec.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.
+ */
+
+describe("Types Spec", () => {
+	it("Loads elements", async () => {
+		browser.page.types.typesTable().navigate()
+			.waitForElementPresent("input[name=fuzzControl]");
+		browser.elements("css selector", "div.ag-row", rows => {
+			browser.assert.ok(rows.status === 0);
+			browser.assert.ok((rows.value as []).length >= 2);
+		});
+	});
+});
diff --git a/experimental/traffic-portal/src/app/api/testing/type.service.ts b/experimental/traffic-portal/src/app/api/testing/type.service.ts
index 387a80e742..ad7d5138ac 100644
--- a/experimental/traffic-portal/src/app/api/testing/type.service.ts
+++ b/experimental/traffic-portal/src/app/api/testing/type.service.ts
@@ -12,7 +12,7 @@
 * limitations under the License.
 */
 import { Injectable } from "@angular/core";
-import { TypeFromResponse } from "trafficops-types";
+import {RequestType, TypeFromResponse} from "trafficops-types";
 
 /** The allowed values for the 'useInTables' query parameter of GET requests to /types. */
 type UseInTable = "cachegroup" |
@@ -29,7 +29,7 @@ type UseInTable = "cachegroup" |
  */
 @Injectable()
 export class TypeService {
-
+	private lastID = 20;
 	private readonly types = [
 		{
 			description: "Mid Logical Location",
@@ -182,4 +182,34 @@ export class TypeService {
 	public async getServerTypes(): Promise<Array<TypeFromResponse>> {
 		return this.getTypesInTable("server");
 	}
+
+	/**
+	 * Deletes an existing type.
+	 *
+	 * @param id Id of the type to delete.
+	 * @returns The deleted type.
+	 */
+	public async deleteType(id: number): Promise<TypeFromResponse> {
+		const index = this.types.findIndex(t => t.id === id);
+		if (index === -1) {
+			throw new Error(`no such Type: ${id}`);
+		}
+		return this.types.splice(index, 1)[0];
+	}
+
+	/**
+	 * Creates a new type.
+	 *
+	 * @param type The type to create.
+	 * @returns The created type.
+	 */
+	public async createType(type: RequestType): Promise<TypeFromResponse> {
+		const t = {
+			...type,
+			id: ++this.lastID,
+			lastUpdated: new Date()
+		};
+		this.types.push(t);
+		return t;
+	}
 }
diff --git a/experimental/traffic-portal/src/app/api/type.service.ts b/experimental/traffic-portal/src/app/api/type.service.ts
index 2fd0e20093..d24ccc5569 100644
--- a/experimental/traffic-portal/src/app/api/type.service.ts
+++ b/experimental/traffic-portal/src/app/api/type.service.ts
@@ -13,7 +13,7 @@
 */
 import { HttpClient } from "@angular/common/http";
 import { Injectable } from "@angular/core";
-import type { TypeFromResponse } from "trafficops-types";
+import type {RequestType, TypeFromResponse} from "trafficops-types";
 
 import { APIService } from "./base-api.service";
 
@@ -79,6 +79,38 @@ export class TypeService extends APIService {
 		return this.getTypesInTable("server");
 	}
 
+	/**
+	 * Deletes an existing type.
+	 *
+	 * @param typeOrId Id of the type to delete.
+	 * @returns The deleted type.
+	 */
+	public async deleteType(typeOrId: number | TypeFromResponse): Promise<TypeFromResponse> {
+		const id = typeof(typeOrId) === "number" ? typeOrId : typeOrId.id;
+		return this.delete<TypeFromResponse>(`types/${id}`).toPromise();
+	}
+
+	/**
+	 * Creates a new type.
+	 *
+	 * @param type The type to create.
+	 * @returns The created type.
+	 */
+	public async createType(type: RequestType): Promise<TypeFromResponse> {
+		return this.post<TypeFromResponse>("types", type).toPromise();
+	}
+
+	/**
+	 * Replaces the current definition of a type with the one given.
+	 *
+	 * @param type The new type.
+	 * @returns The updated type.
+	 */
+	public async updateType(type: TypeFromResponse): Promise<TypeFromResponse> {
+		const path = `types/${type.id}`;
+		return this.put<TypeFromResponse>(path, type).toPromise();
+	}
+
 	/**
 	 * Injects the Angular HTTP client service into the parent constructor.
 	 *
diff --git a/experimental/traffic-portal/src/app/core/core.module.ts b/experimental/traffic-portal/src/app/core/core.module.ts
index 84a171c083..de768aef99 100644
--- a/experimental/traffic-portal/src/app/core/core.module.ts
+++ b/experimental/traffic-portal/src/app/core/core.module.ts
@@ -47,6 +47,8 @@ import { PhysLocTableComponent } from "./servers/phys-loc/table/phys-loc-table.c
 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 {TypeDetailComponent} from "./types/detail/type-detail.component";
+import {TypesTableComponent} from "./types/table/types-table.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";
@@ -74,6 +76,8 @@ export const ROUTES: Routes = [
 	{ component: TenantDetailsComponent, path: "tenants/:id"},
 	{ component: PhysLocDetailComponent, path: "phys-locs/:id" },
 	{ component: PhysLocTableComponent, path: "phys-locs" },
+	{ component: TypesTableComponent, path: "types" },
+	{ component: TypeDetailComponent, path: "types/:id"},
 ].map(r => ({...r, canActivate: [AuthenticatedGuard]}));
 
 /**
@@ -107,7 +111,9 @@ export const ROUTES: Routes = [
 		DivisionDetailComponent,
 		RegionsTableComponent,
 		RegionDetailComponent,
-		CacheGroupDetailsComponent
+		CacheGroupDetailsComponent,
+		TypesTableComponent,
+		TypeDetailComponent
 	],
 	exports: [],
 	imports: [
diff --git a/experimental/traffic-portal/src/app/core/types/detail/type-detail.component.html b/experimental/traffic-portal/src/app/core/types/detail/type-detail.component.html
new file mode 100644
index 0000000000..24113fc291
--- /dev/null
+++ b/experimental/traffic-portal/src/app/core/types/detail/type-detail.component.html
@@ -0,0 +1,45 @@
+<!--
+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="!type"></tp-loading>
+	<form ngNativeValidate (ngSubmit)="submit($event)" *ngIf="type">
+		<mat-card-content>
+			<mat-form-field *ngIf="!new">
+				<mat-label>ID</mat-label>
+				<input matInput type="text" name="id" disabled readonly [defaultValue]="type.id" />
+			</mat-form-field>
+			<mat-form-field>
+				<mat-label>Name</mat-label>
+				<input matInput type="text" name="name" required [(ngModel)]="type.name" />
+			</mat-form-field>
+			<mat-form-field>
+				<mat-label>Description</mat-label>
+				<input matInput type="text" name="description" required [(ngModel)]="type.description" />
+			</mat-form-field>
+			<mat-form-field>
+				<mat-label>Use In Table</mat-label>
+				<input matInput type="text" name="useInTable" disabled readonly [defaultValue]="new ? 'server' : type.useInTable" />
+			</mat-form-field>
+			<mat-form-field *ngIf="!new">
+				<mat-label>Last Updated</mat-label>
+				<input matInput type="text" name="lastUpdated" disabled readonly [defaultValue]="type.lastUpdated" />
+			</mat-form-field>
+		</mat-card-content>
+		<mat-card-actions align="end">
+			<button mat-raised-button type="button" *ngIf="!new" color="warn" (click)="deleteType()">Delete</button>
+			<button mat-raised-button type="submit" color="primary">Save</button>
+		</mat-card-actions>
+	</form>
+</mat-card>
diff --git a/experimental/traffic-portal/src/app/core/types/detail/type-detail.component.scss b/experimental/traffic-portal/src/app/core/types/detail/type-detail.component.scss
new file mode 100644
index 0000000000..48848d2385
--- /dev/null
+++ b/experimental/traffic-portal/src/app/core/types/detail/type-detail.component.scss
@@ -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.
+*/
+
+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/types/detail/type-detail.component.spec.ts b/experimental/traffic-portal/src/app/core/types/detail/type-detail.component.spec.ts
new file mode 100644
index 0000000000..6dcd02eb41
--- /dev/null
+++ b/experimental/traffic-portal/src/app/core/types/detail/type-detail.component.spec.ts
@@ -0,0 +1,78 @@
+/*
+* 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 { MatDialogModule } from "@angular/material/dialog";
+import { ActivatedRoute } from "@angular/router";
+import { RouterTestingModule } from "@angular/router/testing";
+import { ReplaySubject } from "rxjs";
+
+import { APITestingModule } from "src/app/api/testing";
+import { TypeDetailComponent } from "src/app/core/types/detail/type-detail.component";
+import { NavigationService } from "src/app/shared/navigation/navigation.service";
+
+describe("TypeDetailComponent", () => {
+	let component: TypeDetailComponent;
+	let fixture: ComponentFixture<TypeDetailComponent>;
+	let route: ActivatedRoute;
+	let paramMap: jasmine.Spy;
+
+	const navSvc = jasmine.createSpyObj([],{headerHidden: new ReplaySubject<boolean>(), headerTitle: new ReplaySubject<string>()});
+	beforeEach(async () => {
+		await TestBed.configureTestingModule({
+			declarations: [ TypeDetailComponent ],
+			imports: [ APITestingModule, RouterTestingModule, MatDialogModule ],
+			providers: [ { provide: NavigationService, useValue: navSvc } ]
+		})
+			.compileComponents();
+
+		route = TestBed.inject(ActivatedRoute);
+		paramMap = spyOn(route.snapshot.paramMap, "get");
+		paramMap.and.returnValue(null);
+		fixture = TestBed.createComponent(TypeDetailComponent);
+		component = fixture.componentInstance;
+		fixture.detectChanges();
+	});
+
+	it("should create", () => {
+		expect(component).toBeTruthy();
+		expect(paramMap).toHaveBeenCalled();
+	});
+
+	it("new type", async () => {
+		paramMap.and.returnValue("new");
+
+		fixture = TestBed.createComponent(TypeDetailComponent);
+		component = fixture.componentInstance;
+		fixture.detectChanges();
+		await fixture.whenStable();
+		expect(paramMap).toHaveBeenCalled();
+		expect(component.type).not.toBeNull();
+		expect(component.type.name).toBe("");
+		expect(component.new).toBeTrue();
+	});
+
+	it("existing type", async () => {
+		paramMap.and.returnValue("1");
+
+		fixture = TestBed.createComponent(TypeDetailComponent);
+		component = fixture.componentInstance;
+		fixture.detectChanges();
+		await fixture.whenStable();
+		expect(paramMap).toHaveBeenCalled();
+		expect(component.type).not.toBeNull();
+		expect(component.type.name).toBe("MID_LOC");
+		expect(component.new).toBeFalse();
+	});
+});
diff --git a/experimental/traffic-portal/src/app/core/types/detail/type-detail.component.ts b/experimental/traffic-portal/src/app/core/types/detail/type-detail.component.ts
new file mode 100644
index 0000000000..2a88f7913f
--- /dev/null
+++ b/experimental/traffic-portal/src/app/core/types/detail/type-detail.component.ts
@@ -0,0 +1,109 @@
+/*
+* 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 { MatDialog } from "@angular/material/dialog";
+import { ActivatedRoute } from "@angular/router";
+import { TypeFromResponse } from "trafficops-types";
+
+import { TypeService } from "src/app/api";
+import { DecisionDialogComponent } from "src/app/shared/dialogs/decision-dialog/decision-dialog.component";
+import { NavigationService } from "src/app/shared/navigation/navigation.service";
+
+/**
+ * TypeDetailsComponent is the controller for the type add/edit form.
+ */
+@Component({
+	selector: "tp-types-detail",
+	styleUrls: ["./type-detail.component.scss"],
+	templateUrl: "./type-detail.component.html"
+})
+export class TypeDetailComponent implements OnInit {
+	public new = false;
+	public type!: TypeFromResponse;
+
+	constructor(private readonly route: ActivatedRoute, private readonly typeService: TypeService,
+		private readonly location: Location, private readonly dialog: MatDialog, private readonly navSvc: NavigationService) { }
+
+	/**
+	 * Angular lifecycle hook where data is initialized.
+	 */
+	public async ngOnInit(): Promise<void> {
+		const ID = this.route.snapshot.paramMap.get("id");
+		if (ID === null) {
+			console.error("missing required route parameter 'id'");
+			return;
+		}
+
+		if (ID === "new") {
+			this.navSvc.headerTitle.next("New Type");
+			this.new = true;
+			this.type = {
+				description: "",
+				id: -1,
+				lastUpdated: new Date(),
+				name: "",
+				useInTable: "server"
+			};
+			return;
+		}
+
+		const numID = parseInt(ID, 10);
+		if (Number.isNaN(numID)) {
+			console.error("route parameter 'id' was non-number: ", ID);
+			return;
+		}
+
+		this.type = await this.typeService.getTypes(numID);
+		this.navSvc.headerTitle.next(`Type: ${this.type.name}`);
+	}
+
+	/**
+	 * Deletes the current type.
+	 */
+	public async deleteType(): Promise<void> {
+		if (this.new) {
+			console.error("Unable to delete new type");
+			return;
+		}
+		const ref = this.dialog.open(DecisionDialogComponent, {
+			data: {message: `Are you sure you want to delete type ${this.type.name}`,
+				title: "Confirm Delete"}
+		});
+		ref.afterClosed().subscribe(result => {
+			if(result) {
+				this.typeService.deleteType(this.type.id);
+				this.location.back();
+			}
+		});
+	}
+
+	/**
+	 * Submits new/updated type.
+	 *
+	 * @param e HTML form submission event.
+	 */
+	public async submit(e: Event): Promise<void> {
+		e.preventDefault();
+		e.stopPropagation();
+		if(this.new) {
+			this.type = await this.typeService.createType(this.type);
+			this.new = false;
+		} else {
+			this.type = await this.typeService.updateType(this.type);
+		}
+	}
+
+}
diff --git a/experimental/traffic-portal/src/app/core/types/table/types-table.component.html b/experimental/traffic-portal/src/app/core/types/table/types-table.component.html
new file mode 100644
index 0000000000..fcc3049358
--- /dev/null
+++ b/experimental/traffic-portal/src/app/core/types/table/types-table.component.html
@@ -0,0 +1,29 @@
+<!--
+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 class="table-page-content">
+	<div class="search-container">
+		<input type="search" name="fuzzControl" aria-label="Fuzzy Search Types" autofocus inputmode="search" role="search" accesskey="/" placeholder="Fuzzy Search" [formControl]="fuzzControl" (input)="updateURL()" />
+	</div>
+	<tp-generic-table
+		[data]="types | async"
+		[cols]="columnDefs"
+		[fuzzySearch]="fuzzySubject"
+		context="types"
+		[contextMenuItems]="contextMenuItems"
+		(contextMenuAction)="handleContextMenu($event)">
+	</tp-generic-table>
+</mat-card>
+
+<a class="page-fab" mat-fab title="Create a new Type" *ngIf="auth.hasPermission('TYPE:CREATE')" routerLink="new"><mat-icon>add</mat-icon></a>
diff --git a/experimental/traffic-portal/src/app/core/types/table/types-table.component.scss b/experimental/traffic-portal/src/app/core/types/table/types-table.component.scss
new file mode 100644
index 0000000000..a76ede4a23
--- /dev/null
+++ b/experimental/traffic-portal/src/app/core/types/table/types-table.component.scss
@@ -0,0 +1,14 @@
+/*
+* 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.
+*/
+
diff --git a/experimental/traffic-portal/src/app/core/types/table/types-table.component.spec.ts b/experimental/traffic-portal/src/app/core/types/table/types-table.component.spec.ts
new file mode 100644
index 0000000000..ff31b9767a
--- /dev/null
+++ b/experimental/traffic-portal/src/app/core/types/table/types-table.component.spec.ts
@@ -0,0 +1,166 @@
+/*
+* 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, fakeAsync, TestBed, tick } from "@angular/core/testing";
+import { MatDialog, MatDialogModule, type MatDialogRef } from "@angular/material/dialog";
+import { ActivatedRoute } from "@angular/router";
+import { RouterTestingModule } from "@angular/router/testing";
+import { of } from "rxjs";
+
+import { TypeService } from "src/app/api";
+import { APITestingModule } from "src/app/api/testing";
+import { TypesTableComponent } from "src/app/core/types/table/types-table.component";
+import { isAction } from "src/app/shared/generic-table/generic-table.component";
+
+const testType = {
+	description: "TestDescription",
+	id: 1,
+	lastUpdated: new Date(),
+	name: "TestQuest",
+	useInTable: "server"
+};
+
+describe("TypesTableComponent", () => {
+	let component: TypesTableComponent;
+	let fixture: ComponentFixture<TypesTableComponent>;
+
+	beforeEach(async () => {
+		await TestBed.configureTestingModule({
+			declarations: [ TypesTableComponent ],
+			imports: [
+				APITestingModule,
+				RouterTestingModule,
+				MatDialogModule
+			]
+		}).compileComponents();
+
+		fixture = TestBed.createComponent(TypesTableComponent);
+		component = fixture.componentInstance;
+		fixture.detectChanges();
+	});
+
+	it("should create", () => {
+		expect(component).toBeTruthy();
+	});
+
+	it("sets the fuzzy search subject based on the search query param", fakeAsync(() => {
+		const router = TestBed.inject(ActivatedRoute);
+		const searchString = "testquest";
+		spyOnProperty(router, "queryParamMap").and.returnValue(of(new Map([["search", searchString]])));
+
+		let searchValue = "not the right string";
+		component.fuzzySubject.subscribe(
+			s => searchValue = s
+		);
+
+		component.ngOnInit();
+		tick();
+
+		expect(searchValue).toBe(searchString);
+	}));
+
+	it("updates the fuzzy search output", fakeAsync(() => {
+		let called = false;
+		const text = "testquest";
+		const spy = jasmine.createSpy("subscriber", (txt: string): void =>{
+			if (!called) {
+				expect(txt).toBe("");
+				called = true;
+			} else {
+				expect(txt).toBe(text);
+			}
+		});
+		component.fuzzySubject.subscribe(spy);
+		tick();
+		expect(spy).toHaveBeenCalled();
+		component.fuzzControl.setValue(text);
+		component.updateURL();
+		tick();
+		expect(spy).toHaveBeenCalledTimes(2);
+	}));
+
+	it("handles unrecognized contextmenu events", () => {
+		expect(async () => component.handleContextMenu({
+			action: component.contextMenuItems[0].name,
+			data: {description: "Type Description", id: 1, lastUpdated: new Date(), name: "Type", useInTable: "server"}
+		})).not.toThrow();
+	});
+
+	it("handles the 'delete' context menu item", fakeAsync(async () => {
+		const item = component.contextMenuItems.find(c => c.name === "Delete");
+		if (!item) {
+			return fail("missing 'Delete' context menu item");
+		}
+		if (!isAction(item)) {
+			return fail("expected an action, not a link");
+		}
+		expect(item.multiRow).toBeFalsy();
+		expect(item.disabled).toBeUndefined();
+
+		const api = TestBed.inject(TypeService);
+		const spy = spyOn(api, "deleteType").and.callThrough();
+		expect(spy).not.toHaveBeenCalled();
+
+		const dialogService = TestBed.inject(MatDialog);
+		const openSpy = spyOn(dialogService, "open").and.returnValue({
+			afterClosed: () => of(true)
+		} as MatDialogRef<unknown>);
+
+		const type = await api.createType({description: "blah", name: "test", useInTable: "server"});
+		expect(openSpy).not.toHaveBeenCalled();
+		const asyncExpectation = expectAsync(component.handleContextMenu({action: "delete", data: type})).toBeResolvedTo(undefined);
+		tick();
+
+		expect(openSpy).toHaveBeenCalled();
+		tick();
+
+		expect(spy).toHaveBeenCalled();
+
+		await asyncExpectation;
+	}));
+
+	it("generates 'Edit' context menu item href", () => {
+		const item = component.contextMenuItems.find(i => i.name === "Edit");
+		if (!item) {
+			return fail("missing 'Edit' context menu item");
+		}
+		if (isAction(item)) {
+			return fail("expected a link, not an action");
+		}
+		if (typeof(item.href) !== "function") {
+			return fail(`'Edit' context menu item should use a function to determine href, instead uses: ${item.href}`);
+		}
+		expect(item.href(testType)).toBe(String(testType.id));
+		expect(item.queryParams).toBeUndefined();
+		expect(item.fragment).toBeUndefined();
+		expect(item.newTab).toBeFalsy();
+	});
+
+	it("generates 'Open in New Tab' context menu item href", () => {
+		const item = component.contextMenuItems.find(i => i.name === "Open in New Tab");
+		if (!item) {
+			return fail("missing 'Open in New Tab' context menu item");
+		}
+		if (isAction(item)) {
+			return fail("expected a link, not an action");
+		}
+		if (typeof(item.href) !== "function") {
+			return fail(`'Open in New Tab' context menu item should use a function to determine href, instead uses: ${item.href}`);
+		}
+		expect(item.href(testType)).toBe(String(testType.id));
+		expect(item.queryParams).toBeUndefined();
+		expect(item.fragment).toBeUndefined();
+		expect(item.newTab).toBeTrue();
+	});
+});
diff --git a/experimental/traffic-portal/src/app/core/types/table/types-table.component.ts b/experimental/traffic-portal/src/app/core/types/table/types-table.component.ts
new file mode 100644
index 0000000000..da25511616
--- /dev/null
+++ b/experimental/traffic-portal/src/app/core/types/table/types-table.component.ts
@@ -0,0 +1,133 @@
+/*
+* 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 { Component, type OnInit } from "@angular/core";
+import { FormControl } from "@angular/forms";
+import { MatDialog } from "@angular/material/dialog";
+import { ActivatedRoute } from "@angular/router";
+import { BehaviorSubject } from "rxjs";
+import { TypeFromResponse } from "trafficops-types";
+
+import { TypeService } from "src/app/api";
+import { CurrentUserService } from "src/app/shared/currentUser/current-user.service";
+import { DecisionDialogComponent } from "src/app/shared/dialogs/decision-dialog/decision-dialog.component";
+import type { ContextMenuActionEvent, ContextMenuItem } from "src/app/shared/generic-table/generic-table.component";
+import { NavigationService } from "src/app/shared/navigation/navigation.service";
+
+/**
+ * TypesTableComponent is the controller for the "Types" table.
+ */
+@Component({
+	selector: "tp-types",
+	styleUrls: ["./types-table.component.scss"],
+	templateUrl: "./types-table.component.html"
+})
+export class TypesTableComponent implements OnInit {
+	/** List of types */
+	public types: Promise<Array<TypeFromResponse>>;
+
+	/** Definitions of the table's columns according to the ag-grid API */
+	public columnDefs = [
+		{
+			field: "id",
+			headerName: "ID"
+		},
+		{
+			field: "name",
+			headerName: "Name"
+		},
+		{
+			field: "description",
+			headerName: "Description"
+		},
+		{
+			field: "useInTable",
+			headerName: "Use In Table"
+		},
+		{
+			field: "lastUpdated",
+			headerName: "Last Updated"
+		}
+	];
+
+	/** Definitions for the context menu items (which act on augmented type data). */
+	public contextMenuItems: Array<ContextMenuItem<TypeFromResponse>> = [
+		{
+			href: (type: TypeFromResponse): string => `${type.id}`,
+			name: "Edit"
+		},
+		{
+			href: (type: TypeFromResponse): string => `${type.id}`,
+			name: "Open in New Tab",
+			newTab: true
+		},
+		{
+			action: "delete",
+			multiRow: false,
+			name: "Delete"
+		}
+	];
+
+	/** A subject that child components can subscribe to for access to the fuzzy search query text */
+	public fuzzySubject: BehaviorSubject<string>;
+
+	/** Form controller for the user search input. */
+	public fuzzControl = new FormControl<string>("", {nonNullable: true});
+
+	constructor(private readonly route: ActivatedRoute, private readonly navSvc: NavigationService,
+		private readonly api: TypeService, private readonly dialog: MatDialog, public readonly auth: CurrentUserService) {
+		this.fuzzySubject = new BehaviorSubject<string>("");
+		this.types = this.api.getTypes();
+		this.navSvc.headerTitle.next("Types");
+	}
+
+	/** Initializes table data, loading it from Traffic Ops. */
+	public ngOnInit(): void {
+		this.route.queryParamMap.subscribe(
+			m => {
+				const search = m.get("search");
+				if (search) {
+					this.fuzzControl.setValue(decodeURIComponent(search));
+					this.updateURL();
+				}
+			}
+		);
+	}
+
+	/** Update the URL's 'search' query parameter for the user's search input. */
+	public updateURL(): void {
+		this.fuzzySubject.next(this.fuzzControl.value);
+	}
+
+	/**
+	 * Handles a context menu event.
+	 *
+	 * @param evt The action selected from the context menu.
+	 */
+	public async handleContextMenu(evt: ContextMenuActionEvent<TypeFromResponse>): Promise<void> {
+		const data = evt.data as TypeFromResponse;
+		switch(evt.action) {
+			case "delete":
+				const ref = this.dialog.open(DecisionDialogComponent, {
+					data: {message: `Are you sure you want to delete type ${data.name} with id ${data.id} ?`, title: "Confirm Delete"}
+				});
+				ref.afterClosed().subscribe(result => {
+					if(result) {
+						this.api.deleteType(data.id).then(async () => this.types = this.api.getTypes());
+					}
+				});
+				break;
+		}
+	}
+}
diff --git a/experimental/traffic-portal/src/app/shared/navigation/navigation.service.ts b/experimental/traffic-portal/src/app/shared/navigation/navigation.service.ts
index c1168c315f..aabd53bde4 100644
--- a/experimental/traffic-portal/src/app/shared/navigation/navigation.service.ts
+++ b/experimental/traffic-portal/src/app/shared/navigation/navigation.service.ts
@@ -149,6 +149,12 @@ export class NavigationService {
 				name: "Change Logs"
 			}],
 			name: "Other"
+		}, {
+			children: [{
+				href: "/core/types",
+				name: "Types"
+			}],
+			name: "Configuration"
 		}]);
 	}