You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@trafficcontrol.apache.org by oc...@apache.org on 2023/06/01 01:21:38 UTC
[trafficcontrol] branch master updated: Tpv2 param table/ details parity (#7480)
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 <30...@users.noreply.github.com>
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: [{