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 @@
+
+
+
+
+
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
}))))
)
);