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 ff5a606a9e Tpv2 param table/ details parity (#7480)
ff5a606a9e is described below

commit ff5a606a9eca2040b09a1fa37f312825296ef5fd
Author: Srijeet Chatterjee <[email protected]>
AuthorDate: Wed May 31 19:21:31 2023 -0600

    Tpv2 param table/ details parity (#7480)
    
    * wip
    
    * wip
    
    * tests passing
    
    * view profiles working
    
    * change heading
    
    * code review round 1
    
    * code review round 2
    
    * code review round 3
    
    * fix tests
    
    * code review
    
    * fix linting
    
    * address code review
    
    * linting
    
    * adding tests, addressing code review
    
    * code review
    
    * negative test
    
    * fix linting again
    
    * Adding double click functionality
    
    * formatting changes
    
    * add test methods
    
    * change id to index of array
    
    * change param value to textarea
    
    * fix textarea
    
    * fix html for textarea
    
    * add cdkTextareaAutosize
    
    * fix selector
---
 .../traffic-portal/nightwatch/dataClient.ts        |  14 ++
 .../traffic-portal/nightwatch/globals/globals.ts   |   7 +-
 .../nightwatch/page_objects/common.ts              |   2 +
 .../page_objects/parameters/parameterDetail.ts     |  48 ++++++
 .../page_objects/parameters/parametersTable.ts     |  45 +++++
 .../nightwatch/tests/parameters/detail.spec.ts     |  47 +++++
 .../tests/{profiles => parameters}/table.spec.ts   |   6 +-
 .../nightwatch/tests/profiles/table.spec.ts        |   2 +-
 .../src/app/api/profile.service.spec.ts            | 107 +++++++++++-
 .../traffic-portal/src/app/api/profile.service.ts  |  75 +++++++-
 .../src/app/api/testing/profile.service.ts         | 107 +++++++++++-
 .../traffic-portal/src/app/core/core.module.ts     |  10 +-
 .../detail/parameter-detail.component.html         |  46 +++++
 .../detail/parameter-detail.component.scss         |  13 ++
 .../detail/parameter-detail.component.spec.ts      |  83 +++++++++
 .../detail/parameter-detail.component.ts           | 114 ++++++++++++
 .../table/parameters-table.component.html          |  30 ++++
 .../table/parameters-table.component.scss          |  14 ++
 .../table/parameters-table.component.spec.ts       | 192 +++++++++++++++++++++
 .../parameters/table/parameters-table.component.ts | 158 +++++++++++++++++
 .../profile-table/profile-table.component.ts       |   5 +
 .../app/shared/navigation/navigation.service.ts    |  27 ++-
 22 files changed, 1134 insertions(+), 18 deletions(-)

diff --git a/experimental/traffic-portal/nightwatch/dataClient.ts 
b/experimental/traffic-portal/nightwatch/dataClient.ts
index f7af8b1729..67d267700c 100644
--- a/experimental/traffic-portal/nightwatch/dataClient.ts
+++ b/experimental/traffic-portal/nightwatch/dataClient.ts
@@ -28,6 +28,7 @@ import {
        RequestCoordinate,
        RequestDeliveryService,
        RequestDivision,
+       RequestParameter,
        RequestPhysicalLocation,
        RequestProfile,
        RequestRegion,
@@ -41,6 +42,7 @@ import {
        ResponseCacheGroup,
        ResponseDeliveryService,
        ResponseDivision,
+       ResponseParameter,
        ResponsePhysicalLocation,
        ResponseProfile,
        ResponseRegion,
@@ -319,6 +321,18 @@ export class DataClient {
                        const respProfile: ResponseProfile = resp.data.response;
                        data.profile = respProfile;
 
+                       const parameter: RequestParameter = {
+                               configFile: "cfg.txt",
+                               name: `param${id}`,
+                               secure: false,
+                               value: "10",
+                       };
+                       url = `${apiUrl}/parameters`;
+                       resp = await this.client.post(url, 
JSON.stringify(parameter));
+                       const responseParameter: ResponseParameter = 
resp.data.response;
+                       console.log(`Successfully created Parameter 
${responseParameter.name}`);
+                       data.parameter = responseParameter;
+
                        const server: RequestServer = {
                                cachegroupId: responseCG.id,
                                cdnId: respCDN.id,
diff --git a/experimental/traffic-portal/nightwatch/globals/globals.ts 
b/experimental/traffic-portal/nightwatch/globals/globals.ts
index cabede372d..4eaf080822 100644
--- a/experimental/traffic-portal/nightwatch/globals/globals.ts
+++ b/experimental/traffic-portal/nightwatch/globals/globals.ts
@@ -30,6 +30,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 { ParametersPageObject } from 
"nightwatch/page_objects/parameters/parametersTable";
 import type { ProfileDetailPageObject } from 
"nightwatch/page_objects/profiles/profileDetail";
 import type { ProfilePageObject } from 
"nightwatch/page_objects/profiles/profilesTable";
 import type { PhysLocDetailPageObject } from 
"nightwatch/page_objects/servers/physLocDetail";
@@ -57,11 +58,12 @@ import {
        ResponseCoordinate,
        ResponseStatus,
        ResponseProfile,
-       ResponseServer, ResponseServerCapability, ResponseRole,
+       ResponseServer, ResponseServerCapability, ResponseRole, 
ResponseParameter,
 } from "trafficops-types";
 
 import * as config from "../config.json";
 import { DataClient, generateUniqueString } from "../dataClient";
+import {ParameterDetailPageObject} from 
"../page_objects/parameters/parameterDetail";
 import type { CapabilitiesPageObject } from 
"../page_objects/servers/capabilities/capabilitiesTable";
 import type { CapabilityDetailsPageObject } from 
"../page_objects/servers/capabilities/capabilityDetails";
 import type { TypeDetailPageObject } from "../page_objects/types/typeDetail";
@@ -95,6 +97,8 @@ declare module "nightwatch" {
                };
                login: () => LoginPageObject;
                profiles: {
+                       parametersTable: () => ParametersPageObject;
+                       parameterDetail: () => ParameterDetailPageObject;
                        profileTable: () => ProfilePageObject;
                        profileDetail: () => ProfileDetailPageObject;
                };
@@ -152,6 +156,7 @@ export interface CreatedData {
        ds: ResponseDeliveryService;
        ds2: ResponseDeliveryService;
        edgeServer: ResponseServer;
+       parameter: ResponseParameter;
        physLoc: ResponsePhysicalLocation;
        region: ResponseRegion;
        role: ResponseRole;
diff --git a/experimental/traffic-portal/nightwatch/page_objects/common.ts 
b/experimental/traffic-portal/nightwatch/page_objects/common.ts
index 2803e26a1d..8052fb49b2 100644
--- a/experimental/traffic-portal/nightwatch/page_objects/common.ts
+++ b/experimental/traffic-portal/nightwatch/page_objects/common.ts
@@ -58,9 +58,11 @@ const commonPageObject = {
                                dashboard: "[aria-label='Navigate to 
Dashboard']",
                                divisions: "[aria-label='Navigate to 
Divisions']",
                                otherContainer: "[aria-label='Toggle Other']",
+                               parameters: "[aria-label='Navigate to 
Parameters']",
                                physicalLocations: "[aria-label='Navigate to 
Physical Locations']",
                                profile: "[aria-label='Navigate to My 
Profile']",
                                profiles: "[aria-label='Navigate to Profiles']",
+                               profilesContainer: "[aria-label='Toggle 
Profiles']",
                                regions: "[aria-label='Navigate to Regions']",
                                roles: "[aria-label='Navigate to Roles']",
                                servers: "[aria-label='Navigate to Servers']",
diff --git 
a/experimental/traffic-portal/nightwatch/page_objects/parameters/parameterDetail.ts
 
b/experimental/traffic-portal/nightwatch/page_objects/parameters/parameterDetail.ts
new file mode 100644
index 0000000000..6e26bb0893
--- /dev/null
+++ 
b/experimental/traffic-portal/nightwatch/page_objects/parameters/parameterDetail.ts
@@ -0,0 +1,48 @@
+/*
+ * 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 Parameter Details.
+ */
+export type ParameterDetailPageObject = EnhancedPageObject<{}, typeof 
parameterDetailPageObject.elements>;
+
+const parameterDetailPageObject = {
+       elements: {
+               configFile: {
+                       selector: "input[name='configFile']"
+               },
+               id: {
+                       selector: "input[name='id']"
+               },
+               lastUpdated: {
+                       selector: "input[name='lastUpdated']"
+               },
+               name: {
+                       selector: "input[name='name']"
+               },
+               saveBtn: {
+                       selector: "button[type='submit']"
+               },
+               secure: {
+                       selector: "input[name='secure']"
+               },
+               value: {
+                       selector: "textarea[name='value']"
+               }
+       },
+};
+
+export default parameterDetailPageObject;
diff --git 
a/experimental/traffic-portal/nightwatch/page_objects/parameters/parametersTable.ts
 
b/experimental/traffic-portal/nightwatch/page_objects/parameters/parametersTable.ts
new file mode 100644
index 0000000000..ad8a64c27e
--- /dev/null
+++ 
b/experimental/traffic-portal/nightwatch/page_objects/parameters/parametersTable.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 Parameters table commands
+ */
+type ParametersTableCommands = TableSectionCommands;
+
+/**
+ * Defines the Page Object for the Parameters page.
+ */
+export type ParametersPageObject = EnhancedPageObject<{}, {}, 
EnhancedSectionInstance<ParametersTableCommands>>;
+
+const parametersPageObject = {
+       api: {} as NightwatchAPI,
+       sections: {
+               parametersTable: {
+                       commands: {
+                               ...TABLE_COMMANDS
+                       },
+                       elements: {},
+                       selector: "mat-card"
+               }
+       },
+       url(): string {
+               return `${this.api.launchUrl}/core/parameters`;
+       }
+};
+
+export default parametersPageObject;
diff --git 
a/experimental/traffic-portal/nightwatch/tests/parameters/detail.spec.ts 
b/experimental/traffic-portal/nightwatch/tests/parameters/detail.spec.ts
new file mode 100644
index 0000000000..bf0a67b741
--- /dev/null
+++ b/experimental/traffic-portal/nightwatch/tests/parameters/detail.spec.ts
@@ -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.
+ */
+
+describe("Parameter Detail Spec", () => {
+       it("Test parameter", () => {
+               const page = browser.page.parameters.parameterDetail();
+               
browser.url(`${page.api.launchUrl}/core/parameters/${browser.globals.testData.parameter.id}`,
 res => {
+                       browser.assert.ok(res.status === 0);
+                       console.log(res.value);
+                       page.waitForElementVisible("mat-card")
+                               .assert.enabled("@name")
+                               .assert.enabled("@configFile")
+                               .assert.enabled("@value")
+                               .assert.enabled("@saveBtn")
+                               .assert.not.enabled("@id")
+                               .assert.not.enabled("@lastUpdated")
+                               .assert.valueEquals("@name", 
browser.globals.testData.parameter.name)
+                               .assert.valueEquals("@id", 
String(browser.globals.testData.parameter.id));
+               });
+       });
+
+       it("New parameter", () => {
+               const page = browser.page.parameters.parameterDetail();
+               browser.url(`${page.api.launchUrl}/core/parameters/new`, res => 
{
+                       browser.assert.ok(res.status === 0);
+                       page.waitForElementVisible("mat-card")
+                               .assert.enabled("@name")
+                               .assert.enabled("@configFile")
+                               .assert.enabled("@value")
+                               .assert.enabled("@saveBtn")
+                               .assert.not.elementPresent("@id")
+                               .assert.not.elementPresent("@lastUpdated")
+                               .assert.valueEquals("@name", "");
+               });
+       });
+});
diff --git 
a/experimental/traffic-portal/nightwatch/tests/profiles/table.spec.ts 
b/experimental/traffic-portal/nightwatch/tests/parameters/table.spec.ts
similarity index 82%
copy from experimental/traffic-portal/nightwatch/tests/profiles/table.spec.ts
copy to experimental/traffic-portal/nightwatch/tests/parameters/table.spec.ts
index f7a2e78e30..9854e34b17 100644
--- a/experimental/traffic-portal/nightwatch/tests/profiles/table.spec.ts
+++ b/experimental/traffic-portal/nightwatch/tests/parameters/table.spec.ts
@@ -12,15 +12,15 @@
  * limitations under the License.
  */
 
-describe("Profiles Spec", () => {
+describe("Parameters Spec", () => {
        it("Loads elements", async () => {
                await browser.page.common()
                        .section.sidebar
-                       .navigateToNode("profiles", ["configurationContainer"]);
+                       .navigateToNode("parameters", 
["configurationContainer", "profilesContainer"]);
                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);
+                       browser.assert.ok((rows.value as []).length >= 2);
                });
        });
 });
diff --git 
a/experimental/traffic-portal/nightwatch/tests/profiles/table.spec.ts 
b/experimental/traffic-portal/nightwatch/tests/profiles/table.spec.ts
index f7a2e78e30..2020c94077 100644
--- a/experimental/traffic-portal/nightwatch/tests/profiles/table.spec.ts
+++ b/experimental/traffic-portal/nightwatch/tests/profiles/table.spec.ts
@@ -16,7 +16,7 @@ describe("Profiles Spec", () => {
        it("Loads elements", async () => {
                await browser.page.common()
                        .section.sidebar
-                       .navigateToNode("profiles", ["configurationContainer"]);
+                       .navigateToNode("profiles", ["configurationContainer", 
"profilesContainer"]);
                await browser.waitForElementPresent("input[name=fuzzControl]");
                await browser.elements("css selector", "div.ag-row", rows => {
                        browser.assert.ok(rows.status === 0);
diff --git a/experimental/traffic-portal/src/app/api/profile.service.spec.ts 
b/experimental/traffic-portal/src/app/api/profile.service.spec.ts
index 16fc338281..17d0b35ba3 100644
--- a/experimental/traffic-portal/src/app/api/profile.service.spec.ts
+++ b/experimental/traffic-portal/src/app/api/profile.service.spec.ts
@@ -14,7 +14,7 @@
  */
 import { HttpClientTestingModule, HttpTestingController } from 
"@angular/common/http/testing";
 import { TestBed } from "@angular/core/testing";
-import { ProfileType } from "trafficops-types";
+import {ProfileType, ResponseProfile} from "trafficops-types";
 
 import { ProfileService } from "./profile.service";
 
@@ -32,6 +32,16 @@ describe("ProfileService", () => {
                type: ProfileType.ATS_PROFILE
        };
 
+       const parameter = {
+               configFile: "cfg.txt",
+               id: 10,
+               lastUpdated: new Date(),
+               name: "TestParam",
+               profiles: null,
+               secure: false,
+               value: "TestVal"
+       };
+
        beforeEach(() => {
                TestBed.configureTestingModule({
                        imports: [HttpClientTestingModule],
@@ -106,6 +116,101 @@ describe("ProfileService", () => {
                await expectAsync(responseP).toBeResolvedTo(profile);
        });
 
+       it("sends requests multiple Parameters", async () => {
+               const responseParams = service.getParameters();
+               const req = 
httpTestingController.expectOne(`/api/${service.apiVersion}/parameters`);
+               expect(req.request.method).toBe("GET");
+               expect(req.request.params.keys().length).toBe(0);
+               req.flush({response: [parameter]});
+               await expectAsync(responseParams).toBeResolvedTo([parameter]);
+       });
+
+       it("sends requests for a single Parameter by ID", async () => {
+               const responseParams = service.getParameters(parameter.id);
+               const req = httpTestingController.expectOne(r => r.url === 
`/api/${service.apiVersion}/parameters`);
+               expect(req.request.method).toBe("GET");
+               expect(req.request.params.keys().length).toBe(1);
+               expect(req.request.params.get("id")).toBe(String(parameter.id));
+               req.flush({response: [parameter]});
+               await expectAsync(responseParams).toBeResolvedTo(parameter);
+       });
+
+       it("sends requests for multiple parameters by ID", async () => {
+               const responseParams = service.getParameters(parameter.id);
+               const req = httpTestingController.expectOne(r => r.url === 
`/api/${service.apiVersion}/parameters`);
+               expect(req.request.method).toBe("GET");
+               expect(req.request.params.keys().length).toBe(1);
+               expect(req.request.params.get("id")).toBe(String(parameter.id));
+               const data = {
+                       response: [
+                               { configFile: "test", id: 1, lastUpdated: new 
Date(), name: "test", secure: false, value: "test" },
+                               { configFile: "quest", id: 1, lastUpdated: new 
Date(), name: "quest", secure: false, value: "quest" },
+                       ]
+               };
+               req.flush(data);
+               await 
expectAsync(responseParams).toBeRejectedWithError("Traffic Ops responded with 2 
Parameters by identifier 10");
+       });
+
+       it("creates new Parameters", async () => {
+               const responseParams = service.createParameter(parameter);
+               const req = 
httpTestingController.expectOne(`/api/${service.apiVersion}/parameters`);
+               expect(req.request.method).toBe("POST");
+               expect(req.request.params.keys().length).toBe(0);
+               expect(req.request.body).toBe(parameter);
+               req.flush({response: parameter});
+               await expectAsync(responseParams).toBeResolvedTo(parameter);
+       });
+
+       it("gets profiles associated with an existing Parameter", async () => {
+               const responseProfiles = service.getProfilesByParam(parameter);
+               const req = 
httpTestingController.expectOne(`/api/${service.apiVersion}/profiles?param=${parameter.id}`);
+               expect(req.request.method).toBe("GET");
+               expect(req.request.params.keys().length).toBe(1);
+               expect(req.request.body).toBe(null);
+               req.flush({response: []});
+               await 
expectAsync(responseProfiles).toBeResolvedTo(Array<ResponseProfile>());
+       });
+
+       it("gets profiles associated with an existing Parameter ID", async () 
=> {
+               const responseProfiles = 
service.getProfilesByParam(parameter.id);
+               const req = 
httpTestingController.expectOne(`/api/${service.apiVersion}/profiles?param=${parameter.id}`);
+               expect(req.request.method).toBe("GET");
+               expect(req.request.params.keys().length).toBe(1);
+               expect(req.request.body).toBe(null);
+               req.flush({response: []});
+               await 
expectAsync(responseProfiles).toBeResolvedTo(Array<ResponseProfile>());
+       });
+
+       it("deletes existing Parameters", async () => {
+               service.deleteParameter(parameter);
+               const req = 
httpTestingController.expectOne(`/api/${service.apiVersion}/parameters/${parameter.id}`);
+               expect(req.request.method).toBe("DELETE");
+               expect(req.request.params.keys().length).toBe(0);
+               expect(req.request.body).toBeNull();
+               req.flush({response: parameter});
+       });
+
+       it("deletes an existing Parameter by ID", async () => {
+               service.deleteParameter(parameter.id);
+               const req = 
httpTestingController.expectOne(`/api/${service.apiVersion}/parameters/${parameter.id}`);
+               expect(req.request.method).toBe("DELETE");
+               expect(req.request.params.keys().length).toBe(0);
+               expect(req.request.body).toBeNull();
+               req.flush({response: parameter});
+       });
+
+       it("updates an existing Parameter", async () => {
+               const p = parameter;
+               p.value = "newValue";
+               const responseParams = service.updateParameter(parameter);
+               const req = 
httpTestingController.expectOne(`/api/${service.apiVersion}/parameters/${parameter.id}`);
+               expect(req.request.method).toBe("PUT");
+               expect(req.request.params.keys().length).toBe(0);
+               expect(req.request.body).toBe(p);
+               req.flush({response: p});
+               await expectAsync(responseParams).toBeResolvedTo(p);
+       });
+
        afterEach(() => {
                httpTestingController.verify();
        });
diff --git a/experimental/traffic-portal/src/app/api/profile.service.ts 
b/experimental/traffic-portal/src/app/api/profile.service.ts
index 01a00b0122..c628017135 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 { RequestProfile, ResponseProfile } from "trafficops-types";
+import {RequestParameter, RequestProfile, ResponseParameter, ResponseProfile} 
from "trafficops-types";
 
 import { APIService } from "./base-api.service";
 
@@ -71,6 +71,27 @@ export class ProfileService extends APIService {
                return this.get<Array<ResponseProfile>>(path).toPromise();
        }
 
+       /**
+        * Retrieves Profiles associated with a Parameter from the API.
+        *
+        * @param p Either a {@link ResponseParameter} or an integral, unique 
identifier of a Parameter, for which the
+        * Profiles are to be retrieved.
+        * @returns The requested Profile(s).
+        */
+       public async getProfilesByParam(p: number| ResponseParameter): 
Promise<Array<ResponseProfile>> {
+               let id: number;
+               if (typeof p === "number") {
+                       id = p;
+               } else {
+                       id = p.id;
+               }
+
+               const path = "profiles";
+               const params = {param: id};
+               const r = await this.get<Array<ResponseProfile>>(path, 
undefined, params).toPromise();
+               return r;
+       }
+
        /**
         * Creates a new profile.
         *
@@ -103,4 +124,56 @@ export class ProfileService extends APIService {
                return 
this.delete<ResponseProfile>(`profiles/${id}`).toPromise();
        }
 
+       public async getParameters(id: number): Promise<ResponseParameter>;
+       public async getParameters(): Promise<Array<ResponseParameter>>;
+       /**
+        * Retrieves Parameters from the API.
+        *
+        * @param id Specify either the integral, unique identifier (number) of 
a specific Parameter to retrieve.
+        * @returns The requested Parameter(s).
+        */
+       public async getParameters(id?: number): 
Promise<Array<ResponseParameter> | ResponseParameter> {
+               const path = "parameters";
+               if (id !== undefined) {
+                       const params = {id};
+                       const r = await this.get<[ResponseParameter]>(path, 
undefined, params).toPromise();
+                       if (r.length !== 1) {
+                               throw new Error(`Traffic Ops responded with 
${r.length} Parameters by identifier ${id}`);
+                       }
+                       return r[0];
+               }
+               return this.get<Array<ResponseParameter>>(path).toPromise();
+       }
+
+       /**
+        * Deletes an existing parameter.
+        *
+        * @param typeOrId Id of the parameter to delete.
+        * @returns The deleted parameter.
+        */
+       public async deleteParameter(typeOrId: number | ResponseParameter): 
Promise<void> {
+               const id = typeof(typeOrId) === "number" ? typeOrId : 
typeOrId.id;
+               return this.delete(`parameters/${id}`).toPromise();
+       }
+
+       /**
+        * Creates a new parameter.
+        *
+        * @param parameter The parameter to create.
+        * @returns The created parameter.
+        */
+       public async createParameter(parameter: RequestParameter): 
Promise<ResponseParameter> {
+               return this.post<ResponseParameter>("parameters", 
parameter).toPromise();
+       }
+
+       /**
+        * Replaces the current definition of a parameter with the one given.
+        *
+        * @param parameter The new parameter.
+        * @returns The updated parameter.
+        */
+       public async updateParameter(parameter: ResponseParameter): 
Promise<ResponseParameter> {
+               const path = `parameters/${parameter.id}`;
+               return this.put<ResponseParameter>(path, parameter).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 fa2a100873..ead9c178b1 100644
--- a/experimental/traffic-portal/src/app/api/testing/profile.service.ts
+++ b/experimental/traffic-portal/src/app/api/testing/profile.service.ts
@@ -13,7 +13,7 @@
 */
 
 import { Injectable } from "@angular/core";
-import { ProfileType, RequestProfile, type ResponseProfile } from 
"trafficops-types";
+import {ProfileType, RequestParameter, RequestProfile, ResponseParameter, 
ResponseProfile} from "trafficops-types";
 
 /**
  * ProfileService exposes API functionality related to Profiles.
@@ -220,4 +220,109 @@ export class ProfileService {
                return this.profiles.splice(index, 1)[0];
        }
 
+       private lastParamID = 20;
+       private readonly parameters:  ResponseParameter[] = [
+               {
+                       configFile: "cfg.txt",
+                       id: 1,
+                       lastUpdated: new Date(),
+                       name: "param1",
+                       profiles: [],
+                       secure: false,
+                       value: "10"
+               }
+       ];
+
+       public async getParameters(id: number): Promise<ResponseParameter>;
+       public async getParameters(): Promise<Array<ResponseParameter>>;
+       /**
+        * Gets one or all Parameters from Traffic Ops
+        *
+        * @param id The integral, unique identifier (number) of a single 
parameter to be returned.
+        * @returns The requested parameter(s).
+        */
+       public async getParameters(id?: number): Promise<ResponseParameter | 
Array<ResponseParameter>> {
+               if (id !== undefined) {
+                       const parameter = this.parameters.filter(t=>t.id === 
id)[0];
+                       if (!parameter) {
+                               throw new Error(`no such Parameter: ${id}`);
+                       }
+                       return parameter;
+               }
+               return this.parameters;
+       }
+
+       /**
+        * Deletes a Parameter.
+        *
+        * @param typeOrId The Parameter to be deleted, or just its ID.
+        */
+       public async deleteParameter(typeOrId: number | ResponseParameter): 
Promise<void> {
+               const id = typeof typeOrId === "number" ? typeOrId : 
typeOrId.id;
+               const idx = this.parameters.findIndex(p => p.id === id);
+               if (idx < 0) {
+                       throw new Error(`no such Parameter: #${id}`);
+               }
+               this.parameters.splice(idx, 1);
+       }
+
+       /**
+        * Creates a new parameter.
+        *
+        * @param parameter The parameter to create.
+        * @returns The created parameter.
+        */
+       public async createParameter(parameter: RequestParameter): 
Promise<ResponseParameter> {
+               const t = {
+                       ...parameter,
+                       id: ++this.lastParamID,
+                       lastUpdated: new Date(),
+                       profiles: [],
+                       value: parameter.value ?? ""
+               };
+               this.parameters.push(t);
+               return t;
+       }
+
+       /**
+        * Replaces an existing Parameter with the provided new definition of a
+        * Parameter.
+        *
+        * @param parameter The full new definition of the Parameter being
+        * updated.
+        * @returns The updated Parameter
+        */
+       public async updateParameter(parameter: ResponseParameter): 
Promise<ResponseParameter> {
+               const id = this.parameters.findIndex(d => d.id === 
parameter.id);
+               if (id === -1) {
+                       throw new Error(`no such parameter: ${parameter.id}`);
+               }
+               this.parameters[id] = parameter;
+               return parameter;
+       }
+
+       /**
+        * Retrieves Profiles associated with a Parameter from the API.
+        *
+        * @param parameter Either a {@link ResponseParameter} or an integral, 
unique identifier of a Parameter, for which the
+        * Profiles are to be retrieved.
+        * @returns The requested Profile(s).
+        */
+       public async getProfilesByParam(parameter: number| ResponseParameter): 
Promise<Array<ResponseProfile>> {
+               const id = typeof parameter === "number" ? parameter : 
parameter.id;
+               if (id === -1) {
+                       throw new Error(`no such parameter: ${id}`);
+               }
+               const index = this.parameters.findIndex(d => d.id === id);
+               const profiles = this.parameters[index].profiles;
+               if (profiles === null) {
+                       return new Array<ResponseProfile>();
+               }
+               const returnedProfiles = new Array<ResponseProfile>();
+               for (const val of profiles) {
+                       const p = this.getProfiles(val);
+                       returnedProfiles.push(await p);
+               }
+               return returnedProfiles;
+       }
 }
diff --git a/experimental/traffic-portal/src/app/core/core.module.ts 
b/experimental/traffic-portal/src/app/core/core.module.ts
index d9b43464df..41e9168af4 100644
--- a/experimental/traffic-portal/src/app/core/core.module.ts
+++ b/experimental/traffic-portal/src/app/core/core.module.ts
@@ -48,6 +48,8 @@ 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 { ParameterDetailComponent } from 
"./parameters/detail/parameter-detail.component";
+import { ParametersTableComponent } from 
"./parameters/table/parameters-table.component";
 import { ProfileDetailComponent } from 
"./profiles/profile-detail/profile-detail.component";
 import { ProfileTableComponent } from 
"./profiles/profile-table/profile-table.component";
 import { CapabilitiesComponent } from 
"./servers/capabilities/capabilities.component";
@@ -103,6 +105,8 @@ export const ROUTES: Routes = [
        { component: CoordinatesTableComponent, path: "coordinates" },
        { component: TypesTableComponent, path: "types" },
        { component: TypeDetailComponent, path: "types/:id"},
+       { component: ParametersTableComponent, path: "parameters" },
+       { component: ParameterDetailComponent, path: "parameters/:id" },
        { component: StatusesTableComponent, path: "statuses" },
        { component: StatusDetailsComponent, path: "statuses/:id" },
        { component: ISOGenerationFormComponent, path: "iso-gen"},
@@ -153,9 +157,11 @@ export const ROUTES: Routes = [
                StatusesTableComponent,
                StatusDetailsComponent,
                ISOGenerationFormComponent,
-               ProfileTableComponent,
                CDNDetailComponent,
-               ProfileDetailComponent,
+               ParametersTableComponent,
+               ParameterDetailComponent,
+               ProfileTableComponent,
+               ProfileDetailComponent,
                CapabilitiesComponent,
                CapabilityDetailsComponent,
        ],
diff --git 
a/experimental/traffic-portal/src/app/core/parameters/detail/parameter-detail.component.html
 
b/experimental/traffic-portal/src/app/core/parameters/detail/parameter-detail.component.html
new file mode 100644
index 0000000000..30484fe517
--- /dev/null
+++ 
b/experimental/traffic-portal/src/app/core/parameters/detail/parameter-detail.component.html
@@ -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.
+-->
+
+<mat-card>
+       <tp-loading *ngIf="!parameter"></tp-loading>
+       <form ngNativeValidate (ngSubmit)="submit($event)" *ngIf="parameter">
+               <mat-card-content>
+                       <mat-form-field *ngIf="!new">
+                               <mat-label>ID</mat-label>
+                               <input matInput type="text" name="id" disabled 
readonly [defaultValue]="parameter.id" />
+                       </mat-form-field>
+                       <mat-form-field>
+                               <mat-label>Name</mat-label>
+                               <input matInput type="text" name="name" 
required [(ngModel)]="parameter.name" />
+                       </mat-form-field>
+                       <mat-form-field>
+                               <mat-label>Config File</mat-label>
+                               <input matInput type="text" name="configFile" 
required [(ngModel)]="parameter.configFile" />
+                       </mat-form-field>
+                       <mat-form-field>
+                               <mat-label>Value</mat-label>
+                               <textarea matInput name="value" required 
[(ngModel)]="parameter.value" cdkTextareaAutosize></textarea>
+                       </mat-form-field>
+                       <mat-slide-toggle required 
[(ngModel)]="parameter.secure" name="secure">Secure</mat-slide-toggle>
+                       <mat-form-field *ngIf="!new">
+                               <mat-label>Last Updated</mat-label>
+                               <input matInput type="text" name="lastUpdated" 
disabled readonly [defaultValue]="parameter.lastUpdated" />
+                       </mat-form-field>
+               </mat-card-content>
+               <mat-card-actions align="end">
+                       <button mat-raised-button type="button" *ngIf="!new" 
color="warn" (click)="deleteParameter()">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/parameters/detail/parameter-detail.component.scss
 
b/experimental/traffic-portal/src/app/core/parameters/detail/parameter-detail.component.scss
new file mode 100644
index 0000000000..ebe77042d3
--- /dev/null
+++ 
b/experimental/traffic-portal/src/app/core/parameters/detail/parameter-detail.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/parameters/detail/parameter-detail.component.spec.ts
 
b/experimental/traffic-portal/src/app/core/parameters/detail/parameter-detail.component.spec.ts
new file mode 100644
index 0000000000..d1526395e2
--- /dev/null
+++ 
b/experimental/traffic-portal/src/app/core/parameters/detail/parameter-detail.component.spec.ts
@@ -0,0 +1,83 @@
+/*
+* 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 {ProfileService} from "src/app/api";
+import { APITestingModule } from "src/app/api/testing";
+import { ParameterDetailComponent } from 
"src/app/core/parameters/detail/parameter-detail.component";
+import { NavigationService } from 
"src/app/shared/navigation/navigation.service";
+
+describe("ParameterDetailComponent", () => {
+       let component: ParameterDetailComponent;
+       let fixture: ComponentFixture<ParameterDetailComponent>;
+       let route: ActivatedRoute;
+       let paramMap: jasmine.Spy;
+       let service: ProfileService;
+
+       const navSvc = jasmine.createSpyObj([],{headerHidden: new 
ReplaySubject<boolean>(), headerTitle: new ReplaySubject<string>()});
+       beforeEach(async () => {
+               await TestBed.configureTestingModule({
+                       declarations: [ ParameterDetailComponent ],
+                       imports: [ APITestingModule, RouterTestingModule, 
MatDialogModule ],
+                       providers: [ { provide: NavigationService, useValue: 
navSvc } ]
+               })
+                       .compileComponents();
+
+               route = TestBed.inject(ActivatedRoute);
+               paramMap = spyOn(route.snapshot.paramMap, "get");
+               paramMap.and.returnValue(null);
+               service = TestBed.inject(ProfileService);
+               fixture = TestBed.createComponent(ParameterDetailComponent);
+               component = fixture.componentInstance;
+               fixture.detectChanges();
+       });
+
+       it("should create", () => {
+               expect(component).toBeTruthy();
+               expect(paramMap).toHaveBeenCalled();
+       });
+
+       it("new parameter", async () => {
+               paramMap.and.returnValue("new");
+
+               fixture = TestBed.createComponent(ParameterDetailComponent);
+               component = fixture.componentInstance;
+               fixture.detectChanges();
+               await fixture.whenStable();
+               expect(paramMap).toHaveBeenCalled();
+               expect(component.parameter).not.toBeNull();
+               expect(component.parameter.name).toBe("");
+               expect(component.new).toBeTrue();
+       });
+
+       it("existing parameter", async () => {
+               const id = 1;
+               paramMap.and.returnValue(id);
+               const parameter = await service.getParameters(id);
+               fixture = TestBed.createComponent(ParameterDetailComponent);
+               component = fixture.componentInstance;
+               fixture.detectChanges();
+               await fixture.whenStable();
+               expect(paramMap).toHaveBeenCalled();
+               expect(component.parameter).not.toBeNull();
+               expect(component.parameter.name).toBe(parameter.name);
+               expect(component.new).toBeFalse();
+
+       });
+});
diff --git 
a/experimental/traffic-portal/src/app/core/parameters/detail/parameter-detail.component.ts
 
b/experimental/traffic-portal/src/app/core/parameters/detail/parameter-detail.component.ts
new file mode 100644
index 0000000000..da2b46832b
--- /dev/null
+++ 
b/experimental/traffic-portal/src/app/core/parameters/detail/parameter-detail.component.ts
@@ -0,0 +1,114 @@
+/*
+* 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 { ResponseParameter } from "trafficops-types";
+
+import { ProfileService } 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";
+
+/**
+ * ParameterDetailsComponent is the controller for the parameter add/edit form.
+ */
+@Component({
+       selector: "tp-parameters-detail",
+       styleUrls: ["../../styles/form.page.scss"],
+       templateUrl: "./parameter-detail.component.html"
+})
+export class ParameterDetailComponent implements OnInit {
+       public new = false;
+       public parameter!: ResponseParameter;
+       public secure = [
+               { label: "true", value: true },
+               { label: "false", value: false }
+       ];
+
+       constructor(private readonly route: ActivatedRoute, private readonly 
profileService: ProfileService,
+               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 Parameter");
+                       this.new = true;
+                       this.parameter = {
+                               configFile: "",
+                               id: -1,
+                               lastUpdated: new Date(),
+                               name: "",
+                               profiles: [],
+                               secure: false,
+                               value: "",
+                       };
+                       return;
+               }
+
+               const numID = parseInt(ID, 10);
+               if (Number.isNaN(numID)) {
+                       console.error("route parameter 'id' was non-number: ", 
ID);
+                       return;
+               }
+
+               this.parameter = await this.profileService.getParameters(numID);
+               this.navSvc.headerTitle.next(`Parameter: ${this.parameter.name} 
(${this.parameter.id})`);
+       }
+
+       /**
+        * Deletes the current parameter.
+        */
+       public async deleteParameter(): Promise<void> {
+               if (this.new) {
+                       console.error("Unable to delete new parameter");
+                       return;
+               }
+               const ref = this.dialog.open(DecisionDialogComponent, {
+                       data: {message: `Are you sure you want to delete 
parameter ${this.parameter.name}`,
+                               title: "Confirm Delete"}
+               });
+               ref.afterClosed().subscribe(result => {
+                       if(result) {
+                               
this.profileService.deleteParameter(this.parameter.id);
+                               this.location.back();
+                       }
+               });
+       }
+
+       /**
+        * Submits new/updated parameter.
+        *
+        * @param e HTML form submission event.
+        */
+       public async submit(e: Event): Promise<void> {
+               e.preventDefault();
+               e.stopPropagation();
+               if(this.new) {
+                       this.parameter = await 
this.profileService.createParameter(this.parameter);
+                       this.new = false;
+               } else {
+                       this.parameter = await 
this.profileService.updateParameter(this.parameter);
+               }
+       }
+}
diff --git 
a/experimental/traffic-portal/src/app/core/parameters/table/parameters-table.component.html
 
b/experimental/traffic-portal/src/app/core/parameters/table/parameters-table.component.html
new file mode 100644
index 0000000000..cb736966ff
--- /dev/null
+++ 
b/experimental/traffic-portal/src/app/core/parameters/table/parameters-table.component.html
@@ -0,0 +1,30 @@
+<!--
+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 Parameters" inputmode="search" role="search" accesskey="/" 
placeholder="Fuzzy Search" [formControl]="fuzzControl" (input)="updateURL()" />
+       </div>
+       <tp-generic-table
+               [data]="parameters | async"
+               [doubleClickLink]="doubleClickLink"
+               [cols]="columnDefs"
+               [fuzzySearch]="fuzzySubject"
+               context="parameters"
+               [contextMenuItems]="contextMenuItems"
+               (contextMenuAction)="handleContextMenu($event)">
+       </tp-generic-table>
+</mat-card>
+
+<a class="page-fab" mat-fab title="Create a new Parameter" 
*ngIf="auth.hasPermission('PARAMETER:CREATE')" 
routerLink="new"><mat-icon>add</mat-icon></a>
diff --git 
a/experimental/traffic-portal/src/app/core/parameters/table/parameters-table.component.scss
 
b/experimental/traffic-portal/src/app/core/parameters/table/parameters-table.component.scss
new file mode 100644
index 0000000000..a76ede4a23
--- /dev/null
+++ 
b/experimental/traffic-portal/src/app/core/parameters/table/parameters-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/parameters/table/parameters-table.component.spec.ts
 
b/experimental/traffic-portal/src/app/core/parameters/table/parameters-table.component.spec.ts
new file mode 100644
index 0000000000..c08ea53eb8
--- /dev/null
+++ 
b/experimental/traffic-portal/src/app/core/parameters/table/parameters-table.component.spec.ts
@@ -0,0 +1,192 @@
+/*
+* 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 { ProfileService } from "src/app/api";
+import { APITestingModule } from "src/app/api/testing";
+import { ParametersTableComponent } from 
"src/app/core/parameters/table/parameters-table.component";
+import { isAction } from 
"src/app/shared/generic-table/generic-table.component";
+
+const testParameter = {
+       configFile: "cfg.txt",
+       id: 1,
+       lastUpdated: new Date(),
+       name: "TestQuest",
+       profiles: [],
+       secure: false,
+       value: "",
+};
+
+describe("ParametersTableComponent", () => {
+       let component: ParametersTableComponent;
+       let fixture: ComponentFixture<ParametersTableComponent>;
+
+       beforeEach(async () => {
+               await TestBed.configureTestingModule({
+                       declarations: [ ParametersTableComponent ],
+                       imports: [
+                               APITestingModule,
+                               RouterTestingModule,
+                               MatDialogModule
+                       ]
+               }).compileComponents();
+
+               fixture = TestBed.createComponent(ParametersTableComponent);
+               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: {configFile: "cfg.txt", id: 1, lastUpdated: new 
Date(), name: "TestQuest", profiles: [], secure: false, value: ""}
+               })).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(ProfileService);
+               const spy = spyOn(api, "deleteParameter").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 parameter = await api.createParameter({configFile: 
"cfg.txt", name: "test", secure: false, value: ""});
+               expect(openSpy).not.toHaveBeenCalled();
+               const asyncExpectation = 
expectAsync(component.handleContextMenu({action: "delete", data: 
parameter})).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(testParameter)).toBe(String(testParameter.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(testParameter)).toBe(String(testParameter.id));
+               expect(item.queryParams).toBeUndefined();
+               expect(item.fragment).toBeUndefined();
+               expect(item.newTab).toBeTrue();
+       });
+
+       it("generates 'View Profiles' context menu item href", () => {
+               const item = component.contextMenuItems.find(i => i.name === 
"View Profiles");
+               if (!item) {
+                       return fail("missing 'View Profiles' context menu 
item");
+               }
+               if (isAction(item)) {
+                       return fail("expected a link, not an action");
+               }
+               if (!item.href) {
+                       return fail("missing 'href' property");
+               }
+               if (typeof(item.href) !== "string") {
+                       return fail("'View Profiles' context menu item should 
use a static string to determine href, instead uses a function");
+               }
+               expect(item.href).toBe("/core/profiles");
+               if (typeof(item.queryParams) !== "function") {
+                       return fail(
+                               `'View Profiles' context menu item should use a 
function to determine query params, instead uses: ${item.queryParams}`
+                       );
+               }
+               expect(item.queryParams(testParameter)).toEqual({hasParameter: 
testParameter.id});
+               expect(item.fragment).toBeUndefined();
+       });
+});
diff --git 
a/experimental/traffic-portal/src/app/core/parameters/table/parameters-table.component.ts
 
b/experimental/traffic-portal/src/app/core/parameters/table/parameters-table.component.ts
new file mode 100644
index 0000000000..cd31ad7d25
--- /dev/null
+++ 
b/experimental/traffic-portal/src/app/core/parameters/table/parameters-table.component.ts
@@ -0,0 +1,158 @@
+/*
+* 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, Params } from "@angular/router";
+import { BehaviorSubject } from "rxjs";
+import { ResponseParameter } 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 type { ContextMenuActionEvent, ContextMenuItem, DoubleClickLink } from 
"src/app/shared/generic-table/generic-table.component";
+import { NavigationService } from 
"src/app/shared/navigation/navigation.service";
+
+/**
+ * ParametersTableComponent is the controller for the "Parameters" table.
+ */
+@Component({
+       selector: "tp-parameters",
+       styleUrls: ["./parameters-table.component.scss"],
+       templateUrl: "./parameters-table.component.html"
+})
+export class ParametersTableComponent implements OnInit {
+       /** List of parameters */
+       public parameters: Promise<Array<ResponseParameter>>;
+
+       /** Definitions of the table's columns according to the ag-grid API */
+       public columnDefs = [
+               {
+                       field: "id",
+                       filter: "agNumberColumnFilter",
+                       headerName: "ID",
+                       hide: true
+               },
+               {
+                       field: "configFile",
+                       headerName: "Config File"
+               },
+               {
+                       field: "name",
+                       headerName: "Name"
+               },
+               {
+                       field: "profiles",
+                       headerName: "Profiles",
+                       valueFormatter: ({data}: {data: ResponseParameter}): 
string => data.profiles === null? "":data.profiles.join(", ")
+               },
+               {
+                       field: "secure",
+                       filter: "tpBooleanFilter",
+                       headerName: "Secure",
+                       hide: true
+               },
+               {
+                       field: "value",
+                       headerName: "Value"
+               },
+               {
+                       field: "lastUpdated",
+                       filter: "agDateColumnFilter",
+                       headerName: "Last Updated",
+                       hide: true
+               }
+       ];
+
+       /** Defines what the table should do when a row is double-clicked. */
+       public doubleClickLink: DoubleClickLink<ResponseParameter> = {
+               href: (row: ResponseParameter): string => 
`/core/parameters/${row.id}`
+       };
+
+       /** Definitions for the context menu items (which act on augmented 
parameter data). */
+       public contextMenuItems: Array<ContextMenuItem<ResponseParameter>> = [
+               {
+                       href: (responseParameter: ResponseParameter): string => 
`${responseParameter.id}`,
+                       name: "Edit"
+               },
+               {
+                       href: (responseParameter: ResponseParameter): string => 
`${responseParameter.id}`,
+                       name: "Open in New Tab",
+                       newTab: true
+               },
+               {
+                       action: "delete",
+                       multiRow: false,
+                       name: "Delete"
+               },
+               {
+                       href: "/core/profiles",
+                       name: "View Profiles",
+                       queryParams: (selectedRow: ResponseParameter): Params 
=> ({hasParameter: selectedRow.id}),
+               }
+       ];
+
+       /** 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: ProfileService, private readonly dialog: 
MatDialog, public readonly auth: CurrentUserService) {
+               this.fuzzySubject = new BehaviorSubject<string>("");
+               this.parameters = this.api.getParameters();
+               this.navSvc.headerTitle.next("Parameters");
+       }
+
+       /** 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<ResponseParameter>): Promise<void> {
+               const data = evt.data as ResponseParameter;
+               switch(evt.action) {
+                       case "delete":
+                               const ref = 
this.dialog.open(DecisionDialogComponent, {
+                                       data: {message: `Are you sure you want 
to delete Parameter ${data.name} with ID ${data.id}?`, title: "Confirm Delete"}
+                               });
+                               ref.afterClosed().subscribe(result => {
+                                       if(result) {
+                                               
this.api.deleteParameter(data.id).then(async () => this.parameters = 
this.api.getParameters());
+                                       }
+                               });
+                               break;
+               }
+       }
+}
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
index c33c0c7d9e..59fa4a5d12 100644
--- 
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
@@ -142,6 +142,11 @@ export class ProfileTableComponent implements OnInit {
                                }
                        }
                );
+               const hasParameter = 
this.route.snapshot.queryParamMap.get("hasParameter");
+               if (hasParameter == null) {
+                       return;
+               }
+               this.profiles = this.api.getProfilesByParam(+hasParameter);
        }
 
        /** Update the URL's 'search' query parameter for the user's search 
input. */
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 9a31ae2319..71a362285b 100644
--- 
a/experimental/traffic-portal/src/app/shared/navigation/navigation.service.ts
+++ 
b/experimental/traffic-portal/src/app/shared/navigation/navigation.service.ts
@@ -147,14 +147,25 @@ export class NavigationService {
                        }],
                        name: "Servers"
                }, {
-                       children: [{
-                               href: "/core/types",
-                               name: "Types"
-                       },
-                       {
-                               href: "/core/profiles",
-                               name: "Profiles"
-                       }],
+                       children: [
+                               {
+                                       href: "/core/types",
+                                       name: "Types"
+                               },
+                               {
+                                       children: [
+                                               {
+                                                       href: 
"/core/parameters",
+                                                       name: "Parameters"
+                                               },
+                                               {
+                                                       href: "/core/profiles",
+                                                       name: "Profiles"
+                                               }
+                                       ],
+                                       name: "Profiles"
+                               }
+                       ],
                        name: "Configuration"
                }, {
                        children: [{

Reply via email to