diff --git a/experimental/traffic-portal/nightwatch/globals/globals.ts b/experimental/traffic-portal/nightwatch/globals/globals.ts index afcd947938..d8c3925b58 100644 --- a/experimental/traffic-portal/nightwatch/globals/globals.ts +++ b/experimental/traffic-portal/nightwatch/globals/globals.ts @@ -36,6 +36,8 @@ import type { ProfilePageObject } from "nightwatch/page_objects/profiles/profile import type { PhysLocDetailPageObject } from "nightwatch/page_objects/servers/physLocDetail"; import type { PhysLocTablePageObject } from "nightwatch/page_objects/servers/physLocTable"; import type { ServersPageObject } from "nightwatch/page_objects/servers/servers"; +import type { StatusDetailPageObject } from "nightwatch/page_objects/statuses/statusDetail"; +import type { StatusesTablePageObject } from "nightwatch/page_objects/statuses/statusesTable"; import type { ChangeLogsPageObject } from "nightwatch/page_objects/users/changeLogs"; import type { TenantDetailPageObject } from "nightwatch/page_objects/users/tenantDetail"; import type { TenantsPageObject } from "nightwatch/page_objects/users/tenants"; @@ -66,6 +68,8 @@ import { ResponseCoordinate, RequestCoordinate, RequestType, + ResponseStatus, + RequestStatus, ResponseProfile, RequestProfile, ProfileType @@ -110,6 +114,10 @@ declare module "nightwatch" { physLocTable: () => PhysLocTablePageObject; servers: () => ServersPageObject; }; + statuses: { + statusesTable: () => StatusesTablePageObject; + statusDetail: () => StatusDetailPageObject; + }; users: { changeLogs: () => ChangeLogsPageObject; tenants: () => TenantsPageObject; @@ -151,6 +159,7 @@ export interface CreatedData { steeringDS: ResponseDeliveryService; tenant: ResponseTenant; type: TypeFromResponse; + statuses: ResponseStatus; profile: ResponseProfile; } @@ -390,6 +399,16 @@ const globals = { console.log(`Successfully created Type ${respType.name}`); data.type = respType; + const status: RequestStatus = { + description: "blah", + name: `status${globals.uniqueString}`, + }; + url = `${apiUrl}/statuses`; + resp = await client.post(url, JSON.stringify(status)); + const respStatus: ResponseStatus = resp.data.response; + console.log(`Successfully created Profile ${respStatus.name}`); + data.statuses = respStatus; + const profile: RequestProfile = { cdn: 1, description: "blah", diff --git a/experimental/traffic-portal/nightwatch/page_objects/common.ts b/experimental/traffic-portal/nightwatch/page_objects/common.ts index 091b647a65..01534b4b51 100644 --- a/experimental/traffic-portal/nightwatch/page_objects/common.ts +++ b/experimental/traffic-portal/nightwatch/page_objects/common.ts @@ -63,6 +63,7 @@ const commonPageObject = { regions: "[aria-label='Navigate to Regions']", servers: "[aria-label='Navigate to Servers']", serversContainer: "[aria-label='Toggle Servers']", + statuses: "[aria-label='Navigate to Statuses']", tenants: "[aria-label='Navigate to Tenants']", types: "[aria-label='Navigate to Types']", users: "[aria-label='Navigate to Users']", diff --git a/experimental/traffic-portal/nightwatch/page_objects/statuses/statusDetail.ts b/experimental/traffic-portal/nightwatch/page_objects/statuses/statusDetail.ts new file mode 100644 index 0000000000..133be79e6d --- /dev/null +++ b/experimental/traffic-portal/nightwatch/page_objects/statuses/statusDetail.ts @@ -0,0 +1,42 @@ +/* + * 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 Status Details. + */ +export type StatusDetailPageObject = EnhancedPageObject<{}, typeof statusDetailPageObject.elements>; + +const statusDetailPageObject = { + elements: { + description: { + selector: "input[name='description']" + }, + id: { + selector: "input[name='id']" + }, + lastUpdated: { + selector: "input[name='lastUpdated']" + }, + name: { + selector: "input[name='name']" + }, + saveBtn: { + selector: "button[type='submit']" + } + }, +}; + +export default statusDetailPageObject; diff --git a/experimental/traffic-portal/nightwatch/page_objects/statuses/statusesTable.ts b/experimental/traffic-portal/nightwatch/page_objects/statuses/statusesTable.ts new file mode 100644 index 0000000000..6072bd6bb3 --- /dev/null +++ b/experimental/traffic-portal/nightwatch/page_objects/statuses/statusesTable.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 Statuses table commands + */ +type StatusesTableCommands = TableSectionCommands; + +/** + * Defines the Page Object for the Statuses page. + */ +export type StatusesTablePageObject = EnhancedPageObject<{}, {}, EnhancedSectionInstance>; + +const statusesTablePageObject = { + api: {} as NightwatchAPI, + sections: { + statusesTable: { + commands: { + ...TABLE_COMMANDS + }, + elements: {}, + selector: "mat-card" + } + }, + url(): string { + return `${this.api.launchUrl}/core/statuses`; + } +}; + +export default statusesTablePageObject; diff --git a/experimental/traffic-portal/nightwatch/tests/statuses/detail.spec.ts b/experimental/traffic-portal/nightwatch/tests/statuses/detail.spec.ts new file mode 100644 index 0000000000..65203fbf84 --- /dev/null +++ b/experimental/traffic-portal/nightwatch/tests/statuses/detail.spec.ts @@ -0,0 +1,44 @@ +/* + * 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("Status Detail Spec", () => { + it("Test status", () => { + const page = browser.page.statuses.statusDetail(); + browser.url(`${page.api.launchUrl}/core/statuses/${browser.globals.testData.statuses.id}`, res => { + browser.assert.ok(res.status === 0); + page.waitForElementVisible("mat-card") + .assert.enabled("@name") + .assert.enabled("@description") + .assert.enabled("@saveBtn") + .assert.not.enabled("@id") + .assert.not.enabled("@lastUpdated") + .assert.valueEquals("@name", browser.globals.testData.statuses.name) + .assert.valueEquals("@id", String(browser.globals.testData.statuses.id)); + }); + }); + + it("New Status", () => { + const page = browser.page.statuses.statusDetail(); + browser.url(`${page.api.launchUrl}/core/statuses/new`, res => { + browser.assert.ok(res.status === 0); + page.waitForElementVisible("mat-card") + .assert.enabled("@name") + .assert.enabled("@description") + .assert.enabled("@saveBtn") + .assert.not.elementPresent("@id") + .assert.not.elementPresent("@lastUpdated") + .assert.valueEquals("@name", ""); + }); + }); +}); diff --git a/experimental/traffic-portal/nightwatch/tests/statuses/table.spec.ts b/experimental/traffic-portal/nightwatch/tests/statuses/table.spec.ts new file mode 100644 index 0000000000..a02d46be71 --- /dev/null +++ b/experimental/traffic-portal/nightwatch/tests/statuses/table.spec.ts @@ -0,0 +1,26 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +describe("Statuses Spec", () => { + it("Loads elements", async () => { + await browser.page.common() + .section.sidebar + .navigateToNode("statuses", ["serversContainer"]); + 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 >= 2); + }); + }); +}); diff --git a/experimental/traffic-portal/src/app/api/server.service.ts b/experimental/traffic-portal/src/app/api/server.service.ts index 4411056d03..1f0d8f23b2 100644 --- a/experimental/traffic-portal/src/app/api/server.service.ts +++ b/experimental/traffic-portal/src/app/api/server.service.ts @@ -14,7 +14,7 @@ import { HttpClient } from "@angular/common/http"; import { Injectable } from "@angular/core"; -import type { RequestServer, ResponseServer, ResponseStatus, Servercheck } from "trafficops-types"; +import type { RequestServer, RequestStatus, ResponseServer, ResponseStatus, Servercheck } from "trafficops-types"; import { APIService } from "./base-api.service"; @@ -118,7 +118,8 @@ export class ServerService extends APIService { let ret; switch (typeof idOrName) { case "number": - ret = this.get<[ResponseStatus]>(path, {params: {id: String(idOrName)}}).toPromise(); + const response = await this.get<[ResponseStatus]>(path, undefined, { id: String(idOrName) }).toPromise(); + ret = response[0]; break; case "string": ret = this.get<[ResponseStatus]>(path, {params: {name: idOrName}}).toPromise(); @@ -186,4 +187,34 @@ export class ServerService extends APIService { return this.put(`servers/${id}/status`, {offlineReason, status}).toPromise(); } + + /** + * Creating new Status. + * + * @param status The status to create. + * @returns The created status. + */ + public async createStatus(status: RequestStatus): Promise { + return this.post("statuses", status).toPromise(); + } + + /** + * Updates status Details. + * + * @param status The status to update. + * @returns The updated status. + */ + public async updateStatusDetail(status: ResponseStatus): Promise { + return this.put(`statuses/${status.id}`, status).toPromise(); + } + + /** + * Deletes an existing Status. + * + * @param statusId The Status ID + */ + public async deleteStatus(statusId: number | ResponseStatus): Promise { + const id = typeof (statusId) === "number" ? statusId : statusId.id; + return this.delete(`statuses/${id}`).toPromise(); + } } diff --git a/experimental/traffic-portal/src/app/api/testing/server.service.ts b/experimental/traffic-portal/src/app/api/testing/server.service.ts index a33ed5bb64..c316b0c9d3 100644 --- a/experimental/traffic-portal/src/app/api/testing/server.service.ts +++ b/experimental/traffic-portal/src/app/api/testing/server.service.ts @@ -13,7 +13,7 @@ */ import { Injectable } from "@angular/core"; -import type { RequestServer, ResponseServer, ResponseStatus, Servercheck } from "trafficops-types"; +import type { RequestServer, RequestStatus, ResponseServer, ResponseStatus, Servercheck } from "trafficops-types"; import { CDNService, PhysicalLocationService, ProfileService, TypeService } from ".."; @@ -47,7 +47,7 @@ export class ServerService { public servers = new Array(); - private readonly statuses = [ + private readonly statuses: ResponseStatus[] = [ { description: "Sever is administrative down and does not receive traffic.", id: 4, @@ -87,6 +87,7 @@ export class ServerService { ]; private idCounter = 1; + private statusIdCounter = 6; constructor( private readonly cdnService: CDNService, @@ -302,4 +303,55 @@ export class ServerService { srv.statusId = status.id; srv.offlineReason = offlineReason ?? null; } + + /** + * Creates a status. + * + * @param status The status details (name & description) to create. Description is an optional property in status. + * @returns The status as created and returned by the API. + */ + public async createStatus(status: RequestStatus): Promise { + const newStatus = { + description: status.description ? status.description : null, + id: ++this.statusIdCounter, + lastUpdated: new Date(), + name: status.name + }; + this.statuses.push(newStatus); + return newStatus; + } + + /** + * Updates status Details. + * + * @param payload containes name and description for the status., unique identifier thereof. + */ + public async updateStatusDetail(payload: ResponseStatus): Promise { + const index = this.statuses.findIndex(u => u.id === payload.id); + if (index < 0) { + throw new Error(`no such status with id: ${payload.id}`); + } + const updated = { + ...payload, + lastUpdated: new Date() + } as { description: string; id: number; lastUpdated: Date; name: string }; + this.statuses[index] = updated; + + return updated; + } + + /** + * Deletes a Status. + * + * @param statusId The ID of the Status to delete. + * @returns The deleted status. + */ + public async deleteStatus(statusId: number | ResponseStatus): Promise { + const id = typeof (statusId) === "number" ? statusId : statusId.id; + const idx = this.statuses.findIndex(j => j.id === id); + if (idx < 0) { + throw new Error(`no such status: #${id}`); + } + return this.statuses.splice(idx, 1)[0]; + } } diff --git a/experimental/traffic-portal/src/app/core/core.module.ts b/experimental/traffic-portal/src/app/core/core.module.ts index 9828ae033c..833519fe07 100644 --- a/experimental/traffic-portal/src/app/core/core.module.ts +++ b/experimental/traffic-portal/src/app/core/core.module.ts @@ -54,8 +54,10 @@ import { PhysLocTableComponent } from "./servers/phys-loc/table/phys-loc-table.c import { ServerDetailsComponent } from "./servers/server-details/server-details.component"; import { ServersTableComponent } from "./servers/servers-table/servers-table.component"; import { UpdateStatusComponent } from "./servers/update-status/update-status.component"; -import {TypeDetailComponent} from "./types/detail/type-detail.component"; -import {TypesTableComponent} from "./types/table/types-table.component"; +import { StatusDetailsComponent } from "./statuses/status-details/status-details.component"; +import { StatusesTableComponent } from "./statuses/statuses-table/statuses-table.component"; +import { TypeDetailComponent } from "./types/detail/type-detail.component"; +import { TypesTableComponent } from "./types/table/types-table.component"; import { TenantDetailsComponent } from "./users/tenants/tenant-details/tenant-details.component"; import { TenantsComponent } from "./users/tenants/tenants.component"; import { UserDetailsComponent } from "./users/user-details/user-details.component"; @@ -90,6 +92,8 @@ export const ROUTES: Routes = [ { component: CoordinatesTableComponent, path: "coordinates" }, { component: TypesTableComponent, path: "types" }, { component: TypeDetailComponent, path: "types/:id"}, + { component: StatusesTableComponent, path: "statuses" }, + { component: StatusDetailsComponent, path: "statuses/:id" }, { component: ISOGenerationFormComponent, path: "iso-gen"}, { component: ProfileTableComponent, path: "profiles"}, ].map(r => ({...r, canActivate: [AuthenticatedGuard]})); @@ -128,10 +132,12 @@ export const ROUTES: Routes = [ RegionsTableComponent, RegionDetailComponent, CacheGroupDetailsComponent, - CoordinatesTableComponent, - CoordinateDetailComponent, TypesTableComponent, TypeDetailComponent, + CoordinatesTableComponent, + CoordinateDetailComponent, + StatusesTableComponent, + StatusDetailsComponent, ISOGenerationFormComponent, ProfileTableComponent, CDNDetailComponent, diff --git a/experimental/traffic-portal/src/app/core/statuses/status-details/status-details.component.html b/experimental/traffic-portal/src/app/core/statuses/status-details/status-details.component.html new file mode 100644 index 0000000000..ae4fffe695 --- /dev/null +++ b/experimental/traffic-portal/src/app/core/statuses/status-details/status-details.component.html @@ -0,0 +1,44 @@ + + + + +
+ + + ID + + + + Name + + + + Description + + + + Last Updated + + + + + + + +
+
diff --git a/experimental/traffic-portal/src/app/core/statuses/status-details/status-details.component.scss b/experimental/traffic-portal/src/app/core/statuses/status-details/status-details.component.scss new file mode 100644 index 0000000000..0a24e3195e --- /dev/null +++ b/experimental/traffic-portal/src/app/core/statuses/status-details/status-details.component.scss @@ -0,0 +1,25 @@ +/* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ +mat-card { + margin: 1em auto; + width: 95%; + min-width: 350px; + + mat-card-content { + display: grid; + grid-template-columns: 1fr; + row-gap: 2em; + margin: 1em auto 10px; + } +} diff --git a/experimental/traffic-portal/src/app/core/statuses/status-details/status-details.component.spec.ts b/experimental/traffic-portal/src/app/core/statuses/status-details/status-details.component.spec.ts new file mode 100644 index 0000000000..ce7be0b2f3 --- /dev/null +++ b/experimental/traffic-portal/src/app/core/statuses/status-details/status-details.component.spec.ts @@ -0,0 +1,87 @@ +/* + * 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 { ServerService } from "src/app/api"; +import { APITestingModule } from "src/app/api/testing"; +import { NavigationService } from "src/app/shared/navigation/navigation.service"; + +import { StatusDetailsComponent } from "./status-details.component"; + +describe("StatusDetailsComponent", () => { + let component: StatusDetailsComponent; + let fixture: ComponentFixture; + let route: ActivatedRoute; + let paramMap: jasmine.Spy; + let service: ServerService; + + const navSvc = jasmine.createSpyObj([], { headerHidden: new ReplaySubject(), headerTitle: new ReplaySubject() }); + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [StatusDetailsComponent], + imports: [ + APITestingModule, + RouterTestingModule, + MatDialogModule + ], + providers: [ + { provide: NavigationService, useValue: navSvc } + ] + }) + .compileComponents(); + + route = TestBed.inject(ActivatedRoute); + paramMap = spyOn(route.snapshot.paramMap, "get"); + service = TestBed.inject(ServerService); + paramMap.and.returnValue(null); + fixture = TestBed.createComponent(StatusDetailsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("new status", async () => { + paramMap.and.returnValue("new"); + + fixture = TestBed.createComponent(StatusDetailsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + await fixture.whenStable(); + expect(paramMap).toHaveBeenCalled(); + expect(component.statusDetails).not.toBeNull(); + expect(component.new).toBeTrue(); + }); + + it("existing status", async () => { + const id = 1; + paramMap.and.returnValue(id); + const status = await service.getStatuses(id); + fixture = TestBed.createComponent(StatusDetailsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + await fixture.whenStable(); + expect(paramMap).toHaveBeenCalled(); + expect(component.statusDetails).not.toBeNull(); + expect(component.statusDetails.name).toBe(status.name); + expect(component.new).toBeFalse(); + }); +}); diff --git a/experimental/traffic-portal/src/app/core/statuses/status-details/status-details.component.ts b/experimental/traffic-portal/src/app/core/statuses/status-details/status-details.component.ts new file mode 100644 index 0000000000..04119b49f6 --- /dev/null +++ b/experimental/traffic-portal/src/app/core/statuses/status-details/status-details.component.ts @@ -0,0 +1,148 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Location } from "@angular/common"; +import { Component } from "@angular/core"; +import { FormControl, FormGroup } from "@angular/forms"; +import { MatDialog } from "@angular/material/dialog"; +import { ActivatedRoute } from "@angular/router"; +import { RequestStatus, ResponseStatus } from "trafficops-types"; + +import { ServerService } from "src/app/api"; +import { DecisionDialogComponent, DecisionDialogData } from "src/app/shared/dialogs/decision-dialog/decision-dialog.component"; +import { NavigationService } from "src/app/shared/navigation/navigation.service"; + +/** + * StatusDetailsComponent is the controller for a status "details" page. + */ +@Component({ + selector: "tp-status-details", + styleUrls: ["./status-details.component.scss"], + templateUrl: "./status-details.component.html", +}) +export class StatusDetailsComponent { + public new = false; + + /** Loader status for the actions */ + public loading = true; + + /** All details of status requested */ + public statusDetails!: ResponseStatus; + + /** Reactive form intialized to creat / edit status details */ + public statusDetailsForm = new FormGroup({ + description: new FormControl("", {nonNullable: true}), + name: new FormControl("", {nonNullable: true}), + }); + + /** + * Constructor. + * + * @param api The Servers API which is used to provide row data. + * @param route A reference to the route of this view which is used to get the 'id' query parameter of status. + * @param router Angular router + * @param dialog Dialog manager + * @param fb Form builder + * @param navSvc Manages the header + */ + constructor( + private readonly api: ServerService, + private readonly route: ActivatedRoute, + private readonly dialog: MatDialog, + private readonly navSvc: NavigationService, private readonly location: Location, + ) { + // Getting id from the route + const id = this.route.snapshot.paramMap.get("id"); + + /** + * Initializes table data, loading it from Traffic Ops. + * we check whether params is a number if not we shall assume user wants to add a new status. + */ + if (id && id !== "new") { + this.getStatusDetails(id); + } else { + this.navSvc.headerTitle.next("New Status"); + this.new = true; + this.loading = false; + } + } + + /** + * Get status details for the id + * patch the form with status details + * + * @param id ID of the status + */ + public async getStatusDetails(id: string | number): Promise { + this.statusDetails = await this.api.getStatuses(Number(id)); + + // Set page title with status Name + this.navSvc.headerTitle.next(`Status ${this.statusDetails.name}`); + + // Patch the form with existing data we got from service requested above. + this.statusDetailsForm.setValue({ + description: this.statusDetails.description ? this.statusDetails.description : "", + name: this.statusDetails.name + }); + + this.loading = false; + } + + /** + * On submitting the form we check for whether we are performing Create or Edit + * + * @param event HTML form submission event. + */ + public async onSubmit(event: Event): Promise { + event.preventDefault(); + event.stopPropagation(); + + if (this.statusDetailsForm.valid) { + if (this.new) { + const newData: RequestStatus = { + description: this.statusDetailsForm.controls.description.value, + name: this.statusDetailsForm.controls.name.value + }; + this.statusDetails = await this.api.createStatus(newData); + } else { + const editData: ResponseStatus = { + description: this.statusDetailsForm.controls.description.value, + id: this.statusDetails.id, + lastUpdated:this.statusDetails.lastUpdated, + name:this.statusDetailsForm.controls.name.value + }; + this.statusDetails = await this.api.updateStatusDetail(editData); + } + } + } + + /** + * Deleteting status + */ + public async deleteStatus(): Promise { + const ref = this.dialog.open(DecisionDialogComponent, { + data: { + message: `This action CANNOT be undone. This will permanently delete '${this.statusDetails.name}'.`, + title: `Delete Status: ${this.statusDetails.name}` + } + }); + + ref.afterClosed().subscribe(result => { + if (result) { + this.api.deleteStatus(this.statusDetails.id); + this.location.back(); + } + }); + } +} diff --git a/experimental/traffic-portal/src/app/core/statuses/statuses-table/statuses-table.component.html b/experimental/traffic-portal/src/app/core/statuses/statuses-table/statuses-table.component.html new file mode 100644 index 0000000000..0dabb5c1ee --- /dev/null +++ b/experimental/traffic-portal/src/app/core/statuses/statuses-table/statuses-table.component.html @@ -0,0 +1,28 @@ + + +
+ +
+ + +
+ +add diff --git a/experimental/traffic-portal/src/app/core/statuses/statuses-table/statuses-table.component.scss b/experimental/traffic-portal/src/app/core/statuses/statuses-table/statuses-table.component.scss new file mode 100644 index 0000000000..ebe77042d3 --- /dev/null +++ b/experimental/traffic-portal/src/app/core/statuses/statuses-table/statuses-table.component.scss @@ -0,0 +1,13 @@ +/* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ diff --git a/experimental/traffic-portal/src/app/core/statuses/statuses-table/statuses-table.component.spec.ts b/experimental/traffic-portal/src/app/core/statuses/statuses-table/statuses-table.component.spec.ts new file mode 100644 index 0000000000..b0e3a50fba --- /dev/null +++ b/experimental/traffic-portal/src/app/core/statuses/statuses-table/statuses-table.component.spec.ts @@ -0,0 +1,106 @@ +/* + * 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 { HttpClientModule } from "@angular/common/http"; +import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing"; +import { MatDialog, MatDialogModule, MatDialogRef } from "@angular/material/dialog"; +import { RouterTestingModule } from "@angular/router/testing"; +import { of } from "rxjs"; + +import { ServerService } from "src/app/api/server.service"; +import { APITestingModule } from "src/app/api/testing"; +import { isAction } from "src/app/shared/generic-table/generic-table.component"; + +import { StatusesTableComponent } from "./statuses-table.component"; + +describe("StatusesTableComponent", () => { + let component: StatusesTableComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [StatusesTableComponent], + imports: [ + APITestingModule, + HttpClientModule, + MatDialogModule, + RouterTestingModule.withRoutes([ + { component: StatusesTableComponent, path: "" }, + ]), + ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(StatusesTableComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("updates the fuzzy search output", fakeAsync(() => { + let called = false; + const text = "testquest"; + const spy = jasmine.createSpy("subscriber", (txt: string): void =>{ + if (!called) { + expect(txt).toBe(""); + called = true; + } else { + expect(txt).toBe(text); + } + }); + component.fuzzySubject.subscribe(spy); + tick(); + expect(spy).toHaveBeenCalled(); + component.fuzzControl.setValue(text); + component.updateURL(); + tick(); + expect(spy).toHaveBeenCalledTimes(2); + })); + + it("handles the 'delete' context menu item", fakeAsync(async () => { + const item = component.contextMenuItems.find(c => c.name === "Delete"); + if (!item) { + return fail("missing 'Delete' context menu item"); + } + if (!isAction(item)) { + return fail("expected an action, not a link"); + } + expect(item.multiRow).toBeFalsy(); + expect(item.disabled).toBeUndefined(); + + const api = TestBed.inject(ServerService); + const spy = spyOn(api, "deleteStatus").and.callThrough(); + expect(spy).not.toHaveBeenCalled(); + + const dialogService = TestBed.inject(MatDialog); + const openSpy = spyOn(dialogService, "open").and.returnValue({ + afterClosed: () => of(true) + } as MatDialogRef); + + const status = await api.createStatus({description: "blah", name: "test"}); + expect(openSpy).not.toHaveBeenCalled(); + const asyncExpectation = expectAsync(component.handleContextMenu({action: "delete", data: status})).toBeResolvedTo(undefined); + tick(); + + expect(openSpy).toHaveBeenCalled(); + tick(); + + expect(spy).toHaveBeenCalled(); + + await asyncExpectation; + })); +}); diff --git a/experimental/traffic-portal/src/app/core/statuses/statuses-table/statuses-table.component.ts b/experimental/traffic-portal/src/app/core/statuses/statuses-table/statuses-table.component.ts new file mode 100644 index 0000000000..e43ed839cc --- /dev/null +++ b/experimental/traffic-portal/src/app/core/statuses/statuses-table/statuses-table.component.ts @@ -0,0 +1,149 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Component, OnInit } from "@angular/core"; +import { FormControl } from "@angular/forms"; +import { MatDialog } from "@angular/material/dialog"; +import { ActivatedRoute } from "@angular/router"; +import { BehaviorSubject } from "rxjs"; +import { ResponseStatus } from "trafficops-types"; + +import { ServerService } from "src/app/api"; +import { CurrentUserService } from "src/app/shared/current-user/current-user.service"; +import { DecisionDialogComponent } from "src/app/shared/dialogs/decision-dialog/decision-dialog.component"; +import { ContextMenuActionEvent, ContextMenuItem } from "src/app/shared/generic-table/generic-table.component"; +import { NavigationService } from "src/app/shared/navigation/navigation.service"; + +/** + * StatusesTableComponent is the controller for the statuses page - which + * principally contains a table. + */ +@Component({ + selector: "tp-statuses-table", + styleUrls: ["./statuses-table.component.scss"], + templateUrl: "./statuses-table.component.html", +}) +export class StatusesTableComponent implements OnInit { + + /** All of the statues which should appear in the table. */ + public statuses: Promise>; + + /** Definitions of the table's columns according to the ag-grid API */ + public columnDefs = [ + { + field: "name", + headerName: "Name", + hide: false + }, + { + field: "description", + headerName: "Description", + hide: false + }, + { + field: "id", + headerName: "ID", + hide: true + }, + { + field: "lastUpdated", + headerName: "Last Updated", + hide: true + }]; + + /** The current search text. */ + public searchText = ""; + + /** Definitions for the context menu items (which act on statuses data). */ + public contextMenuItems: Array> = [ + { + href: (status: ResponseStatus): string => `${status.id}`, + name: "Open in New Tab", + newTab: true + }, + { + href: (status: ResponseStatus): string => `${status.id}`, + name: "Edit" + }, + { + action: "delete", + multiRow: false, + name: "Delete" + } + ]; + + /** A subject that child components can subscribe to for access to the fuzzy search query text */ + public fuzzySubject: BehaviorSubject; + + /** Form controller for the user search input. */ + public fuzzControl = new FormControl("", {nonNullable: true}); + + /** + * Constructs the component with its required injections. + * + * @param api The Servers API which is used to provide row data. + * @param navSvc Manages the header + */ + constructor( + private readonly dialog: MatDialog, + private readonly route: ActivatedRoute, + private readonly api: ServerService, + private readonly navSvc: NavigationService, public readonly auth: CurrentUserService + ) { + this.fuzzySubject = new BehaviorSubject(""); + this.statuses = this.api.getStatuses(); + this.navSvc.headerTitle.next("Statuses"); + } + + /** 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(); + } + } + ); + } + + /** + * Updates the "search" query parameter in the URL every time the search + * text input changes. + */ + public updateURL(): void { + this.fuzzySubject.next(this.searchText); + } + + /** + * 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 ResponseStatus; + switch(evt.action) { + case "delete": + const ref = this.dialog.open(DecisionDialogComponent, { + data: {message: `Are you sure you want to delete Status ${data.name} with ID ${data.id} ?`, title: "Confirm Delete"} + }); + ref.afterClosed().subscribe(result => { + if(result) { + this.api.deleteStatus(data.id).then(async () => this.statuses = this.api.getStatuses()); + } + }); + 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 14c0dff7aa..84d3876a43 100644 --- a/experimental/traffic-portal/src/app/shared/navigation/navigation.service.ts +++ b/experimental/traffic-portal/src/app/shared/navigation/navigation.service.ts @@ -117,7 +117,12 @@ export class NavigationService { }, { href: "/core/phys-locs", name: "Physical Locations" - }, { + }, + { + href: "/core/statuses", + name: "Statuses" + }, + { children: [{ href: "/core/cache-groups", name: "Cache Groups"