feat: added update project option and refactored project-dialog

This commit is contained in:
mhg 2022-03-04 11:06:41 +01:00 committed by GitHub
parent 203b376ef1
commit 282d66efa6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 475 additions and 137 deletions

View File

@ -45,7 +45,7 @@
status="primary" status="primary"
size="small" size="small"
class="project-button" class="project-button"
(click)="onClickEditProject()"> (click)="onClickEditProject(project)">
<fa-icon [icon]="fa.faPencilAlt"></fa-icon> <fa-icon [icon]="fa.faPencilAlt"></fa-icon>
</button> </button>
<button nbButton <button nbButton
@ -61,7 +61,7 @@
</div> </div>
</div> </div>
<div *ngIf="projects.getValue().length === 0 && !isLoading()" fxLayout="row" fxLayoutAlign="center center"> <div *ngIf="projects.getValue().length === 0 || !isLoading()" fxLayout="row" fxLayoutAlign="center center">
<p class="error-text"> <p class="error-text">
{{'project.overview.no.projects' | translate}} {{'project.overview.no.projects' | translate}}
</p> </p>

View File

@ -36,9 +36,13 @@
} }
.project-card:hover { .project-card:hover {
background-color: nb-theme(color-info-transparent-default); background-color: nb-theme(color-basic-transparent-focus);
// Increases element size on hover
// Decreases usability which is why it is commented out
/*
margin-top: +0.625rem; margin-top: +0.625rem;
transform: scale(1.025) transform: scale(1.025);
*/
} }
.project-link:hover { .project-link:hover {

View File

@ -24,6 +24,8 @@ import {LoadingSpinnerComponent} from '@shared/widgets/loading-spinner/loading-s
import {KeycloakService} from 'keycloak-angular'; import {KeycloakService} from 'keycloak-angular';
import {DialogService} from '@shared/services/dialog-service/dialog.service'; import {DialogService} from '@shared/services/dialog-service/dialog.service';
import {DialogServiceMock} from '@shared/services/dialog-service/dialog.service.mock'; import {DialogServiceMock} from '@shared/services/dialog-service/dialog.service.mock';
import {ProjectDialogService} from '@shared/modules/project-dialog/service/project-dialog.service';
import {ProjectDialogServiceMock} from '@shared/modules/project-dialog/service/project-dialog.service.mock';
describe('ProjectOverviewComponent', () => { describe('ProjectOverviewComponent', () => {
let component: ProjectOverviewComponent; let component: ProjectOverviewComponent;
@ -63,6 +65,7 @@ describe('ProjectOverviewComponent', () => {
providers: [ providers: [
KeycloakService, KeycloakService,
{provide: ProjectService, useValue: new ProjectServiceMock()}, {provide: ProjectService, useValue: new ProjectServiceMock()},
{provide: ProjectDialogService, useClass: ProjectDialogServiceMock},
{provide: DialogService, useClass: DialogServiceMock}, {provide: DialogService, useClass: DialogServiceMock},
{provide: NotificationService, useValue: new NotificationServiceMock()} {provide: NotificationService, useValue: new NotificationServiceMock()}
] ]

View File

@ -1,6 +1,6 @@
import {Component, OnDestroy, OnInit} from '@angular/core'; import {Component, OnDestroy, OnInit} from '@angular/core';
import * as FA from '@fortawesome/free-solid-svg-icons'; import * as FA from '@fortawesome/free-solid-svg-icons';
import {Project, SaveProjectDialogBody} from '@shared/models/project.model'; import {Project, ProjectDialogBody} from '@shared/models/project.model';
import {BehaviorSubject, Observable} from 'rxjs'; 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';
@ -8,6 +8,7 @@ import {NotificationService, PopupType} from '@shared/services/notification.serv
import {catchError, filter, mergeMap, switchMap, 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 {ProjectDialogService} from '@shared/modules/project-dialog/service/project-dialog.service';
@Component({ @Component({
selector: 'app-project-overview', selector: 'app-project-overview',
@ -24,6 +25,7 @@ export class ProjectOverviewComponent implements OnInit, OnDestroy {
constructor( constructor(
private readonly projectService: ProjectService, private readonly projectService: ProjectService,
private readonly dialogService: DialogService, private readonly dialogService: DialogService,
private readonly projectDialogService: ProjectDialogService,
private readonly notificationService: NotificationService) { private readonly notificationService: NotificationService) {
} }
@ -51,17 +53,18 @@ export class ProjectOverviewComponent implements OnInit, OnDestroy {
} }
onClickAddProject(): void { onClickAddProject(): void {
this.dialogService.openCustomDialog( this.projectDialogService.openProjectDialog(
ProjectDialogComponent, ProjectDialogComponent,
null,
{ {
closeOnEsc: false, closeOnEsc: false,
hasScroll: false, hasScroll: false,
autoFocus: false, autoFocus: false,
closeOnBackdropClick: false closeOnBackdropClick: false
} }
).onClose.pipe( ).pipe(
filter(value => !!value), filter(value => !!value),
mergeMap((value: SaveProjectDialogBody) => this.projectService.saveProject(value)), mergeMap((value: ProjectDialogBody) => this.projectService.saveProject(value)),
untilDestroyed(this) untilDestroyed(this)
).subscribe({ ).subscribe({
next: () => { next: () => {
@ -75,8 +78,30 @@ export class ProjectOverviewComponent implements OnInit, OnDestroy {
}); });
} }
onClickEditProject(): void { onClickEditProject(project: Project): void {
console.log('to be implemented...'); this.projectDialogService.openProjectDialog(
ProjectDialogComponent,
project,
{
closeOnEsc: false,
hasScroll: false,
autoFocus: false,
closeOnBackdropClick: false
}
).pipe(
filter(value => !!value),
mergeMap((value: ProjectDialogBody) => this.projectService.updateProject(project.id, value)),
untilDestroyed(this)
).subscribe({
next: () => {
this.loadProjects();
this.notificationService.showPopup('project.popup.update.success', PopupType.SUCCESS);
},
error: error => {
console.error(error);
this.notificationService.showPopup('project.popup.update.failed', PopupType.FAILURE);
}
});
} }
onClickDeleteProject(project: Project): void { onClickDeleteProject(project: Project): void {

View File

@ -3,6 +3,7 @@
"action.login": "Einloggen", "action.login": "Einloggen",
"action.retry": "Erneut Versuchen", "action.retry": "Erneut Versuchen",
"action.save": "Speichern", "action.save": "Speichern",
"action.update": "Aktualisieren",
"action.confirm": "Bestätigen", "action.confirm": "Bestätigen",
"action.cancel": "Abbrechen", "action.cancel": "Abbrechen",
"action.yes": "Ja", "action.yes": "Ja",
@ -14,7 +15,7 @@
"success": "✔", "success": "✔",
"failure": "✘", "failure": "✘",
"warning": "!", "warning": "!",
"info": "", "info": "",
"error.position": { "error.position": {
"permissionDenied": "Berechtigung verweigert", "permissionDenied": "Berechtigung verweigert",
"timeout": "Zeitüberschreitung" "timeout": "Zeitüberschreitung"
@ -29,6 +30,13 @@
"unauthorized": "Benutzer nicht gefunden. Bitte registrieren und erneut versuchen" "unauthorized": "Benutzer nicht gefunden. Bitte registrieren und erneut versuchen"
}, },
"project": { "project": {
"title.label": "Projekt Titel",
"client.label": "Name des Auftraggebers",
"tester.label": "Name des Pentester",
"title": "Titel",
"client": "Klient",
"tester": "Tester",
"createdAt": "Erstellt am",
"overview": { "overview": {
"add.project": "Projekt hinzufügen", "add.project": "Projekt hinzufügen",
"no.projects": "Keine Projekte verfügbar" "no.projects": "Keine Projekte verfügbar"
@ -36,23 +44,26 @@
"create": { "create": {
"header": "Neues Projekt erstellen" "header": "Neues Projekt erstellen"
}, },
"edit": {
"header": "Projekt editieren"
},
"delete": { "delete": {
"title": "Projekt löschen", "title": "Projekt löschen",
"key": "Möchten Sie das Projekt \"{{name}}\" unwiderruflich löschen?" "key": "Möchten Sie das Projekt \"{{name}}\" unwiderruflich löschen?"
}, },
"validationMessage": {
"titleRequired": "Titel ist erforderlich.",
"clientRequired": "Klient ist erforderlich.",
"testerRequired": "Tester ist erforderlich."
},
"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",
"update.success": "Projekt erfolgreich aktualisiert",
"update.failed": "Projekt konnte nicht aktualisiert werden",
"delete.success": "Projekt erfolgreich gelöscht", "delete.success": "Projekt erfolgreich gelöscht",
"delete.failed": "Projekt konnte nicht gelöscht werden" "delete.failed": "Projekt konnte nicht gelöscht werden"
}, }
"title.label": "Projekt Titel",
"client.label": "Name des Auftraggebers",
"tester.label": "Name des Pentester",
"title": "Titel",
"client": "Klient",
"tester": "Tester",
"createdAt": "Erstellt am"
} }
} }

View File

@ -4,6 +4,7 @@
"action.retry": "Try again", "action.retry": "Try again",
"action.confirm": "Confirm", "action.confirm": "Confirm",
"action.save": "Save", "action.save": "Save",
"action.update": "Update",
"action.cancel": "Cancel", "action.cancel": "Cancel",
"action.yes": "Yes", "action.yes": "Yes",
"action.no": "No", "action.no": "No",
@ -14,7 +15,7 @@
"success": "✔", "success": "✔",
"failure": "✘", "failure": "✘",
"warning": "!", "warning": "!",
"info": "", "info": "",
"error.position": { "error.position": {
"permissionDenied": "Permission denied", "permissionDenied": "Permission denied",
"timeout": "Timeout" "timeout": "Timeout"
@ -29,30 +30,40 @@
"unauthorized": "User not found. Please register and try again" "unauthorized": "User not found. Please register and try again"
}, },
"project": { "project": {
"overview": {
"add.project": "Add project",
"no.projects": "No projects available"
},
"create": {
"header": "Create New Project"
},
"delete": {
"title": "Delete Project",
"key": "Do you want to permanently delete the project \"{{name}}\"?"
},
"popup": {
"not.found": "No projects found",
"save.success": "Project saved successfully",
"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",
"tester.label": "Name of Pentester", "tester.label": "Name of Pentester",
"title": "Title", "title": "Title",
"client": "Client", "client": "Client",
"tester": "Tester", "tester": "Tester",
"createdAt": "Created at" "createdAt": "Created at",
"overview": {
"add.project": "Add Project",
"no.projects": "No projects available"
},
"create": {
"header": "Create New Project"
},
"edit": {
"header": "Edit Project"
},
"delete": {
"title": "Delete Project",
"key": "Do you want to permanently delete the project \"{{name}}\"?"
},
"validationMessage": {
"titleRequired": "Title is required.",
"clientRequired": "Client is required.",
"testerRequired": "Tester is required."
},
"popup": {
"not.found": "No projects found",
"save.success": "Project saved successfully",
"save.failed": "Project could not be saved",
"update.success": "Project updated successfully",
"update.failed": "Project could not be updated",
"delete.success": "Project deleted successfully",
"delete.failed": "Project could not be deleted"
}
} }
} }

View File

@ -2,10 +2,10 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>SecurityC4POAngular</title> <title>Security C4PO</title>
<base href="/"> <base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="assets/images/favicons/favicon.ico"> <link rel="icon" type="image/x-icon" href="assets/images/favicons/corporate_favicon.ico">
</head> </head>
<body id="loader-wrapper"> <body id="loader-wrapper">
<app-root id="loader"></app-root> <app-root id="loader"></app-root>

View File

@ -1,4 +1,4 @@
export const GlobalTitlesVariables = { export const GlobalTitlesVariables = {
SECURITYC4PO_TITLE: 'SecurityC4PO', SECURITYC4PO_TITLE: 'Security C4PO',
NOVATEC_NAME: 'Novatec' NOVATEC_NAME: 'Novatec'
}; };

View File

@ -0,0 +1,26 @@
export interface ProjectDialogData {
form: {
[key: string]: GenericFormFieldConfig // key is property name, e.g. title
};
options?: GenericFormFieldOption[];
}
export interface GenericFormFieldConfig {
fieldName: string;
type: string; // text, password, email
labelKey: string; // translation key of field label
placeholder: string; // translation key of placeholder text
controlsConfig: { [key: string]: any };
errors: GenericFormFieldError[];
}
export interface GenericFormFieldError {
errorCode: string;
translationKey: string;
}
export interface GenericFormFieldOption {
headerLabelKey: string;
buttonKey: string;
accentColor: string;
}

View File

@ -21,7 +21,7 @@ export class Project {
} }
} }
export interface SaveProjectDialogBody { export interface ProjectDialogBody {
title: string; title: string;
client: string; client: string;
tester: string; tester: string;

View File

@ -1,48 +1,43 @@
<nb-card #dialog accent="primary" class="project-dialog"> <nb-card #dialog accent="{{dialogData?.options[0].accentColor}}" class="project-dialog">
<nb-card-header fxLayoutAlign="start center" class="dialog-header"> <nb-card-header fxLayoutAlign="start center" class="dialog-header">
{{ 'project.create.header' | translate }} {{ dialogData?.options[0].headerLabelKey | translate }}
</nb-card-header> </nb-card-header>
<nb-card-body> <nb-card-body>
<form [formGroup]="projectFormGroup" fxLayout="column" fxLayoutGap="1rem" fxLayoutAlign="start start"> <form *ngIf="formArray" [formGroup]="projectFormGroup" fxLayout="column" fxLayoutGap="1rem"
<nb-form-field class="project-form-field"> fxLayoutAlign="start start">
<label for="projectTitleInput" class="label"> <ng-template ngFor let-fieldConfig [ngForOf]="formArray">
{{'project.title.label' | translate}}: <!-- TYPE select -->
</label> <ng-container [ngSwitch]="fieldConfig.type">
<input formControlName="projectTitle" required <!-- Default styles -->
id="projectTitleInput" nbInput <nb-form-field *ngSwitchCase="'text'" class="project-form-field">
class="input" type="text" fullWidth <label for="{{fieldConfig.fieldName}}" class="label">
status="{{formCtrlStatus}}" {{fieldConfig.labelKey | translate}}
placeholder="{{'project.title' | translate}} *"> </label>
</nb-form-field> <input formControlName="{{fieldConfig.fieldName}}"
type="text" required fullWidth
<nb-form-field class="project-form-field"> id="{{fieldConfig.fieldName}}" nbInput
<label for="projectClientInput" class="label"> class="input"
{{'project.client.label' | translate}}: [status]="projectFormGroup.get(fieldConfig.fieldName).dirty ? (projectFormGroup.get(fieldConfig.fieldName).invalid ? 'danger' : 'basic') : 'basic'"
</label> placeholder="{{fieldConfig.placeholder | translate}} *">
<input formControlName="projectClient" required <!-- FIXME: when the bug (https://github.com/angular/components/issues/7739) is fixed -->
id="projectClientInput" nbInput <ng-template ngFor let-error [ngForOf]="fieldConfig.errors"
class="input" type="text" fullWidth *ngIf="projectFormGroup.get(fieldConfig.fieldName).dirty">
status="{{formCtrlStatus}}" <span class="error-text"
placeholder="{{'project.client' | translate}} *"> *ngIf="projectFormGroup.get(fieldConfig.fieldName)?.hasError(error.errorCode) && error.errorCode === 'required'">
</nb-form-field> {{error.translationKey | translate}}
</span>
<nb-form-field class="project-form-field"> </ng-template>
<label for="projectTesterInput" class="label"> </nb-form-field>
{{'project.tester.label' | translate}}: </ng-container>
</label> </ng-template>
<input formControlName="projectTester" required
id="projectTesterInput" nbInput
class="input" type="text" fullWidth
status="{{formCtrlStatus}}"
placeholder="{{'project.tester' | translate}} *">
</nb-form-field>
</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" size="small" class="dialog-button" [disabled]="formIsEmptyOrInvalid()" (click)="onClickSave(projectFormGroup.value)"> <button nbButton status="success" size="small" class="dialog-button" [disabled]="!allowSave()"
{{ 'global.action.save' | translate}} (click)="onClickSave(projectFormGroup.value)">
{{ dialogData?.options[0].buttonKey | translate}}
</button> </button>
<button nbButton status="danger" size="small" class="dialog-button"(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,8 +1,9 @@
@import "../../../assets/@theme/styles/_dialog.scss"; @import "../../../assets/@theme/styles/_dialog.scss";
@import '../../../assets/@theme/styles/themes';
.project-dialog { .project-dialog {
width: 24rem; width: 25.25rem;
height: 31rem; height: 35rem;
.project-dialog-header { .project-dialog-header {
height: 8vh; height: 8vh;
@ -20,6 +21,12 @@
} }
.input { .input {
width: 15rem; width: 18rem;
margin-bottom: 0.5rem;
}
.error-text {
float: left;
color: nb-theme(color-danger-default);;
} }
} }

View File

@ -1,8 +1,15 @@
import {ComponentFixture, TestBed} from '@angular/core/testing'; import {ComponentFixture, TestBed} from '@angular/core/testing';
import {ProjectDialogComponent} from './project-dialog.component'; import {ProjectDialogComponent} from './project-dialog.component';
import {CommonModule} from '@angular/common'; import {CommonModule} from '@angular/common';
import {NbButtonModule, NbCardModule, NbDialogRef, NbFormFieldModule, NbInputModule, NbLayoutModule} from '@nebular/theme'; import {
NB_DIALOG_CONFIG,
NbButtonModule,
NbCardModule,
NbDialogRef,
NbFormFieldModule,
NbInputModule,
NbLayoutModule
} from '@nebular/theme';
import {FlexLayoutModule} from '@angular/flex-layout'; import {FlexLayoutModule} from '@angular/flex-layout';
import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
import {ThemeModule} from '@assets/@theme/theme.module'; import {ThemeModule} from '@assets/@theme/theme.module';
@ -14,13 +21,18 @@ import {NotificationService} from '@shared/services/notification.service';
import {NotificationServiceMock} from '@shared/services/notification.service.mock'; import {NotificationServiceMock} from '@shared/services/notification.service.mock';
import {DialogService} from '@shared/services/dialog-service/dialog.service'; import {DialogService} from '@shared/services/dialog-service/dialog.service';
import {DialogServiceMock} from '@shared/services/dialog-service/dialog.service.mock'; import {DialogServiceMock} from '@shared/services/dialog-service/dialog.service.mock';
import {ReactiveFormsModule} from '@angular/forms'; import {ReactiveFormsModule, Validators} from '@angular/forms';
import {Project} from '@shared/models/project.model';
import Mock = jest.Mock;
import deepEqual from 'deep-equal';
describe('ProjectDialogComponent', () => { describe('ProjectDialogComponent', () => {
let component: ProjectDialogComponent; let component: ProjectDialogComponent;
let fixture: ComponentFixture<ProjectDialogComponent>; let fixture: ComponentFixture<ProjectDialogComponent>;
beforeEach(async () => { beforeEach(async () => {
const dialogSpy = createSpyObj('NbDialogRef', ['close']);
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
declarations: [ declarations: [
ProjectDialogComponent ProjectDialogComponent
@ -49,12 +61,14 @@ describe('ProjectDialogComponent', () => {
providers: [ providers: [
{provide: NotificationService, useValue: new NotificationServiceMock()}, {provide: NotificationService, useValue: new NotificationServiceMock()},
{provide: DialogService, useClass: DialogServiceMock}, {provide: DialogService, useClass: DialogServiceMock},
{provide: NbDialogRef, useValue: {}} {provide: NbDialogRef, useValue: dialogSpy},
{provide: NB_DIALOG_CONFIG, useValue: mockedDialogData}
] ]
}).compileComponents(); }).compileComponents();
}); });
beforeEach(() => { beforeEach(() => {
TestBed.overrideProvider(NB_DIALOG_CONFIG, {useValue: mockedDialogData});
fixture = TestBed.createComponent(ProjectDialogComponent); fixture = TestBed.createComponent(ProjectDialogComponent);
component = fixture.componentInstance; component = fixture.componentInstance;
fixture.detectChanges(); fixture.detectChanges();
@ -64,3 +78,71 @@ describe('ProjectDialogComponent', () => {
expect(component).toBeTruthy(); expect(component).toBeTruthy();
}); });
}); });
export const createSpyObj = (baseName, methodNames): { [key: string]: Mock<any> } => {
const obj: any = {};
for (const i of methodNames) {
obj[i] = jest.fn();
}
return obj;
};
export const mockProject: Project = {
id: '11-22-33',
title: 'Test Project',
client: 'Testclient',
tester: 'Testpentester',
createdAt: new Date(),
createdBy: 'UID-11-12-13'
};
export const mockedDialogData = {
form: {
projectTitle: {
fieldName: 'projectTitle',
type: 'text',
labelKey: 'project.title.label',
placeholder: 'project.title',
controlsConfig: [
{value: mockProject ? mockProject.title : '', disabled: false},
[Validators.required]
],
errors: [
{errorCode: 'required', translationKey: 'project.validationMessage.titleRequired'}
]
},
projectClient: {
fieldName: 'projectClient',
type: 'text',
labelKey: 'project.client.label',
placeholder: 'project.client',
controlsConfig: [
{value: mockProject ? mockProject.client : '', disabled: false},
[Validators.required]
],
errors: [
{errorCode: 'required', translationKey: 'project.validationMessage.clientRequired'}
]
},
projectTester: {
fieldName: 'projectTester',
type: 'text',
labelKey: 'project.tester.label',
placeholder: 'project.tester',
controlsConfig: [
{value: mockProject ? mockProject.tester : '', disabled: false},
[Validators.required]
],
errors: [
{errorCode: 'required', translationKey: 'project.validationMessage.testerRequired'}
]
}
},
options: [
{
headerLabelKey: 'project.edit.header',
buttonKey: 'global.action.update',
accentColor: 'warning'
},
]
};

View File

@ -1,8 +1,8 @@
import {Component, OnDestroy, OnInit} from '@angular/core'; import {Component, Inject, OnDestroy, OnInit} from '@angular/core';
import {NbDialogRef} from '@nebular/theme'; import {NB_DIALOG_CONFIG, NbDialogRef} from '@nebular/theme';
import {AbstractControl, FormBuilder, FormGroup, Validators} from '@angular/forms'; import {FormBuilder, FormGroup} from '@angular/forms';
import {FieldStatus} from '@shared/models/form-field-status.model'; import {GenericFormFieldConfig, ProjectDialogData} from '@shared/models/project-dialog-data';
import {untilDestroyed} from 'ngx-take-until-destroy'; import deepEqual from 'deep-equal';
@Component({ @Component({
selector: 'app-project-dialog', selector: 'app-project-dialog',
@ -12,40 +12,29 @@ import {untilDestroyed} from 'ngx-take-until-destroy';
export class ProjectDialogComponent implements OnInit, OnDestroy { export class ProjectDialogComponent implements OnInit, OnDestroy {
// form control elements // form control elements
projectFormGroup: FormGroup; projectFormGroup: FormGroup;
projectTitleCtrl: AbstractControl; formArray: GenericFormFieldConfig[];
projectClientCtrl: AbstractControl;
projectTesterCtrl: AbstractControl;
formCtrlStatus = FieldStatus.BASIC; dialogData: ProjectDialogData;
invalidProjectTitle: string;
invalidProjectClient: string;
invalidProjectTester: string;
readonly MIN_LENGTH: number = 2;
constructor( constructor(
@Inject(NB_DIALOG_CONFIG) private data: ProjectDialogData,
private fb: FormBuilder, private fb: FormBuilder,
protected dialogRef: NbDialogRef<ProjectDialogComponent> protected dialogRef: NbDialogRef<ProjectDialogComponent>
) { ) {
} }
ngOnInit(): void { ngOnInit(): void {
this.projectFormGroup = this.fb.group({ this.projectFormGroup = this.generateFormCreationFieldArray();
projectTitle: ['', [Validators.required, Validators.minLength(this.MIN_LENGTH)]], this.dialogData = this.data;
projectClient: ['', [Validators.required, Validators.minLength(this.MIN_LENGTH)]], }
projectTester: ['', [Validators.required, Validators.minLength(this.MIN_LENGTH)]]
});
this.projectTitleCtrl = this.projectFormGroup.get('projectTitle'); generateFormCreationFieldArray(): FormGroup {
this.projectClientCtrl = this.projectFormGroup.get('projectClient'); this.formArray = Object.values(this.data.form);
this.projectTesterCtrl = this.projectFormGroup.get('projectTester'); const config = this.formArray?.reduce((accumulator: {}, currentValue: GenericFormFieldConfig) => ({
...accumulator,
this.projectFormGroup.valueChanges [currentValue?.fieldName]: currentValue?.controlsConfig
.pipe(untilDestroyed(this)) }), {});
.subscribe(() => { return this.fb.group(config);
this.formCtrlStatus = FieldStatus.BASIC;
});
} }
onClickSave(value): void { onClickSave(value): void {
@ -60,23 +49,41 @@ export class ProjectDialogComponent implements OnInit, OnDestroy {
this.dialogRef.close(); this.dialogRef.close();
} }
formIsEmptyOrInvalid(): boolean { allowSave(): boolean {
return this.isEmpty(this.projectTitleCtrl.value) return this.projectFormGroup.valid && this.projectDataChanged();
|| this.isEmpty(this.projectClientCtrl.value)
|| this.isEmpty(this.projectTesterCtrl.value)
|| this.projectTitleCtrl.invalid
|| this.projectClientCtrl.invalid
|| this.projectTesterCtrl.invalid;
} }
/** /**
* @param ctrlValue of type string * @return true if project data is different from initial value
* @return if ctrlValue is empty or not
*/ */
isEmpty(ctrlValue: string): boolean { private projectDataChanged(): boolean {
return ctrlValue === ''; const oldProjectData = this.parseInitializedProjectDialogData(this.dialogData);
const newProjectData = this.projectFormGroup.getRawValue();
Object.entries(newProjectData).forEach(entry => {
const [key, value] = entry;
if (value === null) {
newProjectData[key] = '';
}
});
const didChange = !deepEqual(oldProjectData, newProjectData);
return didChange;
}
/**
* @param dialogData of type ProjectDialogData
* @return parsed projectData
*/
private parseInitializedProjectDialogData(dialogData: ProjectDialogData): any {
const projectData = {};
Object.entries(dialogData.form).forEach(entry => {
const [key, value] = entry;
projectData[key] = value.controlsConfig[0] ?
(value.controlsConfig[0].value ? value.controlsConfig[0].value : value.controlsConfig[0]) : '';
});
return projectData;
} }
ngOnDestroy(): void { ngOnDestroy(): void {
// ToDo: Remove this after Angular upgrade and use @UnitDestroy() instead
} }
} }

View File

@ -7,6 +7,7 @@ import {FontAwesomeModule} from '@fortawesome/angular-fontawesome';
import {TranslateModule} from '@ngx-translate/core'; import {TranslateModule} from '@ngx-translate/core';
import {DialogService} from '@shared/services/dialog-service/dialog.service'; import {DialogService} from '@shared/services/dialog-service/dialog.service';
import {ReactiveFormsModule} from '@angular/forms'; import {ReactiveFormsModule} from '@angular/forms';
import {ProjectDialogService} from '@shared/modules/project-dialog/service/project-dialog.service';
@NgModule({ @NgModule({
declarations: [ declarations: [
@ -25,6 +26,7 @@ import {ReactiveFormsModule} from '@angular/forms';
], ],
providers: [ providers: [
DialogService, DialogService,
ProjectDialogService,
NbDialogService NbDialogService
], ],
entryComponents: [ entryComponents: [

View File

@ -0,0 +1,17 @@
import {ComponentType} from '@angular/cdk/overlay';
import {NbDialogConfig} from '@nebular/theme';
import {ProjectDialogService} from '@shared/modules/project-dialog/service/project-dialog.service';
import {Project} from '@shared/models/project.model';
import {Observable, of} from 'rxjs';
export class ProjectDialogServiceMock implements Required<ProjectDialogService> {
dialog: any;
openProjectDialog(
componentOrTemplateRef: ComponentType<any>,
project: Project | undefined,
config: Partial<NbDialogConfig<Partial<any> | string>> | undefined): Observable<any> {
return of(undefined);
}
}

View File

@ -0,0 +1,30 @@
import { TestBed } from '@angular/core/testing';
import { ProjectDialogService } from './project-dialog.service';
import {HttpClientTestingModule} from '@angular/common/http/testing';
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
import {NbDialogModule, NbDialogRef} from '@nebular/theme';
import {ProjectDialogServiceMock} from '@shared/modules/project-dialog/service/project-dialog.service.mock';
describe('ProjectDialogService', () => {
let service: ProjectDialogService;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [
HttpClientTestingModule,
BrowserAnimationsModule,
NbDialogModule.forRoot()
],
providers: [
{provide: ProjectDialogService, useClass: ProjectDialogServiceMock},
{provide: NbDialogRef, useValue: {}},
]
});
service = TestBed.inject(ProjectDialogService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@ -0,0 +1,102 @@
import {Injectable} from '@angular/core';
import {NbDialogConfig, NbDialogService} from '@nebular/theme';
import {ComponentType} from '@angular/cdk/overlay';
import {Observable} from 'rxjs';
import {Project} from '@shared/models/project.model';
import {ProjectDialogComponent} from '@shared/modules/project-dialog/project-dialog.component';
import {Validators} from '@angular/forms';
import {ProjectDialogData} from '@shared/models/project-dialog-data';
@Injectable()
export class ProjectDialogService {
constructor(private readonly dialog: NbDialogService) {
}
private readonly MIN_LENGTH: number = 4;
static addDataToDialogConfig(
dialogOptions?: Partial<NbDialogConfig<Partial<any> | string>>,
projectData?: ProjectDialogData
): Partial<NbDialogConfig<Partial<any> | string>> {
return {
context: {data: projectData},
closeOnEsc: dialogOptions?.closeOnEsc || false,
hasScroll: dialogOptions?.hasScroll || false,
autoFocus: dialogOptions?.autoFocus || false,
closeOnBackdropClick: dialogOptions?.closeOnBackdropClick || false
};
}
public openProjectDialog(componentOrTemplateRef: ComponentType<any>,
project?: Project,
config?: Partial<NbDialogConfig<Partial<any> | string>>): Observable<any> {
let dialogOptions: Partial<NbDialogConfig<Partial<any> | string>>;
let dialogData: ProjectDialogData;
// Setup ProjectDialogData
dialogData = {
form: {
projectTitle: {
fieldName: 'projectTitle',
type: 'text',
labelKey: 'project.title.label',
placeholder: 'project.title',
controlsConfig: [
{value: project ? project.title : '', disabled: false},
[Validators.required]
],
errors: [
{errorCode: 'required', translationKey: 'project.validationMessage.titleRequired'}
]
},
projectClient: {
fieldName: 'projectClient',
type: 'text',
labelKey: 'project.client.label',
placeholder: 'project.client',
controlsConfig: [
{value: project ? project.client : '', disabled: false},
[Validators.required]
],
errors: [
{errorCode: 'required', translationKey: 'project.validationMessage.clientRequired'}
]
},
projectTester: {
fieldName: 'projectTester',
type: 'text',
labelKey: 'project.tester.label',
placeholder: 'project.tester',
controlsConfig: [
{value: project ? project.tester : '', disabled: false},
[Validators.required]
],
errors: [
{errorCode: 'required', translationKey: 'project.validationMessage.testerRequired'}
]
}
},
options: []
};
if (project) {
dialogData.options = [
{
headerLabelKey: 'project.edit.header',
buttonKey: 'global.action.update',
accentColor: 'warning'
},
];
} else {
dialogData.options = [
{
headerLabelKey: 'project.create.header',
buttonKey: 'global.action.save',
accentColor: 'primary'
},
];
}
// Merge dialog config with project data
dialogOptions = ProjectDialogService.addDataToDialogConfig(config, dialogData);
return this.dialog.open(ProjectDialogComponent, dialogOptions).onClose;
}
}

View File

@ -24,7 +24,7 @@ export class DialogService {
closeOnEsc: config?.closeOnEsc || false, closeOnEsc: config?.closeOnEsc || false,
hasScroll: config?.hasScroll || false, hasScroll: config?.hasScroll || false,
autoFocus: config?.autoFocus || false, autoFocus: config?.autoFocus || false,
closeOnBackdropClick: config?.closeOnBackdropClick || false, closeOnBackdropClick: config?.closeOnBackdropClick || false
}); });
} }
@ -35,7 +35,7 @@ export class DialogService {
*/ */
openConfirmDialog(message: DialogMessage): NbDialogRef<ConfirmDialogComponent> { openConfirmDialog(message: DialogMessage): NbDialogRef<ConfirmDialogComponent> {
return this.dialog.open(ConfirmDialogComponent, { return this.dialog.open(ConfirmDialogComponent, {
closeOnEsc: false, closeOnEsc: true,
hasScroll: false, hasScroll: false,
autoFocus: false, autoFocus: false,
closeOnBackdropClick: false, closeOnBackdropClick: false,

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, SaveProjectDialogBody} from '@shared/models/project.model'; import {Project, ProjectDialogBody} 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(saveProject: SaveProjectDialogBody): Observable<Project> { saveProject(saveProject: ProjectDialogBody): Observable<Project> {
return of();
}
updateProject(projectId: string, project: ProjectDialogBody): Observable<Project> {
return of(); return of();
} }

View File

@ -4,7 +4,7 @@ import {ProjectService} from './project.service';
import {HttpClientTestingModule, HttpTestingController} from '@angular/common/http/testing'; import {HttpClientTestingModule, HttpTestingController} from '@angular/common/http/testing';
import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
import {KeycloakService} from 'keycloak-angular'; import {KeycloakService} from 'keycloak-angular';
import {Project, SaveProjectDialogBody} from '@shared/models/project.model'; import {Project, ProjectDialogBody} from '@shared/models/project.model';
import {environment} from '../../environments/environment'; import {environment} from '../../environments/environment';
describe('ProjectService', () => { describe('ProjectService', () => {
@ -76,7 +76,7 @@ describe('ProjectService', () => {
describe('saveProject', () => { describe('saveProject', () => {
// arrange // arrange
const mockSaveProjectDialogBody: SaveProjectDialogBody = { const mockSaveProjectDialogBody: ProjectDialogBody = {
client: 'E Corp', client: 'E Corp',
title: 'Some Mock API (v1.0) Scanning', title: 'Some Mock API (v1.0) Scanning',
tester: 'Novatester', tester: 'Novatester',

View File

@ -1,7 +1,7 @@
import {Injectable} from '@angular/core'; import {Injectable} from '@angular/core';
import {environment} from '../../environments/environment'; import {environment} from '../../environments/environment';
import {HttpClient} from '@angular/common/http'; import {HttpClient} from '@angular/common/http';
import {Project, SaveProjectDialogBody} from '../models/project.model'; import {Project, ProjectDialogBody} from '../models/project.model';
import {Observable} from 'rxjs'; import {Observable} from 'rxjs';
@Injectable({ @Injectable({
@ -25,10 +25,20 @@ export class ProjectService {
* Save Project * Save Project
* @param project the information of the project * @param project the information of the project
*/ */
public saveProject(project: SaveProjectDialogBody): Observable<Project> { public saveProject(project: ProjectDialogBody): Observable<Project> {
return this.http.post<Project>(`${this.apiBaseURL}`, project); return this.http.post<Project>(`${this.apiBaseURL}`, project);
} }
/**
* Update Project
* @param projectId the id of the project
* @param project the information of the project
*/
public updateProject(projectId: string, project: ProjectDialogBody): Observable<Project> {
console.log('update Project');
return this.http.patch<Project>(`${this.apiBaseURL}/${projectId}`, project);
}
/** /**
* Delete Project * Delete Project
* @param projectId the id of the project * @param projectId the id of the project

View File

@ -7,6 +7,7 @@
"sourceMap": true, "sourceMap": true,
"declaration": false, "declaration": false,
"downlevelIteration": true, "downlevelIteration": true,
"esModuleInterop": true,
"experimentalDecorators": true, "experimentalDecorators": true,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"moduleResolution": "node", "moduleResolution": "node",

View File

@ -6,6 +6,7 @@
"jest" "jest"
], ],
"module": "commonjs", "module": "commonjs",
"esModuleInterop": true,
"emitDecoratorMetadata": true, "emitDecoratorMetadata": true,
"allowJs": true, "allowJs": true,
}, },

View File

@ -12,7 +12,7 @@ services:
- ../cfg/keycloak.env - ../cfg/keycloak.env
c4po-keycloak-postgress: c4po-keycloak-postgress:
container_name: c4po-keycloak-postgres container_name: c4po-keycloak-postgres
image: postgres:latest image: postgres:10.16-alpine
env_file: env_file:
- ../cfg/keycloakdb.env - ../cfg/keycloakdb.env
ports: ports: