diff --git a/openapi-specs/component-catalog-v1.0.0.yaml b/openapi-specs/component-catalog-v1.0.0.yaml index 82ca393..02345b7 100644 --- a/openapi-specs/component-catalog-v1.0.0.yaml +++ b/openapi-specs/component-catalog-v1.0.0.yaml @@ -75,6 +75,50 @@ paths: application/json: schema: $ref: '#/components/schemas/RestErrorMessage' + /project/{projectKey}/component/{componentId}: + get: + tags: + - Project-components + summary: Returns the extended information of a project component given both its project key and component ID in the Bitbucket repository. + operationId: getProjectComponentById + parameters: + - name: projectKey + in: path + description: project key. + required: true + schema: + type: string + - name: componentId + in: path + description: component ID. + required: true + schema: + type: string + responses: + "200": + description: The extended information of a project component. + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectComponentExtendedInfo' + "401": + description: Invalid client token on the request. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "403": + description: Insufficient permissions for the client to access the resource. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "500": + description: Server error. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' /catalog-descriptors: get: tags: @@ -847,12 +891,51 @@ components: canBeDeleted: type: boolean example: true + hasAutomatedDeletionWorkflow: + type: boolean + example: true logoUrl: type: string example: https://somepic.jpg componentUrl: type: string example: 'https://bitbucket.com/projects/CATALOGS/repos/project-components/browse/projects' + ProjectComponentParameter: + properties: + name: + type: string + example: 'environment' + values: + type: array + items: + type: string + example: + - 'dev' + - 'test' + ProjectComponentExtendedInfo: + properties: + componentId: + type: string + example: 'nextjs-basic-app' + catalogItemId: + type: string + example: 'some-encoded-info' + catalogItemRef: + type: string + example: 'more-encoded-info' + status: + type: string + example: 'CREATING' + componentUrl: + type: string + example: 'https://bitbucket.com/projects/CATALOGS/repos/project-components/browse/projects' + workflowJobId: + type: string + example: '123456' + parameters: + type: array + items: + $ref: '#/components/schemas/ProjectComponentParameter' CatalogDescriptor: properties: id: @@ -1206,6 +1289,12 @@ components: example: "https://bitbucket.com/projects/DEVSTACK/repos/devstack-component-catalog" nullable: true + workflowJobId: + type: string + description: the workflow job id from AWX to correlate provisioning status with AWX job status updates + example: "123456" + nullable: true + parameters: type: array description: List of name/value string parameters. diff --git a/src/app/components/request-deletion-simple-dialog/request-deletion-simple-dialog.component.html b/src/app/components/request-deletion-simple-dialog/request-deletion-simple-dialog.component.html new file mode 100644 index 0000000..c3f12a3 --- /dev/null +++ b/src/app/components/request-deletion-simple-dialog/request-deletion-simple-dialog.component.html @@ -0,0 +1,14 @@ +
+

{{ data.componentName }} - Request Deletion

+ +
+ +
+ + To request deletion for deployed components, a change requested is needed. +
+
+ + + + diff --git a/src/app/components/request-deletion-simple-dialog/request-deletion-simple-dialog.component.scss b/src/app/components/request-deletion-simple-dialog/request-deletion-simple-dialog.component.scss new file mode 100644 index 0000000..04065b5 --- /dev/null +++ b/src/app/components/request-deletion-simple-dialog/request-deletion-simple-dialog.component.scss @@ -0,0 +1,99 @@ +app-request-deletion-dialog { + width: 38.25rem; + display: block !important; + padding: 1.5rem; + box-sizing: border-box; + + > * { + padding: 0 !important; + color: var(--appshell-color-dark-green-main) !important; + } + + .dialog-header { + margin: 0.5rem 0 0 !important; + display: inline-flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + width: 100%; + + h2 { + margin: 0 !important; + padding: 0; + font-family: "AppShellHeadingFont"; + font-size: 20px; + font-weight: 500; + line-height: 24px; + + &::before, + &::after { + height: auto !important; + } + } + + appshell-icon { + cursor: pointer; + } + } + + mat-dialog-content { + display: flex; + flex-direction: column; + gap: 16px; + min-height: 100px; + + .help-message-ctn { + display: flex; + flex-direction: row; + align-items: center; + gap: 0.75rem; + margin: 1.5rem 0 !important; + padding: 1rem; + + background-color: #d2f2f7; + color: #076d7e; + + font-size: 14px; + font-weight: 400; + line-height: 20px; + } + } + + mat-dialog-actions { + gap: 1rem; + + button:first-of-type { + padding: 0.75rem 1rem; + + font-size: 0.875rem; + line-height: 1.25rem; + + --mdc-text-button-container-height: unset !important; + --mdc-text-button-container-padding-horizontal: 0; + + &:hover { + background-color: transparent; + } + } + + button:last-of-type { + padding: 0.75rem 1.5rem; + height: auto; + + font-size: 0.875rem; + line-height: 1.25rem; + + --mdc-filled-button-container-height: unset !important; + + display: inline-flex; + align-items: center; + + border: 2px solid var(--mdc-filled-button-container-color); + border-radius: 0.125rem; + + &:disabled { + border: none; + } + } + } +} \ No newline at end of file diff --git a/src/app/components/request-deletion-simple-dialog/request-deletion-simple-dialog.component.ts b/src/app/components/request-deletion-simple-dialog/request-deletion-simple-dialog.component.ts new file mode 100644 index 0000000..65e4c3b --- /dev/null +++ b/src/app/components/request-deletion-simple-dialog/request-deletion-simple-dialog.component.ts @@ -0,0 +1,43 @@ +import { CommonModule } from "@angular/common"; +import { Component, Inject, ViewEncapsulation } from "@angular/core"; +import { FormsModule } from "@angular/forms"; +import { MatButtonModule } from "@angular/material/button"; +import { MAT_DIALOG_DATA, MatDialogRef, MatDialogContent, MatDialogActions } from "@angular/material/dialog"; +import { MatFormFieldModule } from "@angular/material/form-field"; +import { MatInputModule } from "@angular/material/input"; +import { MatRadioModule } from "@angular/material/radio"; +import { RequestDeletionDialogData } from "../../models/request-deletion-dialog-data"; +import { AppShellIconComponent } from "@opendevstack/ngx-appshell"; + +@Component({ + selector: 'app-request-deletion-dialog', + imports: [ + CommonModule, + MatButtonModule, + MatFormFieldModule, + MatInputModule, + MatRadioModule, + FormsModule, + AppShellIconComponent, + MatDialogContent, + MatDialogActions +], + templateUrl: './request-deletion-simple-dialog.component.html', + styleUrl: './request-deletion-simple-dialog.component.scss', + encapsulation: ViewEncapsulation.None +}) +export class RequestDeletionSimpleDialogComponent { + + constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: RequestDeletionDialogData + ) {} + + onAccept(): void { + this.dialogRef.close(this.data); + } + + onCancel(): void { + this.dialogRef.close(); + } +} \ No newline at end of file diff --git a/src/app/components/request-deletion-simple-dialog/request-deletion-simple-dialog.spec.ts b/src/app/components/request-deletion-simple-dialog/request-deletion-simple-dialog.spec.ts new file mode 100644 index 0000000..45842c3 --- /dev/null +++ b/src/app/components/request-deletion-simple-dialog/request-deletion-simple-dialog.spec.ts @@ -0,0 +1,85 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { By } from '@angular/platform-browser'; + +import { RequestDeletionSimpleDialogComponent } from './request-deletion-simple-dialog.component'; +import { RequestDeletionDialogData } from '../../models/request-deletion-dialog-data'; + +describe('RequestDeletionSimpleDialogComponent', () => { + let component: RequestDeletionSimpleDialogComponent; + let fixture: ComponentFixture; + let dialogRefSpy: jasmine.SpyObj>; + + const dialogData: RequestDeletionDialogData = { + componentName: 'test-component', + projectKey: 'test-project', + location: 'test-location' + }; + + beforeEach(async () => { + dialogRefSpy = jasmine.createSpyObj('MatDialogRef', ['close']); + + await TestBed.configureTestingModule({ + imports: [RequestDeletionSimpleDialogComponent], + providers: [ + { provide: MatDialogRef, useValue: dialogRefSpy }, + { provide: MAT_DIALOG_DATA, useValue: dialogData } + ] + }).compileComponents(); + + fixture = TestBed.createComponent(RequestDeletionSimpleDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should close dialog with data on accept', () => { + component.onAccept(); + + expect(dialogRefSpy.close).toHaveBeenCalledWith(dialogData); + }); + + it('should close dialog without data on cancel', () => { + component.onCancel(); + + expect(dialogRefSpy.close).toHaveBeenCalledWith(); + }); + + it('should call onCancel when close icon is clicked', () => { + spyOn(component, 'onCancel'); + + const closeIcon = fixture.debugElement.query( + By.css('appshell-icon[icon="close"]') + ); + closeIcon.triggerEventHandler('click', null); + + expect(component.onCancel).toHaveBeenCalled(); + }); + + it('should call onCancel when Cancel button is clicked', () => { + spyOn(component, 'onCancel'); + + const cancelButton = fixture.debugElement + .queryAll(By.css('button')) + .find(btn => btn.nativeElement.textContent.trim() === 'Cancel'); + + cancelButton!.nativeElement.click(); + + expect(component.onCancel).toHaveBeenCalled(); + }); + + it('should call onAccept when Request button is clicked', () => { + spyOn(component, 'onAccept'); + + const requestButton = fixture.debugElement + .queryAll(By.css('button')) + .find(btn => btn.nativeElement.textContent.trim() === 'Request'); + + requestButton!.nativeElement.click(); + + expect(component.onAccept).toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/src/app/models/project-component.ts b/src/app/models/project-component.ts index f55b945..23c0400 100644 --- a/src/app/models/project-component.ts +++ b/src/app/models/project-component.ts @@ -6,4 +6,5 @@ export interface ProjectComponent { logo: string | null; url: string; canDelete: boolean; + hasAutomatedDeletionWorkflow: boolean; } \ No newline at end of file diff --git a/src/app/screens/project-components-screen/project-components-screen.component.spec.ts b/src/app/screens/project-components-screen/project-components-screen.component.spec.ts index be64c40..0d84f42 100644 --- a/src/app/screens/project-components-screen/project-components-screen.component.spec.ts +++ b/src/app/screens/project-components-screen/project-components-screen.component.spec.ts @@ -12,6 +12,7 @@ import { AzureService } from '../../services/azure.service'; import { AppShellNotification, AppShellToastService } from '@opendevstack/ngx-appshell'; import { AppUser } from '../../models/app-user'; import { CreateIncidentParameter } from '../../openapi/component-provisioner'; +import { RequestDeletionSimpleDialogComponent } from '../../components/request-deletion-simple-dialog/request-deletion-simple-dialog.component'; describe('ProjectComponentsScreenComponent', () => { let component: ProjectComponentsScreenComponent; @@ -206,20 +207,71 @@ describe('ProjectComponentsScreenComponent', () => { }); }); - it('should show success toast when deletion request succeeds and status is set to deleting', () => { + it('should show success toast when automatic deletion request succeeds and status is set to deleting', () => { component.projectComponents = [{ name: 'test-component', status: 'CREATED', logo: 'http://example.com/logo.png', url: 'http://example.com', - canDelete: true + canDelete: true, + hasAutomatedDeletionWorkflow: true } as ProjectComponent]; const testComponent = { name: 'test-component', status: 'CREATED', logo: 'http://example.com/logo.png', url: 'http://example.com', - canDelete: true + canDelete: true, + hasAutomatedDeletionWorkflow: true + } as ProjectComponent; + component.selectedProject = { projectKey: 'PROJECT_1', location: 'LOC_1' } as AppProject; + const mockResult = { + projectKey: 'PROJECT_1', + componentName: 'test-component' + }; + const dialogSpy = spyOn(component.dialog, 'open').and.returnValue({ + afterClosed: () => of(mockResult) + } as any); + + component.onRequestDeletionClicked(testComponent); + expect(dialogSpy).toHaveBeenCalledWith(RequestDeletionSimpleDialogComponent, { + autoFocus: false, + data: { + componentName: 'test-component', + projectKey: 'PROJECT_1' + } + }); + expect(component.projectComponents[0].status).toBe('DELETING'); + const incidentParams: CreateIncidentParameter[] = []; + expect(provisionerServiceSpy.requestComponentDeletion).toHaveBeenCalledWith( + 'PROJECT_1', + 'test-component', + incidentParams + ); + expect(appShellToastServiceSpy.showToast).toHaveBeenCalledWith({ + id: '', + read: false, + subject: 'only_toast', + title: 'The request has successfully been sent.' + } as AppShellNotification, 8000); + }); + + it('should show success toast when automatic deletion request succeeds and status is set to deleting', () => { + component.projectComponents = [{ + name: 'test-component', + status: 'CREATED', + logo: 'http://example.com/logo.png', + url: 'http://example.com', + canDelete: true, + hasAutomatedDeletionWorkflow: false + } as ProjectComponent]; + const testComponent = { + name: 'test-component', + status: 'CREATED', + logo: 'http://example.com/logo.png', + url: 'http://example.com', + canDelete: true, + hasAutomatedDeletionWorkflow: false } as ProjectComponent; component.selectedProject = { projectKey: 'PROJECT_1', location: 'LOC_1' } as AppProject; const mockResult = { @@ -229,17 +281,24 @@ describe('ProjectComponentsScreenComponent', () => { projectKey: 'PROJECT_1', componentName: 'test-component' }; - spyOn(component.dialog, 'open').and.returnValue({ + const dialogSpy = spyOn(component.dialog, 'open').and.returnValue({ afterClosed: () => of(mockResult) } as any); component.onRequestDeletionClicked(testComponent); + expect(dialogSpy).toHaveBeenCalledWith(RequestDeletionDialogComponent, { + autoFocus: false, + data: { + componentName: 'test-component', + projectKey: 'PROJECT_1' + } + }); expect(component.projectComponents[0].status).toBe('DELETING'); const incidentParams: CreateIncidentParameter[] = [ { name: 'is_deployed', type: 'boolean', value: true as Boolean }, { name: 'change_number', type: 'string', value: 'CHG1234567' as String }, { name: 'reason', type: 'string', value: 'Test reason' as String } - ] + ]; expect(provisionerServiceSpy.requestComponentDeletion).toHaveBeenCalledWith( 'PROJECT_1', 'test-component', diff --git a/src/app/screens/project-components-screen/project-components-screen.component.ts b/src/app/screens/project-components-screen/project-components-screen.component.ts index d79d2fe..1a50e66 100644 --- a/src/app/screens/project-components-screen/project-components-screen.component.ts +++ b/src/app/screens/project-components-screen/project-components-screen.component.ts @@ -14,6 +14,7 @@ import { AzureService } from '../../services/azure.service'; import { AppUser } from '../../models/app-user'; import { CreateIncidentParameter } from '../../openapi/component-provisioner'; import { ComponentStatus } from '../../models/component-status'; +import { RequestDeletionSimpleDialogComponent } from '../../components/request-deletion-simple-dialog/request-deletion-simple-dialog.component'; @Component({ selector: 'app-project-components-screen', @@ -123,22 +124,28 @@ export class ProjectComponentsScreenComponent implements OnInit, OnDestroy { return; } - const dialogRef = this.dialog.open(RequestDeletionDialogComponent, { + const shouldRequestAutomaticDeletion = component.hasAutomatedDeletionWorkflow; + + const dialogRef = this.dialog.open(shouldRequestAutomaticDeletion ? RequestDeletionSimpleDialogComponent : RequestDeletionDialogComponent, { autoFocus: false, data: { componentName: component.name, projectKey: this.selectedProject.projectKey, } }); + // Extended message when automatic deletion is incorrect, since no human intervention is expected + const msg = shouldRequestAutomaticDeletion ? + 'The request has successfully been sent.' : + 'The request has successfully been sent. Support will receive a Service Now ticket and manage the component deletion.'; dialogRef.afterClosed().subscribe((result: RequestDeletionDialogResult | undefined) => { if (result) { - this.submitDeletionRequest(result); + this.submitDeletionRequest(result, msg); } }); } - private submitDeletionRequest(result: RequestDeletionDialogResult): void { + private submitDeletionRequest(result: RequestDeletionDialogResult, notificationMsg: string): void { // Apply optimistic UI and set the current component to deleting status const componentIndex = this.projectComponents.findIndex(c => c.name === result.componentName); let originalStatus: ComponentStatus | undefined = undefined; @@ -164,14 +171,14 @@ export class ProjectComponentsScreenComponent implements OnInit, OnDestroy { type: 'string', value: result.reason as String // NOSONAR } - ]; + ].filter(p => p.value); // Filter by defined values, which are undefined when the simple dialog is called /* eslint-enable @typescript-eslint/no-wrapper-object-types */ this.provisionerService.requestComponentDeletion( result.projectKey, result.componentName, incidentParams ).subscribe({ - next: () => this.onDeletionRequestSuccess(), + next: () => this.onDeletionRequestSuccess(notificationMsg), error: (error) => { this.onDeletionRequestError(error) if (originalStatus && componentIndex !== -1) { @@ -181,12 +188,12 @@ export class ProjectComponentsScreenComponent implements OnInit, OnDestroy { }); } - private onDeletionRequestSuccess(): void { + private onDeletionRequestSuccess(notificationMsg: string): void { this.toastService.showToast({ id: '', read: false, subject: 'only_toast', - title: 'The request has successfully been sent. Support will receive a Service Now ticket and manage the component deletion.' + title: notificationMsg } as AppShellNotification, 8000); } diff --git a/src/app/services/project.service.spec.ts b/src/app/services/project.service.spec.ts index 93e63c4..c387f29 100644 --- a/src/app/services/project.service.spec.ts +++ b/src/app/services/project.service.spec.ts @@ -259,14 +259,16 @@ describe('ProjectService', () => { status: 'Active', logoUrl: 'http://example.com/image.png', componentUrl: 'http://example.com/comp1', - canBeDeleted: true + canBeDeleted: true, + hasAutomatedDeletionWorkflow: true }, { componentId: 'comp2', status: 'Inactive', logoUrl: null, componentUrl: 'http://example.com/comp2', - canBeDeleted: false + canBeDeleted: false, + hasAutomatedDeletionWorkflow: false } ]; @@ -286,14 +288,16 @@ describe('ProjectService', () => { status: 'Active', logo: 'http://example.com/image.png', url: 'http://example.com/comp1', - canDelete: true + canDelete: true, + hasAutomatedDeletionWorkflow: true }, { name: 'comp2', status: 'Inactive', logo: null, url: 'http://example.com/comp2', - canDelete: false + canDelete: false, + hasAutomatedDeletionWorkflow: false } ]); })); @@ -305,14 +309,16 @@ describe('ProjectService', () => { status: null, logoUrl: undefined, componentUrl: '', - canBeDeleted: undefined + canBeDeleted: undefined, + hasAutomatedDeletionWorkflow: undefined }, { componentId: '', status: '', logoUrl: null, componentUrl: null, - canBeDeleted: false + canBeDeleted: false, + hasAutomatedDeletionWorkflow: false }, { // All properties missing @@ -335,21 +341,24 @@ describe('ProjectService', () => { status: 'UNKNOWN', logo: null, url: '', - canDelete: false + canDelete: false, + hasAutomatedDeletionWorkflow: false }, { name: '', status: 'UNKNOWN', logo: null, url: '', - canDelete: false + canDelete: false, + hasAutomatedDeletionWorkflow: false }, { name: '', status: 'UNKNOWN', logo: null, url: '', - canDelete: false + canDelete: false, + hasAutomatedDeletionWorkflow: false } ]); })); @@ -361,14 +370,16 @@ describe('ProjectService', () => { status: 'Active', logoUrl: 'image-id-1', componentUrl: 'http://example.com/comp1', - canBeDeleted: true + canBeDeleted: true, + hasAutomatedDeletionWorkflow: true }, { componentId: 'comp2', status: 'Inactive', logoUrl: 'image-id-2', componentUrl: 'http://example.com/comp2', - canBeDeleted: false + canBeDeleted: false, + hasAutomatedDeletionWorkflow: false } ]; @@ -390,14 +401,16 @@ describe('ProjectService', () => { status: 'Active', logo: null, url: 'http://example.com/comp1', - canDelete: true + canDelete: true, + hasAutomatedDeletionWorkflow: true }, { name: 'comp2', status: 'Inactive', logo: null, url: 'http://example.com/comp2', - canDelete: false + canDelete: false, + hasAutomatedDeletionWorkflow: false } ]); })); diff --git a/src/app/services/project.service.ts b/src/app/services/project.service.ts index 714bdb7..761206e 100644 --- a/src/app/services/project.service.ts +++ b/src/app/services/project.service.ts @@ -103,7 +103,8 @@ export class ProjectService { status: (component.status as ComponentStatus) || 'UNKNOWN', logo: component.logoUrl ? (await this.catalogService.getProductImage(component.logoUrl)) ?? null : null, url: component.componentUrl || '', - canDelete: component.canBeDeleted || false + canDelete: component.canBeDeleted || false, + hasAutomatedDeletionWorkflow: component.hasAutomatedDeletionWorkflow || false })))) ) );