diff --git a/experimental/traffic-portal/src/app/api/coordinate.service.spec.ts b/experimental/traffic-portal/src/app/api/coordinate.service.spec.ts new file mode 100644 index 0000000000..b0811ddb85 --- /dev/null +++ b/experimental/traffic-portal/src/app/api/coordinate.service.spec.ts @@ -0,0 +1,117 @@ +/** + * @license Apache-2.0 + * 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 { + HttpClientTestingModule, + HttpTestingController, +} from "@angular/common/http/testing"; +import { TestBed } from "@angular/core/testing"; + +import { CoordinateService } from "./coordinate.service"; + +describe("CoordinateService", () => { + let service: CoordinateService; + let httpTestingController: HttpTestingController; + const coordinate = { + id: 1, + lastUpdated: new Date(), + latitude: 1.0, + longitude: -1.0, + name: "test_coordinate", + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [CoordinateService], + }); + service = TestBed.inject(CoordinateService); + httpTestingController = TestBed.inject(HttpTestingController); + }); + + it("should be created", () => { + expect(service).toBeTruthy(); + }); + + it("gets multiple Coordinates", async () => { + const responseP = service.getCoordinates(); + const req = httpTestingController.expectOne( + `/api/${service.apiVersion}/coordinates` + ); + expect(req.request.method).toBe("GET"); + expect(req.request.params.keys().length).toBe(0); + req.flush({ response: [coordinate] }); + await expectAsync(responseP).toBeResolvedTo([coordinate]); + }); + + it("gets a single Coordinate by ID", async () => { + const responseP = service.getCoordinates(coordinate.id); + const req = httpTestingController.expectOne( + (r) => r.url === `/api/${service.apiVersion}/coordinates` + ); + expect(req.request.method).toBe("GET"); + expect(req.request.params.keys().length).toBe(1); + expect(req.request.params.get("id")).toBe(String(coordinate.id)); + req.flush({ response: [coordinate] }); + await expectAsync(responseP).toBeResolvedTo(coordinate); + }); + + it("gets a single Coordinate by name", async () => { + const responseP = service.getCoordinates(coordinate.name); + const req = httpTestingController.expectOne( + (r) => r.url === `/api/${service.apiVersion}/coordinates` + ); + expect(req.request.method).toBe("GET"); + expect(req.request.params.keys().length).toBe(1); + expect(req.request.params.get("name")).toBe(coordinate.name); + req.flush({ response: [coordinate] }); + await expectAsync(responseP).toBeResolvedTo(coordinate); + }); + + it("sends requests for multiple coordinates by ID", async () => { + const responseParams = service.getCoordinates(coordinate.id); + const req = httpTestingController.expectOne( + (r) => r.url === `/api/${service.apiVersion}/coordinates` + ); + expect(req.request.method).toBe("GET"); + expect(req.request.params.keys().length).toBe(1); + expect(req.request.params.get("id")).toBe(String(coordinate.id)); + const data = { + response: [ + { + id: 1, + lastUpdated: new Date(), + latitude: 1.0, + longitude: -1.0, + name: "test_coordinate1", + }, + { + id: 1, + lastUpdated: new Date(), + latitude: 1.0, + longitude: -1.0, + name: "test_coordinate2", + }, + ], + }; + req.flush(data); + await expectAsync(responseParams).toBeRejectedWithError( + `Traffic Ops responded with 2 Coordinates by identifier ${coordinate.id}` + ); + }); + + afterEach(() => { + httpTestingController.verify(); + }); +}); diff --git a/experimental/traffic-portal/src/app/api/coordinate.service.ts b/experimental/traffic-portal/src/app/api/coordinate.service.ts new file mode 100644 index 0000000000..6d9d89ddf7 --- /dev/null +++ b/experimental/traffic-portal/src/app/api/coordinate.service.ts @@ -0,0 +1,85 @@ +/* + * 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 { HttpClient } from "@angular/common/http"; +import { Injectable } from "@angular/core"; +import type { ResponseCoordinate } from "trafficops-types"; + +import { APIService } from "./base-api.service"; + +/** + * CoordinateService exposes API functionality relating to Coordinates. + */ +@Injectable() +export class CoordinateService extends APIService { + /** + * Gets a specific Coordinate from Traffic Ops. + * + * @param idOrName Either the integral, unique identifier (number) or name + * (string) of the Coordinate to be returned. + * @returns The requested Coordinate. + */ + public async getCoordinates( + idOrName: number | string + ): Promise; + /** + * Gets Coordinates from Traffic Ops. + * + * @returns An Array of all Coordinates from Traffic Ops. + */ + public async getCoordinates(): Promise>; + + /** + * Gets one or all Coordinates from Traffic Ops. + * + * @param idOrName Optionally the integral, unique identifier (number) or + * name (string) of a single Coordinate to be returned. + * @returns The requested Coordinate(s). + */ + public async getCoordinates( + idOrName?: number | string + ): Promise> { + const path = "coordinates"; + if (idOrName !== undefined) { + let params; + switch (typeof idOrName) { + case "string": + params = { name: idOrName }; + break; + case "number": + params = { id: idOrName }; + } + const r = await this.get<[ResponseCoordinate]>( + path, + undefined, + params + ).toPromise(); + if (r.length !== 1) { + throw new Error( + `Traffic Ops responded with ${r.length} Coordinates by identifier ${idOrName}` + ); + } + return r[0]; + } + return this.get>(path).toPromise(); + } + + /** + * Injects the Angular HTTP client service into the parent constructor. + * + * @param http The Angular HTTP client service. + */ + constructor(http: HttpClient) { + super(http); + } +} diff --git a/experimental/traffic-portal/src/app/api/index.ts b/experimental/traffic-portal/src/app/api/index.ts index 039b8abe1e..7de4187ab3 100644 --- a/experimental/traffic-portal/src/app/api/index.ts +++ b/experimental/traffic-portal/src/app/api/index.ts @@ -20,9 +20,11 @@ import { ChangeLogsService } from "src/app/api/change-logs.service"; import { CacheGroupService } from "./cache-group.service"; import { CDNService } from "./cdn.service"; +import { CoordinateService } from "./coordinate.service"; import { DeliveryServiceService } from "./delivery-service.service"; import { InvalidationJobService } from "./invalidation-job.service"; import { MiscAPIsService } from "./misc-apis.service"; +import { OriginService } from "./origin.service"; import { PhysicalLocationService } from "./physical-location.service"; import { ProfileService } from "./profile.service"; import { ServerService } from "./server.service"; @@ -42,6 +44,8 @@ export * from "./server.service"; export * from "./topology.service"; export * from "./type.service"; export * from "./user.service"; +export * from "./origin.service"; +export * from "./coordinate.service"; /** * The API Module contains all logic used to access the Traffic Ops API. @@ -64,6 +68,8 @@ export * from "./user.service"; TopologyService, TypeService, UserService, + OriginService, + CoordinateService, ] }) export class APIModule { } diff --git a/experimental/traffic-portal/src/app/api/origin.service.spec.ts b/experimental/traffic-portal/src/app/api/origin.service.spec.ts new file mode 100644 index 0000000000..2ff474e235 --- /dev/null +++ b/experimental/traffic-portal/src/app/api/origin.service.spec.ts @@ -0,0 +1,206 @@ +/** + * @license Apache-2.0 + * 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 { + HttpClientTestingModule, + HttpTestingController, +} from "@angular/common/http/testing"; +import { TestBed } from "@angular/core/testing"; + +import { OriginService } from "./origin.service"; + +describe("OriginService", () => { + let service: OriginService; + let httpTestingController: HttpTestingController; + const origin = { + cachegroup: null, + cachegroupId: null, + coordinate: null, + coordinateId: null, + deliveryService: "test", + deliveryServiceId: 1, + fqdn: "origin.infra.ciab.test", + id: 1, + ip6Address: null, + ipAddress: null, + isPrimary: false, + lastUpdated: new Date(), + name: "test", + port: null, + profile: null, + profileId: null, + protocol: "http" as never, + tenant: "root", + tenantId: 1, + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [OriginService], + }); + service = TestBed.inject(OriginService); + httpTestingController = TestBed.inject(HttpTestingController); + }); + + it("should be created", () => { + expect(service).toBeTruthy(); + }); + + it("gets multiple Origins", async () => { + const responseP = service.getOrigins(); + const req = httpTestingController.expectOne( + `/api/${service.apiVersion}/origins` + ); + expect(req.request.method).toBe("GET"); + expect(req.request.params.keys().length).toBe(0); + req.flush({ response: [origin] }); + await expectAsync(responseP).toBeResolvedTo([origin]); + }); + + it("gets a single Origin by ID", async () => { + const responseP = service.getOrigins(origin.id); + const req = httpTestingController.expectOne( + (r) => r.url === `/api/${service.apiVersion}/origins` + ); + expect(req.request.method).toBe("GET"); + expect(req.request.params.keys().length).toBe(1); + expect(req.request.params.get("id")).toBe(String(origin.id)); + req.flush({ response: [origin] }); + await expectAsync(responseP).toBeResolvedTo(origin); + }); + + it("gets a single Origin by name", async () => { + const responseP = service.getOrigins(origin.name); + const req = httpTestingController.expectOne( + (r) => r.url === `/api/${service.apiVersion}/origins` + ); + expect(req.request.method).toBe("GET"); + expect(req.request.params.keys().length).toBe(1); + expect(req.request.params.get("name")).toBe(origin.name); + req.flush({ response: [origin] }); + await expectAsync(responseP).toBeResolvedTo(origin); + }); + + it("submits requests to create new Origins", async () => { + const responseP = service.createOrigin({ + ...origin, + tenantID: origin.tenantId, + }); + const req = httpTestingController.expectOne( + `/api/${service.apiVersion}/origins` + ); + expect(req.request.method).toBe("POST"); + expect(req.request.params.keys().length).toBe(0); + expect(req.request.body.name).toEqual(origin.name); + req.flush({ response: origin }); + await expectAsync(responseP).toBeResolvedTo(origin); + }); + + it("submits requests to update existing Origins", async () => { + const responseP = service.updateOrigin(origin); + const req = httpTestingController.expectOne( + `/api/${service.apiVersion}/origins?id=${origin.id}` + ); + expect(req.request.method).toBe("PUT"); + expect(req.request.params.keys().length).toBe(0); + expect(req.request.body).toEqual(origin); + req.flush({ response: origin }); + await expectAsync(responseP).toBeResolvedTo(origin); + }); + + it("submits requests to delete Origins", async () => { + let responseP = service.deleteOrigin(origin); + let req = httpTestingController.expectOne( + `/api/${service.apiVersion}/origins?id=${origin.id}` + ); + expect(req.request.method).toBe("DELETE"); + expect(req.request.params.keys().length).toBe(0); + req.flush({ alerts: [] }); + await expectAsync(responseP).toBeResolved(); + + responseP = service.deleteOrigin(origin.id); + req = httpTestingController.expectOne( + `/api/${service.apiVersion}/origins?id=${origin.id}` + ); + expect(req.request.method).toBe("DELETE"); + expect(req.request.params.keys().length).toBe(0); + req.flush({ alerts: [] }); + await expectAsync(responseP).toBeResolved(); + }); + + it("sends requests for multiple origins by ID", async () => { + const responseParams = service.getOrigins(origin.id); + const req = httpTestingController.expectOne( + (r) => r.url === `/api/${service.apiVersion}/origins` + ); + expect(req.request.method).toBe("GET"); + expect(req.request.params.keys().length).toBe(1); + expect(req.request.params.get("id")).toBe(String(origin.id)); + const data = { + response: [ + { + cachegroup: null, + cachegroupId: null, + coordinate: null, + coordinateId: null, + deliveryService: "test", + deliveryServiceId: 1, + fqdn: "origin.infra.ciab.test", + id: 1, + ip6Address: null, + ipAddress: null, + isPrimary: false, + lastUpdated: new Date(), + name: "test", + port: null, + profile: null, + profileId: null, + protocol: "http" as never, + tenant: "root", + tenantId: 1, + }, + { + cachegroup: null, + cachegroupId: null, + coordinate: null, + coordinateId: null, + deliveryService: "test", + deliveryServiceId: 1, + fqdn: "origin.infra.ciab.test", + id: 1, + ip6Address: null, + ipAddress: null, + isPrimary: false, + lastUpdated: new Date(), + name: "test2", + port: null, + profile: null, + profileId: null, + protocol: "http" as never, + tenant: "root", + tenantId: 1, + }, + ], + }; + req.flush(data); + await expectAsync(responseParams).toBeRejectedWithError( + `Traffic Ops responded with 2 Origins by identifier ${origin.id}` + ); + }); + + afterEach(() => { + httpTestingController.verify(); + }); +}); diff --git a/experimental/traffic-portal/src/app/api/origin.service.ts b/experimental/traffic-portal/src/app/api/origin.service.ts new file mode 100644 index 0000000000..50b74a9284 --- /dev/null +++ b/experimental/traffic-portal/src/app/api/origin.service.ts @@ -0,0 +1,122 @@ +/* + * 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 { HttpClient } from "@angular/common/http"; +import { Injectable } from "@angular/core"; +import type { RequestOrigin, RequestOriginResponse } from "trafficops-types"; + +import { APIService } from "./base-api.service"; + +/** + * OriginService exposes API functionality relating to Origins. + */ +@Injectable() +export class OriginService extends APIService { + /** + * Gets a specific Origin from Traffic Ops. + * + * @param idOrName Either the integral, unique identifier (number) or name + * (string) of the Origin to be returned. + * @returns The requested Origin. + */ + public async getOrigins( + idOrName: number | string + ): Promise; + /** + * Gets Origins from Traffic Ops. + * + * @returns An Array of all Origins from Traffic Ops. + */ + public async getOrigins(): Promise>; + /** + * Gets one or all Origins from Traffic Ops. + * + * @param idOrName Optionally the integral, unique identifier (number) or + * name (string) of a single Origin to be returned. + * @returns The requested Origin(s). + */ + public async getOrigins( + idOrName?: number | string + ): Promise> { + const path = "origins"; + if (idOrName !== undefined) { + let params; + switch (typeof idOrName) { + case "string": + params = { name: idOrName }; + break; + case "number": + params = { id: idOrName }; + } + const r = await this.get<[RequestOriginResponse]>( + path, + undefined, + params + ).toPromise(); + if (r.length !== 1) { + throw new Error( + `Traffic Ops responded with ${r.length} Origins by identifier ${idOrName}` + ); + } + return r[0]; + } + return this.get>(path).toPromise(); + } + + /** + * Deletes an existing origin. + * + * @param originOrId Id of the origin to delete. + * @returns The deleted origin. + */ + public async deleteOrigin( + originOrId: number | RequestOriginResponse + ): Promise { + const id = typeof originOrId === "number" ? originOrId : originOrId.id; + return this.delete(`origins?id=${id}`).toPromise(); + } + + /** + * Creates a new origin. + * + * @param origin The origin to create. + * @returns The created origin. + */ + public async createOrigin( + origin: RequestOrigin + ): Promise { + return this.post("origins", origin).toPromise(); + } + + /** + * Replaces the current definition of an origin with the one given. + * + * @param origin The new origin. + * @returns The updated origin. + */ + public async updateOrigin( + origin: RequestOriginResponse + ): Promise { + const path = `origins?id=${origin.id}`; + return this.put(path, origin).toPromise(); + } + + /** + * Injects the Angular HTTP client service into the parent constructor. + * + * @param http The Angular HTTP client service. + */ + constructor(http: HttpClient) { + super(http); + } +} diff --git a/experimental/traffic-portal/src/app/api/testing/coordinate.service.ts b/experimental/traffic-portal/src/app/api/testing/coordinate.service.ts new file mode 100644 index 0000000000..a4a0f49759 --- /dev/null +++ b/experimental/traffic-portal/src/app/api/testing/coordinate.service.ts @@ -0,0 +1,68 @@ +/* + * 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 { Injectable } from "@angular/core"; +import type { ResponseCoordinate } from "trafficops-types"; + +/** + * CoordinateService exposes API functionality relating to Coordinates. + */ +@Injectable() +export class CoordinateService { + private readonly coordinates: Array = [ + { + id: 1, + lastUpdated: new Date(), + latitude: 1.0, + longitude: -1.0, + name: "test_coordinate", + }, + ]; + + public async getCoordinates(): Promise>; + public async getCoordinates( + nameOrID: string | number + ): Promise; + + /** + * Gets one or all Coordinates from Traffic Ops. + * + * @param nameOrID If given, returns only the ResponseCoordinate with the given name + * (string) or ID (number). + * @returns An Array of ResponseCoordinate objects - or a single ResponseCoordinate object if 'nameOrID' + * was given. + */ + public async getCoordinates( + nameOrID?: string | number + ): Promise | ResponseCoordinate> { + if (nameOrID) { + let coordinate; + switch (typeof nameOrID) { + case "string": + coordinate = this.coordinates.find( + (d) => d.name === nameOrID + ); + break; + case "number": + coordinate = this.coordinates.find( + (d) => d.id === nameOrID + ); + } + if (!coordinate) { + throw new Error(`no such Coordinate: ${nameOrID}`); + } + return coordinate; + } + return this.coordinates; + } +} diff --git a/experimental/traffic-portal/src/app/api/testing/index.ts b/experimental/traffic-portal/src/app/api/testing/index.ts index b03a1471e1..f71fd42819 100644 --- a/experimental/traffic-portal/src/app/api/testing/index.ts +++ b/experimental/traffic-portal/src/app/api/testing/index.ts @@ -19,9 +19,11 @@ import { CacheGroupService, CDNService, ChangeLogsService, + CoordinateService, DeliveryServiceService, InvalidationJobService, MiscAPIsService, + OriginService, PhysicalLocationService, ProfileService, ServerService, @@ -33,9 +35,11 @@ import { import { CacheGroupService as TestingCacheGroupService } from "./cache-group.service"; import { CDNService as TestingCDNService } from "./cdn.service"; import { ChangeLogsService as TestingChangeLogsService} from "./change-logs.service"; +import { CoordinateService as TestingCoordinateService } from "./coordinate.service"; import { DeliveryServiceService as TestingDeliveryServiceService } from "./delivery-service.service"; import { InvalidationJobService as TestingInvalidationJobService } from "./invalidation-job.service"; import { MiscAPIsService as TestingMiscAPIsService } from "./misc-apis.service"; +import { OriginService as TestingOriginService } from "./origin.service"; import { PhysicalLocationService as TestingPhysicalLocationService } from "./physical-location.service"; import { ProfileService as TestingProfileService } from "./profile.service"; import { ServerService as TestingServerService } from "./server.service"; @@ -56,9 +60,11 @@ import { UserService as TestingUserService } from "./user.service"; {provide: CacheGroupService, useClass: TestingCacheGroupService}, {provide: ChangeLogsService, useClass: TestingChangeLogsService}, {provide: CDNService, useClass: TestingCDNService}, + {provide: CoordinateService, useClass: TestingCoordinateService}, {provide: DeliveryServiceService, useClass: TestingDeliveryServiceService}, {provide: InvalidationJobService, useClass: TestingInvalidationJobService}, {provide: MiscAPIsService, useClass: TestingMiscAPIsService}, + {provide: OriginService, useClass: TestingOriginService}, {provide: PhysicalLocationService, useClass: TestingPhysicalLocationService}, {provide: ProfileService, useClass: TestingProfileService}, {provide: ServerService, useClass: TestingServerService}, diff --git a/experimental/traffic-portal/src/app/api/testing/origin.service.ts b/experimental/traffic-portal/src/app/api/testing/origin.service.ts new file mode 100644 index 0000000000..e85ec3f256 --- /dev/null +++ b/experimental/traffic-portal/src/app/api/testing/origin.service.ts @@ -0,0 +1,182 @@ +/* + * 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 { Injectable } from "@angular/core"; +import type { RequestOrigin, RequestOriginResponse } from "trafficops-types"; + +import { + CacheGroupService, + CoordinateService, + DeliveryServiceService, + ProfileService, + UserService, +} from ".."; + +/** + * CoordinateService exposes API functionality relating to Coordinates. + */ +@Injectable() +export class OriginService { + private readonly origins: Array = [ + { + cachegroup: null, + cachegroupId: null, + coordinate: null, + coordinateId: null, + deliveryService: "test", + deliveryServiceId: 1, + fqdn: "origin.infra.ciab.test", + id: 1, + ip6Address: null, + ipAddress: null, + isPrimary: false, + lastUpdated: new Date(), + name: "test", + port: null, + profile: null, + profileId: null, + protocol: "http", + tenant: "root", + tenantId: 1, + }, + ]; + + constructor( + private readonly userService: UserService, + private readonly coordinateService: CoordinateService, + private readonly cacheGroupService: CacheGroupService, + private readonly profileService: ProfileService, + private readonly dsService: DeliveryServiceService + ) {} + + public async getOrigins(): Promise>; + public async getOrigins( + nameOrID: string | number + ): Promise; + + /** + * Gets one or all Coordinates from Traffic Ops. + * + * @param nameOrID If given, returns only the ResponseCoordinate with the given name + * (string) or ID (number). + * @returns An Array of ResponseCoordinate objects - or a single ResponseCoordinate object if 'nameOrID' + * was given. + */ + public async getOrigins( + nameOrID?: string | number + ): Promise | RequestOriginResponse> { + if (nameOrID) { + let origin; + switch (typeof nameOrID) { + case "string": + origin = this.origins.find((d) => d.name === nameOrID); + break; + case "number": + origin = this.origins.find((d) => d.id === nameOrID); + } + if (!origin) { + throw new Error(`no such Origin: ${nameOrID}`); + } + return origin; + } + return this.origins; + } + + /** + * Replaces the current definition of a Origin with the one given. + * + * @param origin The new Origin. + * @returns The updated Origin. + */ + public async updateOrigin( + origin: RequestOriginResponse + ): Promise { + const id = this.origins.findIndex((d) => d.id === origin.id); + if (id === -1) { + throw new Error(`no such Origin: ${origin.id}`); + } + this.origins[id] = origin; + return origin; + } + + /** + * Creates a new Origin. + * + * @param origin The Origin to create. + * @returns The created Origin. + */ + public async createOrigin( + origin: RequestOrigin + ): Promise { + const tenant = await this.userService.getTenants(origin.tenantID); + const ds = await this.dsService.getDeliveryServices( + origin.deliveryServiceId + ); + let profile = null; + if (!!origin?.profileId) { + profile = await this.profileService.getProfiles(origin.profileId); + } + let coordinate = null; + if (!!origin?.coordinateId) { + coordinate = await this.coordinateService.getCoordinates( + origin.coordinateId + ); + } + let cacheGroup = null; + if (!!origin?.cachegroupId) { + cacheGroup = await this.cacheGroupService.getCacheGroups( + origin.cachegroupId + ); + } + + const created = { + cachegroup: cacheGroup?.name ?? null, + cachegroupId: cacheGroup?.id ?? null, + coordinate: coordinate?.name ?? null, + coordinateId: coordinate?.id ?? null, + deliveryService: ds.displayName ?? null, + deliveryServiceId: ds.id ?? null, + fqdn: "", + id: 1, + ip6Address: null, + ipAddress: null, + isPrimary: null, + lastUpdated: new Date(), + name: "", + port: null, + profile: profile?.name ?? null, + profileId: profile?.id ?? null, + protocol: "https" as never, + tenant: tenant.name ?? null, + tenantId: tenant.id ?? null, + }; + this.origins.push(created); + return created; + } + + /** + * Deletes an existing Origin. + * + * @param origin The Origin to be deleted (or its ID) + */ + public async deleteOrigin( + origin: RequestOriginResponse | number + ): Promise { + const id = typeof origin === "number" ? origin : origin.id; + const index = this.origins.findIndex((d) => d.id === id); + if (index === -1) { + throw new Error(`no such Origin: ${id}`); + } + this.origins.splice(index, 1); + } +} diff --git a/experimental/traffic-portal/src/app/core/core.module.ts b/experimental/traffic-portal/src/app/core/core.module.ts index 4a5f1a96ad..4a09a645f1 100644 --- a/experimental/traffic-portal/src/app/core/core.module.ts +++ b/experimental/traffic-portal/src/app/core/core.module.ts @@ -51,6 +51,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 { OriginDetailComponent } from "./origins/detail/origin-detail.component"; +import { OriginsTableComponent } from "./origins/table/origins-table.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"; @@ -118,6 +120,8 @@ export const ROUTES: Routes = [ { component: CoordinatesTableComponent, path: "coordinates" }, { component: TypesTableComponent, path: "types" }, { component: TypeDetailComponent, path: "types/:id"}, + { component: OriginsTableComponent, path: "origins" }, + { component: OriginDetailComponent, path: "origins/:id"}, { component: ParametersTableComponent, path: "parameters" }, { component: ParameterDetailComponent, path: "parameters/:id" }, { component: StatusesTableComponent, path: "statuses" }, @@ -175,6 +179,8 @@ export const ROUTES: Routes = [ TopologyDetailsComponent, TypeDetailComponent, TypesTableComponent, + OriginDetailComponent, + OriginsTableComponent, UpdatePasswordDialogComponent, UpdateStatusComponent, UserDetailsComponent, diff --git a/experimental/traffic-portal/src/app/core/origins/detail/origin-detail.component.html b/experimental/traffic-portal/src/app/core/origins/detail/origin-detail.component.html new file mode 100644 index 0000000000..94c14be8fe --- /dev/null +++ b/experimental/traffic-portal/src/app/core/origins/detail/origin-detail.component.html @@ -0,0 +1,133 @@ + + + + +
+ + + ID + + + + Name + + + + Tenant + + {{ t.name }} + + + + FQDN + + + + IPv4 Address + + + + IPv6 Address + + + + Protocol + + {{ p }} + + + + TCP Port + + + + Delivery Service + + {{ ds.displayName }} + + + + Coordinate + + {{ c.name }} + + + + Cache Group + + {{ c.name }} + + + + Profile + + {{ c.name }} + + + + Last Updated + + + + + + + +
+
diff --git a/experimental/traffic-portal/src/app/core/origins/detail/origin-detail.component.scss b/experimental/traffic-portal/src/app/core/origins/detail/origin-detail.component.scss new file mode 100644 index 0000000000..ebe77042d3 --- /dev/null +++ b/experimental/traffic-portal/src/app/core/origins/detail/origin-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/origins/detail/origin-detail.component.spec.ts b/experimental/traffic-portal/src/app/core/origins/detail/origin-detail.component.spec.ts new file mode 100644 index 0000000000..62501ed52b --- /dev/null +++ b/experimental/traffic-portal/src/app/core/origins/detail/origin-detail.component.spec.ts @@ -0,0 +1,125 @@ +/* + * 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 { HarnessLoader } from "@angular/cdk/testing"; +import { TestbedHarnessEnvironment } from "@angular/cdk/testing/testbed"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { MatButtonHarness } from "@angular/material/button/testing"; +import { MatDialogModule } from "@angular/material/dialog"; +import { MatDialogHarness } from "@angular/material/dialog/testing"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; +import { ActivatedRoute } from "@angular/router"; +import { RouterTestingModule } from "@angular/router/testing"; +import { ReplaySubject } from "rxjs"; + +import { OriginService } from "src/app/api"; +import { APITestingModule } from "src/app/api/testing"; +import { NavigationService } from "src/app/shared/navigation/navigation.service"; + +import { OriginDetailComponent } from "./origin-detail.component"; + +describe("OriginDetailComponent", () => { + let component: OriginDetailComponent; + let fixture: ComponentFixture; + let route: ActivatedRoute; + let paramMap: jasmine.Spy; + let loader: HarnessLoader; + let service: OriginService; + + const navSvc = jasmine.createSpyObj([], { + headerHidden: new ReplaySubject(), + headerTitle: new ReplaySubject(), + }); + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [OriginDetailComponent], + imports: [APITestingModule, RouterTestingModule, MatDialogModule, NoopAnimationsModule], + providers: [{provide: NavigationService, useValue: navSvc}], + }).compileComponents(); + + route = TestBed.inject(ActivatedRoute); + paramMap = spyOn(route.snapshot.paramMap, "get"); + paramMap.and.returnValue(null); + fixture = TestBed.createComponent(OriginDetailComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + service = TestBed.inject(OriginService); + loader = TestbedHarnessEnvironment.documentRootLoader(fixture); + await fixture.whenStable(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("new origin", async () => { + paramMap.and.returnValue("new"); + + fixture = TestBed.createComponent(OriginDetailComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + await fixture.whenStable(); + expect(paramMap).toHaveBeenCalled(); + expect(component.origin).not.toBeNull(); + expect(component.origin.name).toBe(""); + expect(component.new).toBeTrue(); + }); + + it("existing origin", async () => { + const id = 1; + paramMap.and.returnValue(id); + const origin = await service.getOrigins(id); + fixture = TestBed.createComponent(OriginDetailComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + await fixture.whenStable(); + expect(paramMap).toHaveBeenCalled(); + expect(component.origin).not.toBeNull(); + expect(component.origin.name).toBe(origin.name); + expect(component.new).toBeFalse(); + }); + + it("deletes existing Origins", async () => { + const spy = spyOn(service, "deleteOrigin").and.callThrough(); + let orgs = await service.getOrigins(); + const initialLength = orgs.length; + if (initialLength < 1) { + return fail("need at least one Origin"); + } + const org = orgs[0]; + component.origin = org; + component.new = false; + + const asyncExpectation = expectAsync(component.deleteOrigin()).toBeResolvedTo(undefined); + const dialogs = await loader.getAllHarnesses(MatDialogHarness); + if (dialogs.length !== 1) { + return fail(`failed to open dialog; ${dialogs.length} dialogs found`); + } + const dialog = dialogs[0]; + const buttons = await dialog.getAllHarnesses(MatButtonHarness.with({text: /^[cC][oO][nN][fF][iI][rR][mM]$/})); + if (buttons.length !== 1) { + return fail(`'confirm' button not found; ${buttons.length} buttons found`); + } + await buttons[0].click(); + + expect(spy).toHaveBeenCalledOnceWith(org); + + orgs = await service.getOrigins(); + expect(orgs).not.toContain(org); + expect(orgs.length).toBe(initialLength - 1); + + await asyncExpectation; + }); + +}); diff --git a/experimental/traffic-portal/src/app/core/origins/detail/origin-detail.component.ts b/experimental/traffic-portal/src/app/core/origins/detail/origin-detail.component.ts new file mode 100644 index 0000000000..33d199da1e --- /dev/null +++ b/experimental/traffic-portal/src/app/core/origins/detail/origin-detail.component.ts @@ -0,0 +1,198 @@ +/* + * 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 type { + RequestOrigin, + RequestOriginResponse, + ResponseCacheGroup, + ResponseCoordinate, + ResponseDeliveryService, + ResponseProfile, + ResponseTenant, +} from "trafficops-types"; + +import { + CacheGroupService, + CoordinateService, + DeliveryServiceService, + OriginService, + ProfileService, + UserService, +} from "src/app/api"; +import { DecisionDialogComponent } from "src/app/shared/dialogs/decision-dialog/decision-dialog.component"; +import { LoggingService } from "src/app/shared/logging.service"; +import { NavigationService } from "src/app/shared/navigation/navigation.service"; + +/** + * OriginDetailComponent is the controller for the origin add/edit form. + */ +@Component({ + selector: "tp-origins-detail", + styleUrls: ["./origin-detail.component.scss"], + templateUrl: "./origin-detail.component.html", +}) +export class OriginDetailComponent implements OnInit { + public new = false; + public origin!: RequestOriginResponse; + public tenants = new Array(); + public coordinates = new Array(); + public cacheGroups = new Array(); + public profiles = new Array(); + public deliveryServices = new Array(); + public protocols = new Array(); + + constructor( + private readonly route: ActivatedRoute, + private readonly originService: OriginService, + private readonly location: Location, + private readonly dialog: MatDialog, + private readonly navSvc: NavigationService, + private readonly log: LoggingService, + private readonly userService: UserService, + private readonly coordinateService: CoordinateService, + private readonly cacheGroupService: CacheGroupService, + private readonly profileService: ProfileService, + private readonly dsService: DeliveryServiceService + ) {} + + /** + * Angular lifecycle hook where data is initialized. + */ + public async ngOnInit(): Promise { + this.tenants = await this.userService.getTenants(); + this.cacheGroups = await this.cacheGroupService.getCacheGroups(); + this.coordinates = await this.coordinateService.getCoordinates(); + this.profiles = await this.profileService.getProfiles(); + this.deliveryServices = await this.dsService.getDeliveryServices(); + this.protocols = ["http", "https"]; + + const ID = this.route.snapshot.paramMap.get("id"); + if (ID === null) { + this.log.error("missing required route parameter 'id'"); + return; + } + if (ID === "new") { + this.navSvc.headerTitle.next("New Origin"); + this.new = true; + this.origin = { + cachegroup: null, + cachegroupId: -1, + coordinate: null, + coordinateId: -1, + deliveryService: null, + deliveryServiceId: -1, + fqdn: "", + id: -1, + ip6Address: null, + ipAddress: null, + isPrimary: null, + lastUpdated: new Date(), + name: "", + port: null, + profile: null, + profileId: -1, + protocol: "https", + tenant: null, + tenantId: -1, + }; + return; + } + const numID = parseInt(ID, 10); + if (Number.isNaN(numID)) { + this.log.error("route parameter 'id' was non-number: ", ID); + return; + } + this.origin = await this.originService.getOrigins(numID); + this.navSvc.headerTitle.next(`Origin: ${this.origin.name}`); + } + + /** + * Deletes the current origin. + */ + public async deleteOrigin(): Promise { + if (this.new) { + this.log.error("Unable to delete new origin"); + return; + } + const ref = this.dialog.open(DecisionDialogComponent, { + data: { + message: `Are you sure you want to delete origin ${this.origin.name}`, + title: "Confirm Delete", + }, + }); + ref.afterClosed().subscribe((result) => { + if (result) { + this.originService.deleteOrigin(this.origin); + this.location.back(); + } + }); + } + + /** + * Submits new/updated origin. + * + * @param e HTML form submission event. + */ + public async submit(e: Event): Promise { + e.preventDefault(); + e.stopPropagation(); + if (this.new) { + const { + cachegroupId, + coordinateId, + deliveryServiceId, + fqdn, + ipAddress, + ip6Address, + name, + port, + protocol, + profileId, + tenantId, + } = this.origin; + + const requestOrigin: RequestOrigin = { + deliveryServiceId, + fqdn, + ip6Address, + ipAddress, + name, + port, + protocol, + tenantID: tenantId, + }; + + if (coordinateId !== -1) { + requestOrigin.coordinateId = coordinateId; + } + + if (cachegroupId !== -1) { + requestOrigin.cachegroupId = cachegroupId; + } + + if (profileId !== -1) { + requestOrigin.profileId = profileId; + } + + this.origin = await this.originService.createOrigin(requestOrigin); + this.new = false; + } else { + this.origin = await this.originService.updateOrigin(this.origin); + } + } +} diff --git a/experimental/traffic-portal/src/app/core/origins/table/origins-table.component.html b/experimental/traffic-portal/src/app/core/origins/table/origins-table.component.html new file mode 100644 index 0000000000..a32045d348 --- /dev/null +++ b/experimental/traffic-portal/src/app/core/origins/table/origins-table.component.html @@ -0,0 +1,33 @@ + + + +
+ +
+ + +
+ + + add + diff --git a/experimental/traffic-portal/src/app/core/origins/table/origins-table.component.scss b/experimental/traffic-portal/src/app/core/origins/table/origins-table.component.scss new file mode 100644 index 0000000000..a76ede4a23 --- /dev/null +++ b/experimental/traffic-portal/src/app/core/origins/table/origins-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/origins/table/origins-table.component.spec.ts b/experimental/traffic-portal/src/app/core/origins/table/origins-table.component.spec.ts new file mode 100644 index 0000000000..8800a3271e --- /dev/null +++ b/experimental/traffic-portal/src/app/core/origins/table/origins-table.component.spec.ts @@ -0,0 +1,199 @@ +/* + * 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, + MatDialogRef, +} from "@angular/material/dialog"; +import { ActivatedRoute } from "@angular/router"; +import { RouterTestingModule } from "@angular/router/testing"; +import { of } from "rxjs"; +import type { ResponseOrigin } from "trafficops-types"; + +import { OriginService } from "src/app/api"; +import { APITestingModule } from "src/app/api/testing"; +import { isAction } from "src/app/shared/generic-table/generic-table.component"; + +import { OriginsTableComponent } from "./origins-table.component"; + +const testOrigin: ResponseOrigin = { + cachegroup: "", + cachegroupId: 1, + coordinate: "", + coordinateId: 1, + deliveryService: "", + deliveryServiceId: 1, + fqdn: "0", + id: 1, + ip6Address: "", + ipAddress: "", + isPrimary: false, + lastUpdated: new Date(), + name: "TestOrigin", + port: 80, + profile: "", + profileId: 1, + protocol: "https", + tenant: "*", + tenantId: 0, +}; + +describe("OriginsTableComponent", () => { + let component: OriginsTableComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [OriginsTableComponent], + imports: [APITestingModule, RouterTestingModule, MatDialogModule], + }).compileComponents(); + + fixture = TestBed.createComponent(OriginsTableComponent); + 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 = "testorigin"; + 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 = "testorigin"; + 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("handle 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(OriginService); + const spy = spyOn(api, "deleteOrigin").and.callThrough(); + expect(spy).not.toHaveBeenCalled(); + + const dialogService = TestBed.inject(MatDialog); + const openSpy = spyOn(dialogService, "open").and.returnValue({ + afterClosed: () => of(true), + } as MatDialogRef); + + const origin = await api.createOrigin({ + deliveryServiceId: 1, + fqdn: "0", + name: "*", + protocol: "https", + tenantID: 1, + }); + expect(openSpy).not.toHaveBeenCalled(); + const asyncExpectation = expectAsync( + component.handleContextMenu({ + action: "delete", + data: origin, + }) + ).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(testOrigin)).toBe(String(testOrigin.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(testOrigin)).toBe(String(testOrigin.id)); + expect(item.queryParams).toBeUndefined(); + expect(item.fragment).toBeUndefined(); + expect(item.newTab).toBeTrue(); + }); +}); diff --git a/experimental/traffic-portal/src/app/core/origins/table/origins-table.component.ts b/experimental/traffic-portal/src/app/core/origins/table/origins-table.component.ts new file mode 100644 index 0000000000..984879aa6a --- /dev/null +++ b/experimental/traffic-portal/src/app/core/origins/table/origins-table.component.ts @@ -0,0 +1,193 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, type OnInit } from "@angular/core"; +import { FormControl } from "@angular/forms"; +import { MatDialog } from "@angular/material/dialog"; +import { ActivatedRoute } from "@angular/router"; +import { ColDef } from "ag-grid-community"; +import { BehaviorSubject } from "rxjs"; +import type { RequestOriginResponse } from "trafficops-types"; + +import { OriginService } 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"; + +/** + * OriginsTableComponent is the controller for the "Origins" table. + */ +@Component({ + selector: "tp-origins", + styleUrls: ["./origins-table.component.scss"], + templateUrl: "./origins-table.component.html", +}) +export class OriginsTableComponent implements OnInit { + /** List of origins */ + public origins: Promise>; + + /** Definitions of the table's columns according to the ag-grid API */ + public columnDefs: ColDef[] = [ + { + field: "name", + headerName: "Name", + }, + { + field: "tenant", + headerName: "Tenant", + }, + { + field: "isPrimary", + filter: "tpBooleanFilter", + headerName: "Primary", + }, + { + field: "deliveryService", + headerName: "Delivery Service", + }, + { + field: "fqdn", + headerName: "FQDN", + }, + { + cellRenderer: "sshCellRenderer", + field: "ipAddress", + headerName: "IPv4 Address", + }, + { + cellRenderer: "sshCellRenderer", + field: "ip6Address", + headerName: "IPv6 Address", + }, + { + field: "protocol", + headerName: "Protocol", + }, + { + field: "port", + headerName: "Port", + }, + { + field: "coordinate", + headerName: "Coordinate", + }, + { + field: "cachegroup", + headerName: "CacheGroup", + }, + { + field: "profile", + headerName: "Profile", + }, + { + field: "lastUpdated", + filter: "agDateColumnFilter", + headerName: "Last Updated", + hide: true, + }, + ]; + + /** Definitions for the context menu items (which act on augmented origin data). */ + public contextMenuItems: Array> = [ + { + href: (origin: RequestOriginResponse): string => `${origin.id}`, + name: "Edit", + }, + { + href: (origin: RequestOriginResponse): string => `${origin.id}`, + name: "Open in New Tab", + newTab: true, + }, + { + action: "delete", + multiRow: false, + name: "Delete", + }, + ]; + + /** Defines what the table should do when a row is double-clicked. */ + public doubleClickLink: DoubleClickLink = { + href: (row: RequestOriginResponse): string => `/core/origins/${row.id}`, + }; + + /** A subject that child components can subscribe to for access to the fuzzy search query text */ + public fuzzySubject: BehaviorSubject; + + /** Form controller for the user search input. */ + public fuzzControl = new FormControl("", { nonNullable: true }); + + constructor( + private readonly route: ActivatedRoute, + private readonly navSvc: NavigationService, + private readonly api: OriginService, + private readonly dialog: MatDialog, + public readonly auth: CurrentUserService + ) { + this.fuzzySubject = new BehaviorSubject(""); + this.origins = this.api.getOrigins(); + this.navSvc.headerTitle.next("Origins"); + } + + /** 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 + ): Promise { + const data = evt.data as RequestOriginResponse; + switch (evt.action) { + case "delete": + const ref = this.dialog.open(DecisionDialogComponent, { + data: { + message: `Are you sure you want to delete origin ${data.name} with id ${data.id} ?`, + title: "Confirm Delete", + }, + }); + ref.afterClosed().subscribe((result) => { + if (result) { + this.api + .deleteOrigin(data.id) + .then( + async () => + (this.origins = this.api.getOrigins()) + ); + } + }); + break; + } + } +} diff --git a/experimental/traffic-portal/src/app/shared/navigation/navigation.service.ts b/experimental/traffic-portal/src/app/shared/navigation/navigation.service.ts index aa69fad15b..e3331ac550 100644 --- a/experimental/traffic-portal/src/app/shared/navigation/navigation.service.ts +++ b/experimental/traffic-portal/src/app/shared/navigation/navigation.service.ts @@ -203,7 +203,7 @@ export class NavigationService { href: "/core/types", name: "Types" }, { - href: `${this.tpv1Url}/origins`, + href: "/core/origins", name: "Origins" }, {