You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@trafficcontrol.apache.org by sh...@apache.org on 2023/04/18 16:28:34 UTC

[trafficcontrol] branch master updated: Yuengling profile table parity (#7434)

This is an automated email from the ASF dual-hosted git repository.

shamrick 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 54e509bfe6 Yuengling profile table parity (#7434)
54e509bfe6 is described below

commit 54e509bfe60b073cb488a088c22c2337b35dff9b
Author: Kannan.G.B <11...@users.noreply.github.com>
AuthorDate: Tue Apr 18 21:58:27 2023 +0530

    Yuengling profile table parity (#7434)
    
    * navigation for profile
    
    * profile table for parity CDN-19162
    
    * latest changes
    
    * comment addressed
    
    * duplicate configuration removed, older restored
    
    * config order updated
    
    * e2e test
    
    * lint fix
    
    * added profiles in sidebar object
    
    * container corrected
---
 .../traffic-portal/nightwatch/globals/globals.ts   |  21 +++
 .../nightwatch/page_objects/common.ts              |   1 +
 .../page_objects/profiles/profilesTable.ts         |  46 +++++++
 .../nightwatch/tests/profiles/table.spec.ts        |  26 ++++
 .../traffic-portal/src/app/api/profile.service.ts  |  24 +++-
 .../src/app/api/testing/profile.service.ts         |  36 ++++-
 .../traffic-portal/src/app/core/core.module.ts     |   3 +
 .../profile-table/profile-table.component.html     |  27 ++++
 .../profile-table/profile-table.component.scss     |  13 ++
 .../profile-table/profile-table.component.spec.ts  | 126 ++++++++++++++++++
 .../profile-table/profile-table.component.ts       | 148 +++++++++++++++++++++
 .../app/shared/navigation/navigation.service.ts    |   4 +
 12 files changed, 472 insertions(+), 3 deletions(-)

diff --git a/experimental/traffic-portal/nightwatch/globals/globals.ts b/experimental/traffic-portal/nightwatch/globals/globals.ts
index 5db7fdc9a9..afcd947938 100644
--- a/experimental/traffic-portal/nightwatch/globals/globals.ts
+++ b/experimental/traffic-portal/nightwatch/globals/globals.ts
@@ -32,6 +32,7 @@ import type { DeliveryServiceCardPageObject } from "nightwatch/page_objects/deli
 import type { DeliveryServiceDetailPageObject } from "nightwatch/page_objects/deliveryServices/deliveryServiceDetail";
 import type { DeliveryServiceInvalidPageObject } from "nightwatch/page_objects/deliveryServices/deliveryServiceInvalidationJobs";
 import type { LoginPageObject } from "nightwatch/page_objects/login";
+import type { ProfilePageObject } from "nightwatch/page_objects/profiles/profilesTable";
 import type { PhysLocDetailPageObject } from "nightwatch/page_objects/servers/physLocDetail";
 import type { PhysLocTablePageObject } from "nightwatch/page_objects/servers/physLocTable";
 import type { ServersPageObject } from "nightwatch/page_objects/servers/servers";
@@ -65,6 +66,9 @@ import {
 	ResponseCoordinate,
 	RequestCoordinate,
 	RequestType,
+	ResponseProfile,
+	RequestProfile,
+	ProfileType
 } from "trafficops-types";
 
 import * as config from "../config.json";
@@ -98,6 +102,9 @@ declare module "nightwatch" {
 			deliveryServiceInvalidationJobs: () => DeliveryServiceInvalidPageObject;
 		};
 		login: () => LoginPageObject;
+		profiles: {
+			profileTable: () => ProfilePageObject;
+		};
 		servers: {
 			physLocDetail: () => PhysLocDetailPageObject;
 			physLocTable: () => PhysLocTablePageObject;
@@ -144,6 +151,7 @@ export interface CreatedData {
 	steeringDS: ResponseDeliveryService;
 	tenant: ResponseTenant;
 	type: TypeFromResponse;
+	profile: ResponseProfile;
 }
 
 const testData = {};
@@ -382,6 +390,19 @@ const globals = {
 			console.log(`Successfully created Type ${respType.name}`);
 			data.type = respType;
 
+			const profile: RequestProfile = {
+				cdn: 1,
+				description: "blah",
+				name: `profile${globals.uniqueString}`,
+				routingDisabled: false,
+				type: ProfileType.ATS_PROFILE,
+			};
+			url = `${apiUrl}/profiles`;
+			resp = await client.post(url, JSON.stringify(profile));
+			const respProfile: ResponseProfile = resp.data.response;
+			console.log(`Successfully created Profile ${respProfile.name}`);
+			data.profile = respProfile;
+
 		} catch(e) {
 			console.error("Request for", url, "failed:", (e as AxiosError).message);
 			throw e;
diff --git a/experimental/traffic-portal/nightwatch/page_objects/common.ts b/experimental/traffic-portal/nightwatch/page_objects/common.ts
index 9c4b975a6f..091b647a65 100644
--- a/experimental/traffic-portal/nightwatch/page_objects/common.ts
+++ b/experimental/traffic-portal/nightwatch/page_objects/common.ts
@@ -59,6 +59,7 @@ const commonPageObject = {
 				otherContainer: "[aria-label='Toggle Other']",
 				physicalLocations: "[aria-label='Navigate to Physical Locations']",
 				profile: "[aria-label='Navigate to My Profile']",
+				profiles: "[aria-label='Navigate to Profiles']",
 				regions: "[aria-label='Navigate to Regions']",
 				servers: "[aria-label='Navigate to Servers']",
 				serversContainer: "[aria-label='Toggle Servers']",
diff --git a/experimental/traffic-portal/nightwatch/page_objects/profiles/profilesTable.ts b/experimental/traffic-portal/nightwatch/page_objects/profiles/profilesTable.ts
new file mode 100644
index 0000000000..60e36b9cb9
--- /dev/null
+++ b/experimental/traffic-portal/nightwatch/page_objects/profiles/profilesTable.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 Profiles table commands
+ */
+type ProfileTableCommands = TableSectionCommands;
+
+/**
+ * Defines the Page Object for the Profiles page.
+ */
+export type ProfilePageObject = EnhancedPageObject<{}, {},
+EnhancedSectionInstance<ProfileTableCommands>>;
+
+const profilePageObject = {
+	api: {} as NightwatchAPI,
+	sections: {
+		profileTable: {
+			commands: {
+				...TABLE_COMMANDS
+			},
+			elements: {},
+			selector: "mat-card"
+		}
+	},
+	url(): string {
+		return `${this.api.launchUrl}/core/profiles`;
+	}
+};
+
+export default profilePageObject;
diff --git a/experimental/traffic-portal/nightwatch/tests/profiles/table.spec.ts b/experimental/traffic-portal/nightwatch/tests/profiles/table.spec.ts
new file mode 100644
index 0000000000..f7a2e78e30
--- /dev/null
+++ b/experimental/traffic-portal/nightwatch/tests/profiles/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.
+ */
+
+describe("Profiles Spec", () => {
+	it("Loads elements", async () => {
+		await browser.page.common()
+			.section.sidebar
+			.navigateToNode("profiles", ["configurationContainer"]);
+		await browser.waitForElementPresent("input[name=fuzzControl]");
+		await browser.elements("css selector", "div.ag-row", rows => {
+			browser.assert.ok(rows.status === 0);
+			browser.assert.ok((rows.value as []).length >= 1);
+		});
+	});
+});
diff --git a/experimental/traffic-portal/src/app/api/profile.service.ts b/experimental/traffic-portal/src/app/api/profile.service.ts
index bf638dbbf1..af494b50c6 100644
--- a/experimental/traffic-portal/src/app/api/profile.service.ts
+++ b/experimental/traffic-portal/src/app/api/profile.service.ts
@@ -14,7 +14,7 @@
 
 import { HttpClient } from "@angular/common/http";
 import { Injectable } from "@angular/core";
-import { ResponseProfile } from "trafficops-types";
+import { RequestProfile, ResponseProfile } from "trafficops-types";
 
 import { APIService } from "./base-api.service";
 
@@ -57,4 +57,26 @@ export class ProfileService extends APIService {
 		}
 		return this.get<Array<ResponseProfile>>(path).toPromise();
 	}
+
+	/**
+	 * Creates a new type.
+	 *
+	 * @param profile The type to create.
+	 * @returns The created type.
+	 */
+	public async createProfile(profile: RequestProfile): Promise<ResponseProfile> {
+		return this.post<ResponseProfile>("profiles", profile).toPromise();
+	}
+
+	/**
+	 * Deletes an existing type.
+	 *
+	 * @param profileId Id of the profile to delete.
+	 * @returns The success message.
+	 */
+	public async deleteProfile(profileId: number | ResponseProfile): Promise<ResponseProfile> {
+		const id = typeof (profileId) === "number" ? profileId : profileId.id;
+		return this.delete<ResponseProfile>(`profiles/${id}`).toPromise();
+	}
+
 }
diff --git a/experimental/traffic-portal/src/app/api/testing/profile.service.ts b/experimental/traffic-portal/src/app/api/testing/profile.service.ts
index 5f25ad622e..1f4a96b174 100644
--- a/experimental/traffic-portal/src/app/api/testing/profile.service.ts
+++ b/experimental/traffic-portal/src/app/api/testing/profile.service.ts
@@ -13,14 +13,14 @@
 */
 
 import { Injectable } from "@angular/core";
-import { ProfileType, type ResponseProfile } from "trafficops-types";
+import { ProfileType, RequestProfile, type ResponseProfile } from "trafficops-types";
 
 /**
  * ProfileService exposes API functionality related to Profiles.
  */
 @Injectable()
 export class ProfileService {
-
+	private lastID = 10;
 	private readonly profiles: ResponseProfile[] = [
 		{
 			cdn: 1,
@@ -173,4 +173,36 @@ export class ProfileService {
 			})
 		);
 	}
+
+	/**
+	 * Creates a new profile.
+	 *
+	 * @param profile The profile to create.
+	 * @returns The created profile.
+	 */
+	public async createProfile(profile: RequestProfile): Promise<ResponseProfile> {
+		const t = {
+			...profile,
+			cdnName: null,
+			id: ++this.lastID,
+			lastUpdated: new Date()
+		};
+		this.profiles.push(t);
+		return t;
+	}
+
+	/**
+	 * Deletes an existing profile.
+	 *
+	 * @param id Id of the profile to delete.
+	 * @returns The success message.
+	 */
+	public async deleteProfile(id: number): Promise<ResponseProfile> {
+		const index = this.profiles.findIndex(t => t.id === id);
+		if (index === -1) {
+			throw new Error(`no such Type: ${id}`);
+		}
+		return this.profiles.splice(index, 1)[0];
+	}
+
 }
diff --git a/experimental/traffic-portal/src/app/core/core.module.ts b/experimental/traffic-portal/src/app/core/core.module.ts
index e4f0aa3123..9828ae033c 100644
--- a/experimental/traffic-portal/src/app/core/core.module.ts
+++ b/experimental/traffic-portal/src/app/core/core.module.ts
@@ -48,6 +48,7 @@ import {
 } from "./deliveryservice/invalidation-jobs/new-invalidation-job-dialog/new-invalidation-job-dialog.component";
 import { NewDeliveryServiceComponent } from "./deliveryservice/new-delivery-service/new-delivery-service.component";
 import { ISOGenerationFormComponent } from "./misc/isogeneration-form/isogeneration-form.component";
+import { ProfileTableComponent } from "./profiles/profile-table/profile-table.component";
 import { PhysLocDetailComponent } from "./servers/phys-loc/detail/phys-loc-detail.component";
 import { PhysLocTableComponent } from "./servers/phys-loc/table/phys-loc-table.component";
 import { ServerDetailsComponent } from "./servers/server-details/server-details.component";
@@ -90,6 +91,7 @@ export const ROUTES: Routes = [
 	{ component: TypesTableComponent, path: "types" },
 	{ component: TypeDetailComponent, path: "types/:id"},
 	{ component: ISOGenerationFormComponent, path: "iso-gen"},
+	{ component: ProfileTableComponent, path: "profiles"},
 ].map(r => ({...r, canActivate: [AuthenticatedGuard]}));
 
 /**
@@ -131,6 +133,7 @@ export const ROUTES: Routes = [
 		TypesTableComponent,
 		TypeDetailComponent,
 		ISOGenerationFormComponent,
+  		ProfileTableComponent,
 		CDNDetailComponent,
 	],
 	exports: [],
diff --git a/experimental/traffic-portal/src/app/core/profiles/profile-table/profile-table.component.html b/experimental/traffic-portal/src/app/core/profiles/profile-table/profile-table.component.html
new file mode 100644
index 0000000000..121b7cdcd9
--- /dev/null
+++ b/experimental/traffic-portal/src/app/core/profiles/profile-table/profile-table.component.html
@@ -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 class="table-page-content">
+	<div class="search-container">
+		<input type="search" name="fuzzControl" aria-label="Fuzzy Search Profiles" autofocus inputmode="search" role="search" accesskey="/" placeholder="Fuzzy Search" [formControl]="fuzzControl" (input)="updateURL()" />
+	</div>
+	<tp-generic-table
+		[data]="profiles | async"
+		[cols]="columnDefs"
+		[fuzzySearch]="fuzzySubject"
+		context="profiles"
+		[contextMenuItems]="contextMenuItems"
+		(contextMenuAction)="handleContextMenu($event)">
+	</tp-generic-table>
+</mat-card>
diff --git a/experimental/traffic-portal/src/app/core/profiles/profile-table/profile-table.component.scss b/experimental/traffic-portal/src/app/core/profiles/profile-table/profile-table.component.scss
new file mode 100644
index 0000000000..ebe77042d3
--- /dev/null
+++ b/experimental/traffic-portal/src/app/core/profiles/profile-table/profile-table.component.scss
@@ -0,0 +1,13 @@
+/*
+* 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/profiles/profile-table/profile-table.component.spec.ts b/experimental/traffic-portal/src/app/core/profiles/profile-table/profile-table.component.spec.ts
new file mode 100644
index 0000000000..53326e993d
--- /dev/null
+++ b/experimental/traffic-portal/src/app/core/profiles/profile-table/profile-table.component.spec.ts
@@ -0,0 +1,126 @@
+/*
+* 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, fakeAsync, 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 { ProfileType } from "trafficops-types";
+
+import { ProfileService } from "src/app/api";
+import { APITestingModule } from "src/app/api/testing";
+import { isAction } from "src/app/shared/generic-table/generic-table.component";
+
+import { ProfileTableComponent } from "./profile-table.component";
+
+describe("ProfileTableComponent", () => {
+	let component: ProfileTableComponent;
+	let fixture: ComponentFixture<ProfileTableComponent>;
+
+	beforeEach(async () => {
+		await TestBed.configureTestingModule({
+			declarations: [ProfileTableComponent],
+			imports: [
+				APITestingModule,
+				RouterTestingModule,
+				MatDialogModule
+			]
+		})
+			.compileComponents();
+
+		fixture = TestBed.createComponent(ProfileTableComponent);
+		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 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(ProfileService);
+		const spy = spyOn(api, "deleteProfile").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 profile = await api.createProfile({
+			cdn: 1,
+			description: "blah",
+			name: "test",
+			routingDisabled: false,
+			type: ProfileType.ATS_PROFILE
+		});
+		expect(openSpy).not.toHaveBeenCalled();
+		const asyncExpectation = expectAsync(component.handleContextMenu({ action: "delete", data: profile })).toBeResolvedTo(undefined);
+		tick();
+
+		expect(openSpy).toHaveBeenCalled();
+		tick();
+
+		expect(spy).toHaveBeenCalled();
+
+		await asyncExpectation;
+	}));
+});
diff --git a/experimental/traffic-portal/src/app/core/profiles/profile-table/profile-table.component.ts b/experimental/traffic-portal/src/app/core/profiles/profile-table/profile-table.component.ts
new file mode 100644
index 0000000000..075ee04d23
--- /dev/null
+++ b/experimental/traffic-portal/src/app/core/profiles/profile-table/profile-table.component.ts
@@ -0,0 +1,148 @@
+/*
+* 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, OnInit } from "@angular/core";
+import { FormControl, UntypedFormControl } from "@angular/forms";
+import { MatDialog } from "@angular/material/dialog";
+import { ActivatedRoute, Params } from "@angular/router";
+import { BehaviorSubject } from "rxjs";
+import { ResponseProfile } from "trafficops-types";
+
+import { ProfileService } from "src/app/api";
+import { CurrentUserService } from "src/app/shared/current-user/current-user.service";
+import { DecisionDialogComponent } from "src/app/shared/dialogs/decision-dialog/decision-dialog.component";
+import { ContextMenuActionEvent, ContextMenuItem } from "src/app/shared/generic-table/generic-table.component";
+import { NavigationService } from "src/app/shared/navigation/navigation.service";
+
+/**
+ * ProfileTableComponent is the controller for the profiles page - which
+ * principally contains a table.
+ */
+@Component({
+	selector: "tp-profile-table",
+	styleUrls: ["./profile-table.component.scss"],
+	templateUrl: "./profile-table.component.html"
+})
+export class ProfileTableComponent implements OnInit {
+	/** All the physical locations which should appear in the table. */
+	public profiles: Promise<Array<ResponseProfile>>;
+
+  	/** Definitions of the table's columns according to the ag-grid API */
+	public columnDefs = [{
+		field: "cdnName",
+		headerName: "CDN"
+	}, {
+		field: "description",
+		headerName: "Description",
+	}, {
+		field: "id",
+		headerName: "ID",
+		hide: true
+	}, {
+		field: "lastUpdated",
+		headerName: "Last Updated",
+		hide: true
+	}, {
+		field: "name",
+		headerName: "Name"
+	}, {
+		field: "routingDisabled",
+		headerName: "Routing Disabled"
+	}, {
+		field: "type",
+		headerName: "Type"
+	}];
+
+	/** Definitions for the context menu items (which act on augmented cache-group data). */
+	public contextMenuItems: Array<ContextMenuItem<ResponseProfile>> = [
+		{
+			action: "delete",
+			multiRow: false,
+			name: "Delete"
+		},
+		{
+			href: "/core/servers",
+			name: "View Servers",
+			queryParams: (profile: ResponseProfile): Params => ({profileName: profile.name})
+		}
+	];
+
+	/** 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: UntypedFormControl = new FormControl<string>("", {nonNullable: true});
+
+	/**
+	 * Constructs the component with its required injections.
+	 *
+	 * @param api The Servers API which is used to provide row data.
+	 * @param route A reference to the route of this view which is used to set the fuzzy search box text from the 'search' query parameter.
+	 * @param router Angular router
+	 * @param navSvc Manages the header
+	 * @param dialog Dialog manager
+	 */
+	constructor(
+		private readonly api: ProfileService,
+		private readonly route: ActivatedRoute,
+		private readonly navSvc: NavigationService,
+		private readonly dialog: MatDialog,
+		public readonly auth: CurrentUserService) {
+		this.fuzzySubject = new BehaviorSubject<string>("");
+		this.profiles = this.api.getProfiles();
+		this.navSvc.headerTitle.next("Profiles");
+	}
+
+	/** 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<ResponseProfile>): Promise<void> {
+		const data = evt.data as ResponseProfile;
+		switch (evt.action) {
+			case "delete":
+				const ref = this.dialog.open(DecisionDialogComponent, {
+					data: {
+						message: `Are you sure to delete Profile ${data.name} with id ${data.id}?`,
+						title: "Confirm Delete"
+					}
+				});
+				ref.afterClosed().subscribe(result => {
+					if (result) {
+						this.api.deleteProfile(data.id).then(async () => this.profiles = this.api.getProfiles());
+					}
+				});
+				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 b85b2d69e2..14c0dff7aa 100644
--- a/experimental/traffic-portal/src/app/shared/navigation/navigation.service.ts
+++ b/experimental/traffic-portal/src/app/shared/navigation/navigation.service.ts
@@ -141,6 +141,10 @@ export class NavigationService {
 			children: [{
 				href: "/core/types",
 				name: "Types"
+			},
+			{
+				href: "/core/profiles",
+				name: "Profiles"
 			}],
 			name: "Configuration"
 		}, {