[BP-841] Add cyclic reporting form component Signed-off-by: Christopher Keim <keim@develop-group.de>
diff --git a/src/app/cyclic-reporting/components/form/cyclic-report-form.component.html b/src/app/cyclic-reporting/components/form/cyclic-report-form.component.html new file mode 100644 index 0000000..f0fd69b --- /dev/null +++ b/src/app/cyclic-reporting/components/form/cyclic-report-form.component.html
@@ -0,0 +1,135 @@ +<!-- +/******************************************************************************** + * Copyright © 2020 Basys GmbH. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +--> + +<div class="container-fluid"> + <div class="row"> + <div class="col-md-12"> + <div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom"> + <h1 class="h2" id="overviewHeader"> + Zyklischen Report verwalten + </h1> + <div class="btn-toolbar mb-2 mb-md-0"> + <div class="btn-group mr-2"> + <button class="btn btn-primary mr-1" routerLink=".." > + Zurück + </button> + <ng-container *ngIf="report"> + <button *ngIf="report?.id != null" + [disabled]="form?.disabled" + (click)="delete()" + class="btn btn-danger mr-1"> + Löschen + </button> + <button class="btn btn-success mr-1" + [disabled]="form?.disabled" + (click)="submit()"> + Speichern + </button> + </ng-container> + </div> + </div> + </div> + </div> + </div> + + <div class="row" *ngIf="report == null"> + <div class="col-md-12"> + Daten konnten nicht geladen werden. Bitte kehren Sie zur Übersicht zurück oder betreten Sie die Seite erneut. + </div> + </div> + + <div class="row" *ngIf="report != null"> + <div class="col-md-12 cyclic-report-form"> + + <div class="cyclic-report-form-column"> + <ok-cyclic-report-form-input [form]="form" [key]="'name'"> + <span class="cyclic-report-form-label">Name:</span> + </ok-cyclic-report-form-input> + + <div></div> + + <ok-cyclic-report-form-select [form]="form" [key]="'reportName'" [options]="reportNameOptions"> + <span class="cyclic-report-form-label">Planart:</span> + </ok-cyclic-report-form-select> + + <ok-cyclic-report-form-select [form]="form" [key]="'standByListId'" + [options]="standByListOptions"> + <span class="cyclic-report-form-label">Liste:</span> + </ok-cyclic-report-form-select> + + <ok-cyclic-report-form-select [form]="form" [key]="'statusId'" [options]="planStatusOptions"> + <span class="cyclic-report-form-label">Ebene:</span> + </ok-cyclic-report-form-select> + + <ok-cyclic-report-form-select [form]="form" [key]="'printFormat'" [options]="printFormats"> + <span class="cyclic-report-form-label">Format:</span> + </ok-cyclic-report-form-select> + + <div></div> + + <ng-container *ngFor="let entry of toFormArray?.controls; let i = index; let first = first;"> + <ok-cyclic-report-form-input [form]="toFormArray" [key]="i" + [displayAddButton]="first" [displayCancelButton]="!first" + (add)="addEmailControl()" (cancel)="removeEmailControlAt(i)"> + <span class="cyclic-report-form-label"> + <ng-container *ngIf="first">Empfänger:</ng-container> + </span> + </ok-cyclic-report-form-input> + </ng-container> + + <ok-cyclic-report-form-input [form]="form" [key]="'subject'"> + <span class="cyclic-report-form-label">Betreff:</span> + </ok-cyclic-report-form-input> + + <ok-cyclic-report-form-textarea [form]="form" [key]="'emailText'" class="cyclic-report-form-textarea"> + <span class="cyclic-report-form-label">Emailtext:</span> + </ok-cyclic-report-form-textarea> + + <ok-cyclic-report-form-input [form]="form" [key]="'fileNamePattern'"> + <span class="cyclic-report-form-label">Dateiname:</span> + </ok-cyclic-report-form-input> + + </div> + + <div class="cyclic-report-form-column"> + + <ok-cyclic-report-form-date-controls [form]="form" + [options]="weekDayOptions" + [triggerMinuteStep]="triggerMinuteStep" + [key]="'trigger'"> + <span>Auslöse­zeitpunkt:</span> + </ok-cyclic-report-form-date-controls> + + <ok-cyclic-report-form-date-controls [form]="form" + [key]="'validFrom'" [forDayOffset]="true"> + <span>Gültig­keit von:</span> + </ok-cyclic-report-form-date-controls> + + <ok-cyclic-report-form-date-controls [form]="form" [key]="'validTo'" + [forDayOffset]="true"> + <span>Gültig­keit bis:</span> + </ok-cyclic-report-form-date-controls> + + <ok-error class="cyclic-report-form-error" [control]="form"></ok-error> + + <ok-cyclic-report-form-info + [data]="form?.value" + [dateReplacementTokens]="dateTokens" + class="cyclic-report-form-info"> + </ok-cyclic-report-form-info> + + </div> + + </div> + </div> + +</div>
diff --git a/src/app/cyclic-reporting/components/form/cyclic-report-form.component.scss b/src/app/cyclic-reporting/components/form/cyclic-report-form.component.scss new file mode 100644 index 0000000..973cd15 --- /dev/null +++ b/src/app/cyclic-reporting/components/form/cyclic-report-form.component.scss
@@ -0,0 +1,52 @@ +/******************************************************************************** + * Copyright © 2020 Basys GmbH. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + + +.cyclic-report-form { + display: flex; + flex-flow: row wrap; + justify-content: space-around; + padding: 0; + width: 100%; +} + +.cyclic-report-form-column { + flex: 1 1 25em; + width: 25em; + margin: 0 15px; + + display: flex; + flex-flow: column; + + & > * { + margin-bottom: 0.75em; + } +} + +.cyclic-report-form-error { + margin-left: auto; + margin-right: auto; +} + +.cyclic-report-form-label { + display: inline-block; + padding: calc(.375rem + 1px) 1rem calc(.375rem + 1px) 0; + line-height: 1.5; + margin: 0; + min-width: 10rem; +} + +.cyclic-report-form-textarea { + min-height: calc(5 * 1.5em + 1em); +} + +.cyclic-report-form-info { + padding: 0 1em 0 1em; +}
diff --git a/src/app/cyclic-reporting/components/form/cyclic-report-form.component.spec.ts b/src/app/cyclic-reporting/components/form/cyclic-report-form.component.spec.ts new file mode 100644 index 0000000..f675a09 --- /dev/null +++ b/src/app/cyclic-reporting/components/form/cyclic-report-form.component.spec.ts
@@ -0,0 +1,270 @@ +/******************************************************************************** + * Copyright © 2020 Basys GmbH. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import {CommonModule} from '@angular/common'; +import {HttpClientTestingModule} from '@angular/common/http/testing'; +import {async, ComponentFixture, TestBed} from '@angular/core/testing'; +import {ActivatedRoute, Router} from '@angular/router'; +import {RouterTestingModule} from '@angular/router/testing'; +import {SharedModule} from '@shared/shared.module'; +import {CyclicReportObject} from '@shared/model/CyclicReportObject'; +import {ReportObject} from '@shared/model/ReportObject'; +import {StandbylistObject} from '@shared/model/StandbylistObject'; +import {FormUtil} from '@shared/utils/form.util'; +import {MessageService} from 'primeng/api'; +import {defer, of, throwError} from 'rxjs'; +import {CyclicReportFormDateControlsComponent} from '../form-date-controls/cyclic-report-form-date-controls.component'; +import {CyclicReportFormInfoComponent} from '../form-info/cyclic-report-form-info.component'; +import {CyclicReportFormInputComponent} from '../form-input/cyclic-report-form-input.component'; +import {CyclicReportFormSelectComponent} from '../form-select/cyclic-report-form-select.component'; +import {CyclicReportFormComponent} from './cyclic-report-form.component'; +import {CyclicReportFormTextareaComponent} from '@cyclic-reporting/components/form-textarea/cyclic-report-form-textarea.component'; + +function createMockData<T extends object>(data: Partial<T>): T { + return (data == null ? {} : data) as T; +} + +describe('CyclicReportFormComponent', () => { + + let component: CyclicReportFormComponent; + let fixture: ComponentFixture<CyclicReportFormComponent>; + let idParam: number | 'new'; + let report: CyclicReportObject; + let router: Router; + let activatedRoute: ActivatedRoute; + + const standByListData: StandbylistObject[] = Array(42).fill(0) + .map((_, id) => createMockData<StandbylistObject>({ id: id, title: 'QVL ' + id })); + + const reportObjects: ReportObject[] = Array(42).fill(0) + .map((_, id) => createMockData<ReportObject>({ reportName: 'ReportName' + id })); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ + CyclicReportFormComponent, + CyclicReportFormDateControlsComponent, + CyclicReportFormInfoComponent, + CyclicReportFormInputComponent, + CyclicReportFormSelectComponent, + CyclicReportFormTextareaComponent + ], + imports: [ + CommonModule, + SharedModule, + RouterTestingModule, + HttpClientTestingModule + ], + providers: [ + MessageService, + { + provide: ActivatedRoute, + useValue: { params: defer(() => of({id: idParam == null ? undefined : '' + idParam}))} + } + ] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(CyclicReportFormComponent); + component = fixture.componentInstance; + component.excludedReportNames = reportObjects.slice(-1).map((reportObject) => reportObject.reportName); + router = TestBed.get(Router); + activatedRoute = TestBed.get(ActivatedRoute); + idParam = 19; + report = null; + spyOn(component.reportingService, 'getReportData') + .and.returnValue(defer(() => of(reportObjects))); + spyOn(component.masterdataService, 'getStandbyListSelection') + .and.returnValue(defer(() => of(standByListData))); + spyOn(component.cyclicReportingService, 'getCyclicReports') + .and.returnValue(defer(() => of(report == null ? [] : [report]))); + }); + + describe('should create', () => { + it('for new reports', () => { + idParam = 'new'; + fixture.detectChanges(); + expect(component).toBeDefined(); + }); + + it('for non-existing reports', () => { + idParam = -19; + fixture.detectChanges(); + expect(component).toBeDefined(); + }); + + it('for existing report', () => { + idParam = 19; + report = createMockData<CyclicReportObject>({ id: 19, to: [ 'a@b.c', 'x@y.z' ], standByListId: 19 }); + fixture.detectChanges(); + expect(component).toBeDefined(); + }); + }); + + it('should add and remove email controls', () => { + idParam = 'new'; + fixture.detectChanges(); + expect(component.form.value.to).toEqual(['']); + component.addEmailControl(); + expect(component.form.value.to).toEqual(['', '']); + component.removeEmailControlAt(1); + expect(component.form.value.to).toEqual(['']); + }); + + describe('should submit', () => { + + it('for new reports', () => { + const validateSpy = spyOn(FormUtil, 'validate').and.returnValue(true); + const putFormSpy = spyOn(component.cyclicReportingService, 'putCyclicReport').and.returnValue(of(report)); + const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true)); + + report = { + id: null, + name: 'Name', + fileNamePattern: '{Date}_{Time}_{Week}', + subject: 'Test Subject', + to: ['test@tld.org'], + emailText: '', + reportName: reportObjects[0].reportName, + printFormat: 'pdf', + standByListId: standByListData[0].id, + statusId: 2, + triggerWeekDay: 1, + triggerHour: 8, + triggerMinute: 0, + validFromDayOffset: 0, + validFromHour: 8, + validFromMinute: 0, + validToDayOffset: 1, + validToHour: 8, + validToMinute: 0 + }; + idParam = 'new'; + fixture.detectChanges(); + + component.form.patchValue(report); + component.submit(); + expect(validateSpy).toHaveBeenCalled(); + expect(putFormSpy).toHaveBeenCalledWith(report); + expect(navigateSpy).toHaveBeenCalledWith(['..'], { relativeTo: activatedRoute }); + }); + + it('for existing reports', () => { + const validateSpy = spyOn(FormUtil, 'validate').and.returnValue(true); + const postFormSpy = spyOn(component.cyclicReportingService, 'postCyclicReport').and.returnValue(of(report)); + const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true)); + + report = { + id: 19, + name: 'Name', + fileNamePattern: '{Date}_{Time}_{Week}', + subject: 'Test Subject', + to: ['test@tld.org'], + emailText: '', + reportName: reportObjects[0].reportName, + printFormat: 'pdf', + standByListId: standByListData[0].id, + statusId: 2, + triggerWeekDay: 1, + triggerHour: 8, + triggerMinute: 0, + validFromDayOffset: 0, + validFromHour: 8, + validFromMinute: 0, + validToDayOffset: 1, + validToHour: 8, + validToMinute: 0 + }; + idParam = 19; + fixture.detectChanges(); + + component.addEmailControl(); + component.submit(); + expect(validateSpy).toHaveBeenCalled(); + expect(postFormSpy).toHaveBeenCalledWith(report); + expect(navigateSpy).toHaveBeenCalledWith(['..'], { relativeTo: activatedRoute }); + }); + + it('and handle errors correctly', () => { + const validateSpy = spyOn(FormUtil, 'validate').and.returnValue(false); + const postFormSpy = spyOn(component.cyclicReportingService, 'postCyclicReport').and.returnValue(of(report)); + const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true)); + + report = { + id: 19, + name: 'Name', + fileNamePattern: '{Date}_{Time}_{Week}', + subject: 'Test Subject', + to: ['test@tld.org'], + emailText: '', + reportName: reportObjects[0].reportName, + printFormat: 'pdf', + standByListId: standByListData[0].id, + statusId: 2, + triggerWeekDay: 1, + triggerHour: 8, + triggerMinute: 0, + validFromDayOffset: 0, + validFromHour: 8, + validFromMinute: 0, + validToDayOffset: 1, + validToHour: 8, + validToMinute: 0 + }; + idParam = 19; + fixture.detectChanges(); + + component.submit(); + expect(validateSpy).toHaveBeenCalled(); + expect(postFormSpy).not.toHaveBeenCalled(); + + validateSpy.and.returnValue(true); + postFormSpy.and.returnValue(throwError('TestError')); + component.submit(); + expect(validateSpy).toHaveBeenCalled(); + expect(postFormSpy).toHaveBeenCalledWith(report); + expect(navigateSpy).not.toHaveBeenCalled(); + expect(component.form.enabled).toBe(true); + }); + }); + + describe('should delete', () => { + it('existing reports', () => { + const deleteSpy = spyOn(component.cyclicReportingService, 'deleteCyclicReport') + .and.returnValue(of(null)); + const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true)); + + idParam = 19; + report = createMockData<CyclicReportObject>({ id: 19 }); + fixture.detectChanges(); + + component.delete(); + expect(deleteSpy).toHaveBeenCalledWith(19); + expect(navigateSpy).toHaveBeenCalledWith(['..'], { relativeTo: activatedRoute }); + }); + + it('and handle errors correctly', () => { + const deleteSpy = spyOn(component.cyclicReportingService, 'deleteCyclicReport') + .and.returnValue(throwError('TestError')); + const navigateSpy = spyOn(router, 'navigate'); + + idParam = 19; + report = createMockData<CyclicReportObject>({ id: 19 }); + fixture.detectChanges(); + + component.delete(); + expect(deleteSpy).toHaveBeenCalledWith(19); + expect(navigateSpy).not.toHaveBeenCalled(); + expect(component.form.disabled).toBe(false); + }); + }); + +});
diff --git a/src/app/cyclic-reporting/components/form/cyclic-report-form.component.ts b/src/app/cyclic-reporting/components/form/cyclic-report-form.component.ts new file mode 100644 index 0000000..0b54c5b --- /dev/null +++ b/src/app/cyclic-reporting/components/form/cyclic-report-form.component.ts
@@ -0,0 +1,241 @@ +/******************************************************************************** + * Copyright © 2020 Basys GmbH. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import {Component, Injector, OnDestroy, OnInit} from '@angular/core'; +import {FormArray, FormControl, Validators} from '@angular/forms'; +import {UtilService} from '@core/services/util.service'; +import {MasterdataService} from '@masterdata/services/masterdata.service'; +import {ReportingService} from '@reporting/services/reporting.service'; +import {AbstractFormComponent} from '@shared/abstract/abstract-form/abstract-form.component'; +import {CyclicReportObject} from '@shared/model/CyclicReportObject'; +import {SelectOptionObject} from '@shared/model/SelectOptionObject'; +import {FormUtil} from '@shared/utils/form.util'; +import {combineLatest, defer, EMPTY, Observable, of, Subscription} from 'rxjs'; +import {catchError, map, switchMap, tap} from 'rxjs/operators'; +import {CyclicReportingService} from '../../services/cyclic-reporting.service'; +import {CyclicReportingUtilService} from '../../services/cyclic-reporting-util.service'; +import {CyclicReportValidators} from '../../validators/cyclic-report.validators'; + +@Component({ + selector: 'ok-cyclic-report-form', + styleUrls: ['cyclic-report-form.component.scss'], + templateUrl: 'cyclic-report-form.component.html' +}) +export class CyclicReportFormComponent extends AbstractFormComponent implements OnInit, OnDestroy { + + public dateTokens = this.cyclicReportingUtilService.dateReplacementTokens; + + public defaultValue: CyclicReportObject = { + id: undefined, + name: '', + fileNamePattern: '{Date}_{Time}_{Week}', + subject: '', + to: [], + emailText: '', + reportName: '', + printFormat: this.cyclicReportingUtilService.printFormatOptions[0].value, + standByListId: Number.NEGATIVE_INFINITY, + statusId: this.cyclicReportingUtilService.planStatusOptions[0].value, + triggerWeekDay: 1, + triggerHour: 8, + triggerMinute: 0, + validFromDayOffset: 0, + validFromHour: 8, + validFromMinute: 0, + validToDayOffset: 1, + validToHour: 8, + validToMinute: 0 + }; + + public excludedReportNames: string[] = this.cyclicReportingUtilService.excludedReportNames; + + public planStatusOptions = this.cyclicReportingUtilService.planStatusOptions; + + public printFormats = this.cyclicReportingUtilService.printFormatOptions; + + public report: CyclicReportObject; + + public reportNameOptions: SelectOptionObject[] = []; + + public standByListOptions: SelectOptionObject[] = []; + + public toFormArray: FormArray; + + public triggerMinuteStep = 5; + + public weekDayOptions = this.cyclicReportingUtilService.weekDayOptions; + + private subscriptions: Subscription[] = []; + + public constructor( + injector: Injector, + public utilService: UtilService, + public masterdataService: MasterdataService, + public reportingService: ReportingService, + public cyclicReportingService: CyclicReportingService, + public cyclicReportingUtilService: CyclicReportingUtilService + ) { + super(injector); + } + + public ngOnInit() { + this.subscriptions.push(this.initializeForm().subscribe()); + } + + public ngOnDestroy() { + this.subscriptions.forEach((subscription) => subscription.unsubscribe()); + } + + public submit() { + this.form.markAsTouched(); + if (!FormUtil.validate(this.form)) { + return; + } + + this.subscriptions.push(...[ + of(this.form.value).pipe( + switchMap((value) => { + this.form.disable(); + value = { + ...value, + to: value.to.filter((_) => _ !== '') + }; + return value.id == null ? + this.cyclicReportingService.putCyclicReport(value) + : this.cyclicReportingService.postCyclicReport(value); + }), + switchMap(() => this.navigateToOverview()), + catchError(() => { + this.form.enable(); + return EMPTY; + }) + ).subscribe() + ]); + } + + public delete() { + this.form.disable(); + this.subscriptions.push(...[ + this.cyclicReportingService.deleteCyclicReport(this.report.id).pipe( + switchMap(() => this.navigateToOverview()), + catchError(() => { + this.form.enable(); + return EMPTY; + }) + ).subscribe() + ]); + } + + public addEmailControl() { + this.toFormArray.push(this.createEmailControl('')); + } + + public removeEmailControlAt(index: number) { + this.toFormArray.removeAt(index); + } + + public navigateToOverview() { + return this.router.navigate(['..'], { relativeTo: this.route }); + } + + private initializeForm() { + return defer(() => { + this.createForm({...this.defaultValue}); + this.form.disable(); + return combineLatest(this.fetchReport(), this.fetchReportNameOptions(), this.fetchStandByListOptions()); + }).pipe( + tap(([report, reportNameOptions, standByListOptions]) => { + this.reportNameOptions = reportNameOptions.filter((option) => !this.excludedReportNames.includes(option.value)); + this.standByListOptions = standByListOptions; + this.form.enable(); + this.createForm({ + ...this.defaultValue, + ...report, + standByListId: extractValueFromOption( + report == null ? this.standByListOptions[0] : findInOptions(report.standByListId, this.standByListOptions) + ), + reportName: extractValueFromOption( + report == null ? this.reportNameOptions[0] : findInOptions(report.reportName, this.reportNameOptions) + ) + }); + }), + catchError(() => { + this.report = undefined; + return EMPTY; + }) + ); + } + + private fetchReport(): Observable<CyclicReportObject> { + return this.route.params.pipe( + map((params) => params.id), + switchMap((id) => { + return id === 'new' ? of(null) : this.cyclicReportingService.getCyclicReports().pipe( + map((reports) => { + id = parseInt(id, 10); + const result = reports.find((entry) => entry.id === id); + if (result == null) { + throw new Error('Entry not found'); + } + return result; + })); + }) + ); + } + + private fetchReportNameOptions(): Observable<SelectOptionObject<string>[]> { + return this.reportingService.getReportData().pipe( + map((data) => data.map((reportObject) => ({ + ...reportObject, + value: reportObject.reportName, + label: reportObject.reportName + }))) + ); + } + + private fetchStandByListOptions(): Observable<SelectOptionObject<number>[]> { + return this.masterdataService.getStandbyListSelection().pipe( + map((data) => data.map((reportObject) => ({ + ...reportObject, + value: reportObject.id, + label: reportObject.title + }))) + ); + } + + private createForm(report: CyclicReportObject) { + this.report = report; + const to = Array.isArray(report.to) && report.to.length > 0 ? report.to : ['']; + this.toFormArray = this.fb.array(to.map((email) => this.createEmailControl(email))); + this.form = this.fb.group({...report, to: this.toFormArray}); + Object.values(this.form.controls) + .forEach((control) => control.setValidators(Validators.required)); + this.form.get('id').clearValidators(); + this.form.get('emailText').clearValidators(); + this.form.get('name').setValidators([Validators.required, Validators.maxLength(256)]); + this.form.get('fileNamePattern').setValidators([Validators.required, Validators.maxLength(128)]); + this.form.get('subject').setValidators([Validators.required, Validators.maxLength(128)]); + this.form.setValidators([CyclicReportValidators.validationDate]); + } + + private createEmailControl(value: string): FormControl { + return this.fb.control(value, [Validators.email]); + } + +} + +function extractValueFromOption<T>(option: SelectOptionObject<T>) { + return option == null ? undefined : option.value; +} + +function findInOptions<T>(value: T, options: SelectOptionObject<T>[]) { + const selected = options.find((option) => option.value === value); + return selected == null ? undefined : selected; +}