feat: added delete project option and generic confirm dialog

This commit is contained in:
Marcel Haag 2022-01-29 02:15:32 +01:00
parent c9987b48b9
commit c54303b50a
21 changed files with 283 additions and 21 deletions

View File

@ -27,6 +27,7 @@ import {KeycloakService} from 'keycloak-angular';
import {httpInterceptorProviders} from '@shared/interceptors'; import {httpInterceptorProviders} from '@shared/interceptors';
import {FlexLayoutModule} from '@angular/flex-layout'; import {FlexLayoutModule} from '@angular/flex-layout';
import {DialogService} from '@shared/services/dialog-service/dialog.service'; import {DialogService} from '@shared/services/dialog-service/dialog.service';
import {ConfirmDialogModule} from '@shared/modules/confirm-dialog/confirm-dialog.module';
@NgModule({ @NgModule({
declarations: [ declarations: [
@ -45,6 +46,7 @@ import {DialogService} from '@shared/services/dialog-service/dialog.service';
NbIconModule, NbIconModule,
NbButtonModule, NbButtonModule,
NbEvaIconsModule, NbEvaIconsModule,
ConfirmDialogModule,
NgxsModule.forRoot([SessionState], {developmentMode: !environment.production}), NgxsModule.forRoot([SessionState], {developmentMode: !environment.production}),
HttpClientModule, HttpClientModule,
TranslateModule.forRoot({ TranslateModule.forRoot({

View File

@ -1,6 +1,6 @@
<div fxLayout="row" fxLayoutGap="2rem"> <div fxLayout="row" fxLayoutGap="2rem">
<div *ngFor="let project of projects | async"> <div *ngFor="let project of projects | async">
<nb-card accent="success" class="project-card"> <nb-card class="project-card" accent="success">
<nb-card-header fxLayoutAlign="start center" <nb-card-header fxLayoutAlign="start center"
routerLink="id" routerLink="id"
fragment="{{project.id}}" fragment="{{project.id}}"
@ -52,7 +52,7 @@
status="danger" status="danger"
size="small" size="small"
class="project-button" class="project-button"
(click)="onClickDeleteProject()"> (click)="onClickDeleteProject(project)">
<fa-icon [icon]="fa.faTrash"></fa-icon> <fa-icon [icon]="fa.faTrash"></fa-icon>
</button> </button>
</div> </div>

View File

@ -1,3 +1,5 @@
@import '../../assets/@theme/styles/themes';
.project-card { .project-card {
max-width: 22rem; max-width: 22rem;
width: 22rem; width: 22rem;
@ -33,6 +35,12 @@
} }
} }
.project-card:hover {
background-color: nb-theme(color-info-transparent-default);
margin-top: +0.625rem;
transform: scale(1.025)
}
.project-link:hover { .project-link:hover {
cursor: pointer !important; cursor: pointer !important;
} }

View File

@ -5,10 +5,9 @@ import {BehaviorSubject, Observable} from 'rxjs';
import {untilDestroyed} from 'ngx-take-until-destroy'; import {untilDestroyed} from 'ngx-take-until-destroy';
import {ProjectService} from '@shared/services/project.service'; import {ProjectService} from '@shared/services/project.service';
import {NotificationService, PopupType} from '@shared/services/notification.service'; import {NotificationService, PopupType} from '@shared/services/notification.service';
import {filter, mergeMap, tap} from 'rxjs/operators'; import {catchError, filter, mergeMap, switchMap, tap} from 'rxjs/operators';
import {DialogService} from '@shared/services/dialog-service/dialog.service'; import {DialogService} from '@shared/services/dialog-service/dialog.service';
import {ProjectDialogComponent} from '@shared/modules/project-dialog/project-dialog.component'; import {ProjectDialogComponent} from '@shared/modules/project-dialog/project-dialog.component';
import {NB_DIALOG_CONFIG} from '@nebular/theme/components/dialog/dialog-config';
@Component({ @Component({
selector: 'app-project-overview', selector: 'app-project-overview',
@ -80,8 +79,30 @@ export class ProjectOverviewComponent implements OnInit, OnDestroy {
console.log('to be implemented...'); console.log('to be implemented...');
} }
onClickDeleteProject(): void { onClickDeleteProject(project: Project): void {
console.log('to be implemented...'); const message = {
title: 'project.delete.title',
key: 'project.delete.key',
data: {name: project.title},
};
this.dialogService.openConfirmDialog(
message
).onClose.pipe(
filter((confirm) => !!confirm),
switchMap(() => this.projectService.deleteProjectById(project.id)),
catchError(() => {
this.notificationService.showPopup('project.popup.delete.failed', PopupType.FAILURE);
return [];
}),
untilDestroyed(this)
).subscribe({
next: () => {
this.loadProjects();
this.notificationService.showPopup('project.popup.delete.success', PopupType.SUCCESS);
}, error: error => {
console.error(error);
}
});
} }
isLoading(): Observable<boolean> { isLoading(): Observable<boolean> {

View File

@ -0,0 +1,18 @@
.dialog-header {
height: 6.75vh;
font-size: 1.5rem;
.dialog-headline {
margin-top: 2rem;
}
}
.dialog-body {
font-size: 1.15rem;
}
.dialog-button {
width: 4.5rem;
height: 2.5rem;
font-size: 1.5rem;
}

View File

@ -1,9 +1,3 @@
/**
* @license
* Copyright Akveo. All Rights Reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*/
@mixin ngx-pace-theme() { @mixin ngx-pace-theme() {
.pace .pace-progress { .pace .pace-progress {

View File

@ -3,7 +3,10 @@
"action.login": "Einloggen", "action.login": "Einloggen",
"action.retry": "Erneut Versuchen", "action.retry": "Erneut Versuchen",
"action.save": "Speichern", "action.save": "Speichern",
"action.confirm": "Bestätigen",
"action.cancel": "Abbrechen", "action.cancel": "Abbrechen",
"action.yes": "Ja",
"action.no": "Nein",
"username": "Nutzername", "username": "Nutzername",
"password": "Passwort" "password": "Passwort"
}, },
@ -33,10 +36,16 @@
"create": { "create": {
"header": "Neues Projekt erstellen" "header": "Neues Projekt erstellen"
}, },
"delete": {
"title": "Projekt löschen",
"key": "Möchten Sie das Projekt \"{{name}}\" unwiderruflich löschen?"
},
"popup": { "popup": {
"not.found": "Keine Projekte gefunden", "not.found": "Keine Projekte gefunden",
"save.success": "Projekt erfolgreich gespeichert", "save.success": "Projekt erfolgreich gespeichert",
"save.failed": "Projekt konnte nicht gespeichert werden" "save.failed": "Projekt konnte nicht gespeichert werden",
"delete.success": "Projekt erfolgreich gelöscht",
"delete.failed": "Projekt konnte nicht gelöscht werden"
}, },
"title.label": "Projekt Titel", "title.label": "Projekt Titel",
"client.label": "Name des Auftraggebers", "client.label": "Name des Auftraggebers",

View File

@ -2,8 +2,11 @@
"global": { "global": {
"action.login": "Login", "action.login": "Login",
"action.retry": "Try again", "action.retry": "Try again",
"action.confirm": "Confirm",
"action.save": "Save", "action.save": "Save",
"action.cancel": "Cancel", "action.cancel": "Cancel",
"action.yes": "Yes",
"action.no": "No",
"username": "Username", "username": "Username",
"password": "Password" "password": "Password"
}, },
@ -33,10 +36,16 @@
"create": { "create": {
"header": "Create New Project" "header": "Create New Project"
}, },
"delete": {
"title": "Delete Project",
"key": "Do you want to permanently delete the project \"{{name}}\"?"
},
"popup": { "popup": {
"not.found": "No projects found", "not.found": "No projects found",
"save.success": "Project saved successfully", "save.success": "Project saved successfully",
"save.failed": "Project could not be saved" "save.failed": "Project could not be saved",
"delete.success": "Project deleted successfully",
"delete.failed": "Project could not be deleted"
}, },
"title.label": "Project Title", "title.label": "Project Title",
"client.label": "Name of Client", "client.label": "Name of Client",

View File

@ -0,0 +1,21 @@
<nb-card>
<nb-card-header fxLayoutAlign="start center" class="dialog-header confirm">
{{ data?.title | translate }}
</nb-card-header>
<nb-card-body class="dialog-body">
{{ data?.key | translate: data?.data }}
</nb-card-body>
<nb-card-footer fxLayout="row" fxLayoutGap="1.5rem" fxLayoutAlign="end end">
<button nbButton size="small"
class="dialog-button"
status="danger"
(click)="onClickConfirm()">
{{ 'global.action.yes' | translate }}
</button>
<button nbButton size="small"
class="dialog-button"
(click)="onClickClose()">
{{ 'global.action.no' | translate }}
</button>
</nb-card-footer>
</nb-card>

View File

@ -0,0 +1 @@
@import "../../../assets/@theme/styles/_dialog.scss";

View File

@ -0,0 +1,56 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ConfirmDialogComponent } from './confirm-dialog.component';
import {DialogService} from '@shared/services/dialog-service/dialog.service';
import {DialogServiceMock} from '@shared/services/dialog-service/dialog.service.mock';
import {NbButtonModule, NbCardModule, NbDialogRef, NbLayoutModule} from '@nebular/theme';
import {CommonModule} from '@angular/common';
import {TranslateLoader, TranslateModule} from '@ngx-translate/core';
import {HttpLoaderFactory} from '../../../app/common-app.module';
import {HttpClient, HttpClientModule} from '@angular/common/http';
import {HttpClientTestingModule} from '@angular/common/http/testing';
import {FlexLayoutModule} from '@angular/flex-layout';
describe('ConfirmDialogComponent', () => {
let component: ConfirmDialogComponent;
let fixture: ComponentFixture<ConfirmDialogComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [
ConfirmDialogComponent
],
imports: [
CommonModule,
NbLayoutModule,
NbCardModule,
NbButtonModule,
FlexLayoutModule,
TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useFactory: HttpLoaderFactory,
deps: [HttpClient]
}
}),
HttpClientModule,
HttpClientTestingModule
],
providers: [
{provide: DialogService, useClass: DialogServiceMock},
{provide: NbDialogRef, useValue: {}}
]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(ConfirmDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,28 @@
import {Component, Input} from '@angular/core';
import {NbDialogRef} from '@nebular/theme';
@Component({
selector: 'app-confirm-dialog',
templateUrl: './confirm-dialog.component.html',
styleUrls: ['./confirm-dialog.component.scss']
})
export class ConfirmDialogComponent {
/**
* @param data contains all relevant information the dialog needs
* @param data.title The translation key for the dialog title
* @param data.key The translation key for the shown message
* @param data.data The data that may be used in the message translation key
*/
@Input() data: any;
constructor(protected dialogRef: NbDialogRef<any>) {
}
onClickConfirm(): void {
this.dialogRef.close({confirm: true});
}
onClickClose(): void {
this.dialogRef.close();
}
}

View File

@ -0,0 +1,23 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import {ConfirmDialogComponent} from '@shared/modules/confirm-dialog/confirm-dialog.component';
import {NbButtonModule, NbCardModule} from '@nebular/theme';
import {FlexLayoutModule} from '@angular/flex-layout';
import {TranslateModule} from '@ngx-translate/core';
@NgModule({
declarations: [
ConfirmDialogComponent
],
imports: [
CommonModule,
NbCardModule,
NbButtonModule,
FlexLayoutModule,
TranslateModule
],
entryComponents: [
ConfirmDialogComponent
]
})
export class ConfirmDialogModule { }

View File

@ -1,5 +1,5 @@
<nb-card #dialog accent="primary" class="project-dialog"> <nb-card #dialog accent="primary" class="project-dialog">
<nb-card-header fxLayoutAlign="start center" class="project-dialog-header"> <nb-card-header fxLayoutAlign="start center" class="dialog-header">
{{ 'project.create.header' | translate }} {{ 'project.create.header' | translate }}
</nb-card-header> </nb-card-header>
<nb-card-body> <nb-card-body>
@ -39,10 +39,10 @@
</form> </form>
</nb-card-body> </nb-card-body>
<nb-card-footer fxLayout="row" fxLayoutGap="1.5rem" fxLayoutAlign="end end"> <nb-card-footer fxLayout="row" fxLayoutGap="1.5rem" fxLayoutAlign="end end">
<button nbButton status="success" [disabled]="formIsEmptyOrInvalid()" (click)="onClickSave(projectFormGroup.value)"> <button nbButton status="success" size="small" class="dialog-button" [disabled]="formIsEmptyOrInvalid()" (click)="onClickSave(projectFormGroup.value)">
{{ 'global.action.save' | translate}} {{ 'global.action.save' | translate}}
</button> </button>
<button nbButton status="danger" (click)="onClickClose()"> <button nbButton status="danger" size="small" class="dialog-button"(click)="onClickClose()">
{{ 'global.action.cancel' | translate }} {{ 'global.action.cancel' | translate }}
</button> </button>
</nb-card-footer> </nb-card-footer>

View File

@ -1,3 +1,5 @@
@import "../../../assets/@theme/styles/_dialog.scss";
.project-dialog { .project-dialog {
width: 24rem; width: 24rem;
height: 31rem; height: 31rem;

View File

@ -0,0 +1,6 @@
export interface DialogMessage {
key: string;
data?: any;
title?: string;
inputPlaceholderKey?: string;
}

View File

@ -2,6 +2,7 @@ import {DialogService} from '@shared/services/dialog-service/dialog.service';
import {ComponentType} from '@angular/cdk/overlay'; import {ComponentType} from '@angular/cdk/overlay';
import {TemplateRef} from '@angular/core'; import {TemplateRef} from '@angular/core';
import {NbDialogConfig, NbDialogRef} from '@nebular/theme'; import {NbDialogConfig, NbDialogRef} from '@nebular/theme';
import {DialogMessage} from '@shared/services/dialog-service/dialog-message';
export class DialogServiceMock implements Required<DialogService> { export class DialogServiceMock implements Required<DialogService> {
@ -13,4 +14,8 @@ export class DialogServiceMock implements Required<DialogService> {
): NbDialogRef<T> { ): NbDialogRef<T> {
return null; return null;
} }
openConfirmDialog(message: DialogMessage): NbDialogRef<any> {
return null;
}
} }

View File

@ -1,6 +1,8 @@
import {Injectable, TemplateRef} from '@angular/core'; import {Injectable, TemplateRef} from '@angular/core';
import {NbDialogConfig, NbDialogRef, NbDialogService} from '@nebular/theme'; import {NbDialogConfig, NbDialogRef, NbDialogService} from '@nebular/theme';
import {ComponentType} from '@angular/cdk/overlay'; import {ComponentType} from '@angular/cdk/overlay';
import {DialogMessage} from '@shared/services/dialog-service/dialog-message';
import {ConfirmDialogComponent} from '@shared/modules/confirm-dialog/confirm-dialog.component';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@ -25,4 +27,19 @@ export class DialogService {
closeOnBackdropClick: config?.closeOnBackdropClick || false, closeOnBackdropClick: config?.closeOnBackdropClick || false,
}); });
} }
/**
* @param message.key The translation key for the shown message
* @param message.data The data that may be used in the message translation key (Set it null if it's not required in the key)
* @param message.title The translation key for the dialog title
*/
openConfirmDialog(message: DialogMessage): NbDialogRef<ConfirmDialogComponent> {
return this.dialog.open(ConfirmDialogComponent, {
closeOnEsc: false,
hasScroll: false,
autoFocus: false,
closeOnBackdropClick: false,
context: {data: message}
});
}
} }

View File

@ -1,7 +1,7 @@
import {ProjectService} from '@shared/services/project.service'; import {ProjectService} from '@shared/services/project.service';
import {HttpClient} from '@angular/common/http'; import {HttpClient} from '@angular/common/http';
import {Observable, of} from 'rxjs'; import {Observable, of} from 'rxjs';
import {Project} from '@shared/models/project.model'; import {Project, SaveProjectDialogBody} from '@shared/models/project.model';
export class ProjectServiceMock implements Required<ProjectService> { export class ProjectServiceMock implements Required<ProjectService> {
@ -12,7 +12,11 @@ export class ProjectServiceMock implements Required<ProjectService> {
return of([]); return of([]);
} }
saveProject(): Observable<Project> { saveProject(saveProject: SaveProjectDialogBody): Observable<Project> {
return of();
}
deleteProjectById(projectId: string): Observable<string> {
return of(); return of();
} }
} }

View File

@ -33,6 +33,7 @@ describe('ProjectService', () => {
}); });
describe('getProjects', () => { describe('getProjects', () => {
// arrange
const mockProject: Project = { const mockProject: Project = {
id: '56c47c56-3bcd-45f1-a05b-c197dbd33111', id: '56c47c56-3bcd-45f1-a05b-c197dbd33111',
client: 'E Corp', client: 'E Corp',
@ -52,6 +53,7 @@ describe('ProjectService', () => {
}]; }];
it('should get Projects', (done) => { it('should get Projects', (done) => {
// act
service.getProjects().subscribe((projects) => { service.getProjects().subscribe((projects) => {
expect(projects[0].id).toEqual(mockProject.id); expect(projects[0].id).toEqual(mockProject.id);
expect(projects[0].client).toEqual(mockProject.client); expect(projects[0].client).toEqual(mockProject.client);
@ -62,6 +64,7 @@ describe('ProjectService', () => {
done(); done();
}); });
// assert
const mockReq = httpMock.expectOne(`${apiBaseURL}`); const mockReq = httpMock.expectOne(`${apiBaseURL}`);
expect(mockReq.cancelled).toBe(false); expect(mockReq.cancelled).toBe(false);
expect(mockReq.request.responseType).toEqual('json'); expect(mockReq.request.responseType).toEqual('json');
@ -72,6 +75,7 @@ describe('ProjectService', () => {
}); });
describe('saveProject', () => { describe('saveProject', () => {
// arrange
const mockSaveProjectDialogBody: SaveProjectDialogBody = { const mockSaveProjectDialogBody: SaveProjectDialogBody = {
client: 'E Corp', client: 'E Corp',
title: 'Some Mock API (v1.0) Scanning', title: 'Some Mock API (v1.0) Scanning',
@ -97,17 +101,40 @@ describe('ProjectService', () => {
}; };
it('should save project', (done) => { it('should save project', (done) => {
// act
service.saveProject(mockSaveProjectDialogBody).subscribe( service.saveProject(mockSaveProjectDialogBody).subscribe(
value => { value => {
expect(value).toEqual(mockProject); expect(value).toEqual(mockProject);
done(); done();
}, },
fail); fail);
// assert
const req = httpMock.expectOne(`${apiBaseURL}`); const req = httpMock.expectOne(`${apiBaseURL}`);
expect(req.request.method).toBe('POST'); expect(req.request.method).toBe('POST');
req.flush(mockProject); req.flush(mockProject);
}); });
}); });
describe('deleteProject', () => {
// arrange
const mockProjectId = '56c47c56-3bcd-45f1-a05b-c197dbd33111';
const httpResponse = {
id: '56c47c56-3bcd-45f1-a05b-c197dbd33111'
};
it('should delete project', (done) => {
// act
service.deleteProjectById(mockProjectId).subscribe(
value => {
expect(value).toEqual(httpResponse.id);
done();
},
fail);
// assert
const req = httpMock.expectOne(`${apiBaseURL}/${mockProjectId}`);
expect(req.request.method).toBe('DELETE');
req.flush(httpResponse.id);
});
});
}); });

View File

@ -14,6 +14,9 @@ export class ProjectService {
constructor(private http: HttpClient) { constructor(private http: HttpClient) {
} }
/**
* Get Projects
*/
public getProjects(): Observable<Project[]> { public getProjects(): Observable<Project[]> {
return this.http.get<Project[]>(`${this.apiBaseURL}`); return this.http.get<Project[]>(`${this.apiBaseURL}`);
} }
@ -25,4 +28,12 @@ export class ProjectService {
public saveProject(project: SaveProjectDialogBody): Observable<Project> { public saveProject(project: SaveProjectDialogBody): Observable<Project> {
return this.http.post<Project>(`${this.apiBaseURL}`, project); return this.http.post<Project>(`${this.apiBaseURL}`, project);
} }
/**
* Delete Project
* @param projectId the id of the project
*/
public deleteProjectById(projectId: string): Observable<string> {
return this.http.delete<string>(`${this.apiBaseURL}/${projectId}`);
}
} }