From 4bda2e2f61f689b31a0ed6429d5c0b1e0a4d90d0 Mon Sep 17 00:00:00 2001 From: Mathis Hofer Date: Tue, 27 Oct 2020 17:18:18 +0100 Subject: [PATCH] Preselect absence type and don't override existing absence types on edit #211 --- .../edit-absences-edit.component.html | 7 +- .../edit-absences-edit.component.spec.ts | 387 +++++++++++++++++- .../edit-absences-edit.component.ts | 292 ++++++------- .../edit-absences-list.component.ts | 4 +- .../edit-absences-selection.service.ts | 26 +- .../services/edit-absences-state.service.ts | 5 +- .../edit-absences-update.service.spec.ts | 17 + .../services/edit-absences-update.service.ts | 151 +++++++ .../my-absences-abstract-confirm.component.ts | 19 +- .../my-profile-edit.component.ts | 16 +- .../confirm-absences.component.ts | 22 +- src/app/shared/utils/form.spec.ts | 148 +++++++ src/app/shared/utils/form.ts | 90 ++-- 13 files changed, 911 insertions(+), 273 deletions(-) create mode 100644 src/app/edit-absences/services/edit-absences-update.service.spec.ts create mode 100644 src/app/edit-absences/services/edit-absences-update.service.ts create mode 100644 src/app/shared/utils/form.spec.ts diff --git a/src/app/edit-absences/components/edit-absences-edit/edit-absences-edit.component.html b/src/app/edit-absences/components/edit-absences-edit/edit-absences-edit.component.html index 5036236a0..8f81e96d6 100644 --- a/src/app/edit-absences/components/edit-absences-edit/edit-absences-edit.component.html +++ b/src/app/edit-absences/components/edit-absences-edit/edit-absences-edit.component.html @@ -2,12 +2,13 @@ class="erz-container erz-container-limited erz-container-padding-y" *erzLet="{ confirmationStates: confirmationStates$ | async, - categories: activeCategories$ | async + categories: activeCategories$ | async, + formGroup: formGroup$ | async } as data" >
diff --git a/src/app/edit-absences/components/edit-absences-edit/edit-absences-edit.component.spec.ts b/src/app/edit-absences/components/edit-absences-edit/edit-absences-edit.component.spec.ts index 068c6c935..3dbdc81c5 100644 --- a/src/app/edit-absences/components/edit-absences-edit/edit-absences-edit.component.spec.ts +++ b/src/app/edit-absences/components/edit-absences-edit/edit-absences-edit.component.spec.ts @@ -1,15 +1,55 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { of } from 'rxjs'; +import { HttpTestingController } from '@angular/common/http/testing'; +import { of, Observable } from 'rxjs'; +import { isEqual } from 'lodash-es'; -import { buildTestModuleMetadata } from 'src/spec-helpers'; +import { buildTestModuleMetadata, settings } from 'src/spec-helpers'; import { EditAbsencesEditComponent } from './edit-absences-edit.component'; import { EditAbsencesStateService } from '../../services/edit-absences-state.service'; +import { PresenceTypesService } from 'src/app/shared/services/presence-types.service'; +import { buildPresenceType, buildLessonPresence } from 'src/spec-builders'; +import { DropDownItemsRestService } from 'src/app/shared/services/drop-down-items-rest.service'; +import { PresenceType } from 'src/app/shared/models/presence-type.model'; +import { DropDownItem } from 'src/app/shared/models/drop-down-item.model'; describe('EditAbsencesEditComponent', () => { let component: EditAbsencesEditComponent; let fixture: ComponentFixture; + let element: HTMLElement; + let httpTestingController: HttpTestingController; + let state: EditAbsencesStateService; + let absence: PresenceType; + let doctor: PresenceType; + let ill: PresenceType; + let late: PresenceType; + let dispensation: PresenceType; + let halfDay: PresenceType; beforeEach(async(() => { + absence = buildPresenceType(settings.absencePresenceTypeId, true, false); + absence.NeedsConfirmation = true; + + doctor = buildPresenceType(13, true, false); + doctor.NeedsConfirmation = true; + doctor.Designation = 'Arzt'; + + ill = buildPresenceType(14, true, false); + ill.NeedsConfirmation = true; + ill.Designation = 'Krank'; + + late = buildPresenceType(settings.latePresenceTypeId, false, true); + late.Designation = 'Verspätung'; + + dispensation = buildPresenceType( + settings.dispensationPresenceTypeId, + false, + false + ); + dispensation.IsDispensation = true; + + halfDay = buildPresenceType(settings.halfDayPresenceTypeId, false, false); + halfDay.IsHalfDay = true; + TestBed.configureTestingModule( buildTestModuleMetadata({ declarations: [EditAbsencesEditComponent], @@ -18,22 +58,357 @@ describe('EditAbsencesEditComponent', () => { provide: EditAbsencesStateService, useValue: { presenceTypes$: of([]), - selected: [{ lessonIds: [1, 2, 3], personIds: [4, 5, 6] }], + selected: [], resetSelection: jasmine.createSpy('resetSelection'), }, }, + { + provide: PresenceTypesService, + useValue: { + presenceTypes$: of([ + absence, + doctor, + ill, + late, + dispensation, + halfDay, + ]), + confirmationTypes$: of([absence, doctor, ill]), + incidentTypes$: of([late]), + halfDayActive$: of(true), + }, + }, + { + provide: DropDownItemsRestService, + useValue: { + getAbsenceConfirmationStates(): Observable< + ReadonlyArray + > { + return of([ + { + Key: settings.excusedAbsenceStateId, + Value: 'entschuldigt', + }, + { + Key: settings.unexcusedAbsenceStateId, + Value: 'unentschuldigt', + }, + { + Key: settings.unconfirmedAbsenceStateId, + Value: 'zu bestätigen', + }, + { + Key: settings.checkableAbsenceStateId, + Value: 'zu kontrollieren', + }, + ]); + }, + }, + }, ], }) ).compileComponents(); + + httpTestingController = TestBed.inject(HttpTestingController); + state = TestBed.inject(EditAbsencesStateService); })); beforeEach(() => { fixture = TestBed.createComponent(EditAbsencesEditComponent); component = fixture.componentInstance; - fixture.detectChanges(); + element = fixture.debugElement.nativeElement; + + // Don't do any navigation + (component as any).onSaveSuccess = jasmine.createSpy('onSaveSuccess'); + }); + + afterEach(() => { + httpTestingController.verify(); }); - it('should create', () => { - expect(component).toBeTruthy(); + describe('initial absence type', () => { + it('preselects the absence type if all selected entries have the same', () => { + state.selected = [ + buildLessonPresence( + 1, + new Date(), + new Date(), + 'Math', + undefined, + ill.Id + ), + buildLessonPresence( + 2, + new Date(), + new Date(), + 'Deutsch', + undefined, + ill.Id + ), + ]; + fixture.detectChanges(); + expect(getSelect('absenceTypeId').value).toContain(String(ill.Id)); + }); + + it('is empty if absence type of selected entries differs', () => { + state.selected = [ + buildLessonPresence( + 1, + new Date(), + new Date(), + 'Math', + undefined, + doctor.Id + ), + buildLessonPresence( + 2, + new Date(), + new Date(), + 'Deutsch', + undefined, + ill.Id + ), + ]; + fixture.detectChanges(); + + expect(getSelect('absenceTypeId').value).toContain('null'); + }); + }); + + describe('form submission', () => { + beforeEach(() => { + state.selected = [ + buildLessonPresence( + 1, + new Date(), + new Date(), + 'Math', + undefined, + undefined, + undefined, + 100 + ), + buildLessonPresence( + 2, + new Date(), + new Date(), + 'Französisch', + undefined, + absence.Id, + undefined, + 100 + ), + buildLessonPresence( + 3, + new Date(), + new Date(), + 'Math', + undefined, + doctor.Id, + undefined, + 100 + ), + buildLessonPresence( + 4, + new Date(), + new Date(), + 'Englisch', + undefined, + ill.Id, + undefined, + 100 + ), + buildLessonPresence( + 5, + new Date(), + new Date(), + 'Chemie', + undefined, + late.Id, + undefined, + 100 + ), + buildLessonPresence( + 6, + new Date(), + new Date(), + 'Zeichnen', + undefined, + dispensation.Id, + undefined, + 100 + ), + buildLessonPresence( + 7, + new Date(), + new Date(), + 'Turnen', + undefined, + halfDay.Id, + undefined, + 100 + ), + ]; + fixture.detectChanges(); + }); + + it('resets all entries if updating to present', () => { + clickRadio('present'); + clickSave(); + + expectResetRequest({ + LessonIds: [1, 2, 3, 4, 5, 6, 7], + PersonIds: [100], + }); + }); + + it('updates all entries to chosen absence type if excused', () => { + clickRadio('entschuldigt'); + selectOption('absenceTypeId', 'Krank'); + clickSave(); + + expectEditRequest({ + LessonIds: [1, 2, 3, 4, 5, 6, 7], + PersonIds: [100], + PresenceTypeId: ill.Id, + ConfirmationValue: settings.excusedAbsenceStateId, + }); + }); + + it('updates all entries to default absence type if unexcused', () => { + clickRadio('unentschuldigt'); + clickSave(); + + expectEditRequest({ + LessonIds: [1, 2, 3, 4, 5, 6, 7], + PersonIds: [100], + PresenceTypeId: settings.absencePresenceTypeId, + ConfirmationValue: settings.unexcusedAbsenceStateId, + }); + }); + + it('marks all entries as unconfirmed but preserves absence type if available', () => { + clickRadio('zu bestätigen'); + clickSave(); + + expectEditRequest({ + LessonIds: [1, 5, 6, 7], + PersonIds: [100], + PresenceTypeId: absence.Id, + ConfirmationValue: settings.unconfirmedAbsenceStateId, + }); + expectEditRequest({ + LessonIds: [2, 3, 4], + PersonIds: [100], + ConfirmationValue: settings.unconfirmedAbsenceStateId, + }); + }); + + it('marks all entries as checkable but preserves absence type if available', () => { + clickRadio('zu kontrollieren'); + clickSave(); + + expectEditRequest({ + LessonIds: [1, 5, 6, 7], + PersonIds: [100], + PresenceTypeId: absence.Id, + ConfirmationValue: settings.checkableAbsenceStateId, + }); + expectEditRequest({ + LessonIds: [2, 3, 4], + PersonIds: [100], + ConfirmationValue: settings.checkableAbsenceStateId, + }); + }); + + it('updates entries to dispensation', () => { + clickRadio('dispensation'); + clickSave(); + + expectEditRequest({ + LessonIds: [1, 2, 3, 4, 5, 6, 7], + PersonIds: [100], + PresenceTypeId: settings.dispensationPresenceTypeId, + }); + }); + + it('updates entries to half day', () => { + clickRadio('half-day'); + clickSave(); + + expectEditRequest({ + LessonIds: [1, 2, 3, 4, 5, 6, 7], + PersonIds: [100], + PresenceTypeId: settings.halfDayPresenceTypeId, + }); + }); + + it('updates entries to incident', () => { + clickRadio('incident'); + selectOption('incidentId', 'Verspätung'); + clickSave(); + + expectEditRequest({ + LessonIds: [1, 2, 3, 4, 5, 6, 7], + PersonIds: [100], + PresenceTypeId: settings.latePresenceTypeId, + }); + }); }); + + function getSelect(controlName: string): HTMLSelectElement { + const select = element.querySelector( + `select[formControlName="${controlName}"]` + ) as HTMLSelectElement | undefined; + expect(select).toBeDefined(); + return select as HTMLSelectElement; + } + + function selectOption(controlName: string, optionLabel: string): void { + const select = getSelect(controlName); + const option = Array.prototype.slice + .call(select.options) + .find((o) => o.label === optionLabel); + select.value = option?.value; + select.dispatchEvent(new Event('change')); + } + + function clickRadio(labelText: string): void { + const labels = Array.prototype.slice.call( + element.querySelectorAll('label') + ); + const label = labels.find((l) => l.textContent.includes(labelText)) as + | HTMLElement + | undefined; + return label?.parentElement?.querySelector('input')?.click(); + } + + function clickSave(): void { + const button = element.querySelector('button[type="submit"]') as + | HTMLButtonElement + | undefined; + button?.click(); + } + + function expectResetRequest(body: any): void { + const url = 'https://eventotest.api/LessonPresences/Reset'; + + httpTestingController + .expectOne( + (req) => req.urlWithParams === url && isEqual(req.body, body), + url + ) + .flush(''); + } + + function expectEditRequest(body: any): void { + const url = 'https://eventotest.api/LessonPresences/Edit'; + + httpTestingController + .expectOne( + (req) => req.urlWithParams === url && isEqual(req.body, body), + url + ) + .flush(''); + } }); diff --git a/src/app/edit-absences/components/edit-absences-edit/edit-absences-edit.component.ts b/src/app/edit-absences/components/edit-absences-edit/edit-absences-edit.component.ts index c4e613661..cd8a310b7 100644 --- a/src/app/edit-absences/components/edit-absences-edit/edit-absences-edit.component.ts +++ b/src/app/edit-absences/components/edit-absences-edit/edit-absences-edit.component.ts @@ -9,33 +9,32 @@ import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; import { ToastrService } from 'ngx-toastr'; -import { BehaviorSubject, combineLatest, Observable, Subject } from 'rxjs'; +import { BehaviorSubject, Subject, Observable } from 'rxjs'; import { - filter, finalize, map, shareReplay, - startWith, take, takeUntil, + switchMap, } from 'rxjs/operators'; +import { uniq } from 'lodash-es'; + import { SETTINGS, Settings } from 'src/app/settings'; import { DropDownItem } from 'src/app/shared/models/drop-down-item.model'; import { DropDownItemsRestService } from 'src/app/shared/services/drop-down-items-rest.service'; -import { LessonPresencesUpdateRestService } from 'src/app/shared/services/lesson-presences-update-rest.service'; -import { getValidationErrors } from 'src/app/shared/utils/form'; +import { + EditAbsencesUpdateService, + Category, +} from '../../services/edit-absences-update.service'; +import { + getValidationErrors, + getControlValueChanges, +} from 'src/app/shared/utils/form'; import { EditAbsencesStateService } from '../../services/edit-absences-state.service'; import { parseQueryString } from 'src/app/shared/utils/url'; import { PresenceTypesService } from 'src/app/shared/services/presence-types.service'; -enum Category { - Absent = 'absent', - Dispensation = 'dispensation', - HalfDay = 'half-day', - Incident = 'incident', - Present = 'present', -} - @Component({ selector: 'erz-edit-absences-edit', templateUrl: './edit-absences-edit.component.html', @@ -43,36 +42,24 @@ enum Category { changeDetection: ChangeDetectionStrategy.OnPush, }) export class EditAbsencesEditComponent implements OnInit, OnDestroy { - formGroup = this.createFormGroup(); + absenceTypes$ = this.presenceTypesService.confirmationTypes$; + incidents$ = this.presenceTypesService.incidentTypes$; + + formGroup$ = this.createFormGroup(); saving$ = new BehaviorSubject(false); private submitted$ = new BehaviorSubject(false); - formErrors$ = combineLatest([ - getValidationErrors(this.formGroup), - this.submitted$, - ]).pipe( - filter((v) => v[1]), - map((v) => v[0]), - startWith([]) - ); - - absenceTypeIdErrors$ = combineLatest([ - getValidationErrors(this.formGroup.get('absenceTypeId')), + formErrors$ = getValidationErrors(this.formGroup$, this.submitted$); + absenceTypeIdErrors$ = getValidationErrors( + this.formGroup$, this.submitted$, - ]).pipe( - filter((v) => v[1]), - map((v) => v[0]), - startWith([]) + 'absenceTypeId' ); - - incidentIdErrors$ = combineLatest([ - getValidationErrors(this.formGroup.get('incidentId')), + incidentIdErrors$ = getValidationErrors( + this.formGroup$, this.submitted$, - ]).pipe( - filter((v) => v[1]), - map((v) => v[0]), - startWith([]) + 'incidentId' ); availableCategories = [ @@ -87,10 +74,6 @@ export class EditAbsencesEditComponent implements OnInit, OnDestroy { .getAbsenceConfirmationStates() .pipe(map(this.sortAbsenceConfirmationStates.bind(this)), shareReplay(1)); - absenceTypes$ = this.presenceTypesService.confirmationTypes$; - - incidents$ = this.presenceTypesService.incidentTypes$; - // Remove Category HalfDay if the corresponding PresenceType is inactive activeCategories$ = this.presenceTypesService.halfDayActive$.pipe( map((halfDayActive) => @@ -111,7 +94,7 @@ export class EditAbsencesEditComponent implements OnInit, OnDestroy { private state: EditAbsencesStateService, private dropDownItemsService: DropDownItemsRestService, private presenceTypesService: PresenceTypesService, - private updateService: LessonPresencesUpdateRestService, + private updateService: EditAbsencesUpdateService, @Inject(SETTINGS) private settings: Settings ) {} @@ -121,20 +104,16 @@ export class EditAbsencesEditComponent implements OnInit, OnDestroy { this.navigateBack(); } - const categoryControl = this.formGroup.get('category'); - const confirmationValueControl = this.formGroup.get('confirmationValue'); - if (categoryControl && confirmationValueControl) { - // Disable confirmation value radios and absence type/incident - // select when not absent - categoryControl.valueChanges - .pipe(takeUntil(this.destroy$)) - .subscribe(this.updateConfirmationValueDisabled.bind(this)); + // Disable confirmation value radios and absence type/incident + // select when not absent + getControlValueChanges(this.formGroup$, 'category') + .pipe(takeUntil(this.destroy$)) + .subscribe(this.updateConfirmationValueDisabled.bind(this)); - // Disable absence type select when not excused - confirmationValueControl.valueChanges - .pipe(takeUntil(this.destroy$)) - .subscribe(this.updateAbsenceTypeIdDisabled.bind(this)); - } + // Disable absence type select when not excused + getControlValueChanges(this.formGroup$, 'confirmationValue') + .pipe(takeUntil(this.destroy$)) + .subscribe(this.updateAbsenceTypeIdDisabled.bind(this)); } ngOnDestroy(): void { @@ -155,144 +134,115 @@ export class EditAbsencesEditComponent implements OnInit, OnDestroy { onSubmit(): void { this.submitted$.next(true); - if (this.formGroup.valid) { - const { - category, - confirmationValue, - absenceTypeId, - } = this.fetchAndProcessFormValues(); - this.save(category, confirmationValue, absenceTypeId); - } + this.formGroup$.pipe(take(1)).subscribe((formGroup) => { + if (formGroup.valid) { + this.save(formGroup); + } + }); } cancel(): void { this.navigateBack(); } - private createFormGroup(): FormGroup { - return this.fb.group({ - category: [Category.Absent, Validators.required], - confirmationValue: [ - this.settings.excusedAbsenceStateId, - Validators.required, - ], - absenceTypeId: [null, Validators.required], - incidentId: [{ value: null, disabled: true }, Validators.required], - }); + private createFormGroup(): Observable { + return this.getInitialAbsenceTypeId().pipe( + map((initialAbsenceTypeId) => + this.fb.group({ + category: [Category.Absent, Validators.required], + confirmationValue: [ + this.settings.excusedAbsenceStateId, + Validators.required, + ], + absenceTypeId: [initialAbsenceTypeId, Validators.required], + incidentId: [{ value: null, disabled: true }, Validators.required], + }) + ), + shareReplay(1) + ); + } + + private getInitialAbsenceTypeId(): Observable> { + return this.absenceTypes$.pipe( + take(1), + map((absenceTypes) => { + const availableTypeIds = absenceTypes.map((t) => t.Id); + const selectedTypeIds = uniq( + this.state.selected.map((e) => e.TypeRef.Id) + ); + return selectedTypeIds.length === 1 && + selectedTypeIds[0] != null && + availableTypeIds.includes(selectedTypeIds[0]) + ? selectedTypeIds[0] + : null; + }) + ); } private updateConfirmationValueDisabled(): void { - const categoryControl = this.formGroup.get('category'); - const confirmationValueControl = this.formGroup.get('confirmationValue'); - const absenceTypeIdControl = this.formGroup.get('absenceTypeId'); - const incidentIdControl = this.formGroup.get('incidentId'); - if ( - categoryControl && - confirmationValueControl && - absenceTypeIdControl && - incidentIdControl - ) { - if (categoryControl.value === Category.Absent) { - confirmationValueControl.enable(); - this.updateAbsenceTypeIdDisabled(); - } else { - confirmationValueControl.disable(); - absenceTypeIdControl.disable(); - } + this.formGroup$.pipe(take(1)).subscribe((formGroup) => { + const categoryControl = formGroup.get('category'); + const confirmationValueControl = formGroup.get('confirmationValue'); + const absenceTypeIdControl = formGroup.get('absenceTypeId'); + const incidentIdControl = formGroup.get('incidentId'); + if ( + categoryControl && + confirmationValueControl && + absenceTypeIdControl && + incidentIdControl + ) { + if (categoryControl.value === Category.Absent) { + confirmationValueControl.enable(); + this.updateAbsenceTypeIdDisabled(); + } else { + confirmationValueControl.disable(); + absenceTypeIdControl.disable(); + } - if (categoryControl.value === Category.Incident) { - incidentIdControl.enable(); - } else { - incidentIdControl.disable(); + if (categoryControl.value === Category.Incident) { + incidentIdControl.enable(); + } else { + incidentIdControl.disable(); + } } - } + }); } private updateAbsenceTypeIdDisabled(): void { - const confirmationValueControl = this.formGroup.get('confirmationValue'); - const absenceTypeIdControl = this.formGroup.get('absenceTypeId'); - if (confirmationValueControl && absenceTypeIdControl) { - confirmationValueControl.value === this.settings.excusedAbsenceStateId - ? absenceTypeIdControl.enable() - : absenceTypeIdControl.disable(); - } + this.formGroup$.pipe(take(1)).subscribe((formGroup) => { + const confirmationValueControl = formGroup.get('confirmationValue'); + const absenceTypeIdControl = formGroup.get('absenceTypeId'); + if (confirmationValueControl && absenceTypeIdControl) { + confirmationValueControl.value === this.settings.excusedAbsenceStateId + ? absenceTypeIdControl.enable() + : absenceTypeIdControl.disable(); + } + }); } - private fetchAndProcessFormValues(): { - category: Category; - confirmationValue: Option; - absenceTypeId: number; - } { - // tslint:disable:prefer-const - let { + private save(formGroup: FormGroup): void { + this.saving$.next(true); + const { category, confirmationValue, absenceTypeId, incidentId, - } = this.formGroup.value; - // tslint:enable:prefer-const - switch (category) { - case Category.Absent: - if (confirmationValue !== this.settings.excusedAbsenceStateId) { - absenceTypeId = this.settings.absencePresenceTypeId; - } - break; - case Category.Dispensation: - absenceTypeId = this.settings.dispensationPresenceTypeId; - confirmationValue = null; - break; - case Category.HalfDay: - absenceTypeId = this.settings.halfDayPresenceTypeId; - confirmationValue = null; - break; - case Category.Incident: - absenceTypeId = incidentId; - confirmationValue = null; - break; - } - return { - category, - confirmationValue, - absenceTypeId, - }; - } - - private save( - category: Category, - confirmationValue: Option, - absenceTypeId: number - ): void { - let requests: ReadonlyArray> = []; - this.saving$.next(true); - - if (category === Category.Present) { - requests = this.createResetBulkRequests(); - } else { - requests = this.createEditBulkRequests(confirmationValue, absenceTypeId); - } - combineLatest(requests) // tslint:disable-line - .pipe(finalize(() => this.saving$.next(false))) - .subscribe(this.onSaveSuccess.bind(this)); - } - - private createResetBulkRequests(): ReadonlyArray> { - return this.state.selected.map(({ lessonIds, personIds }) => - this.updateService.removeLessonPresences(lessonIds, personIds) - ); - } - - private createEditBulkRequests( - confirmationValue: Option, - absenceTypeId: number - ): ReadonlyArray> { - return this.state.selected.map(({ lessonIds, personIds }) => - this.updateService.editLessonPresences( - lessonIds, - personIds, - absenceTypeId, - confirmationValue || undefined + } = formGroup.value; + this.presenceTypesService.presenceTypes$ + .pipe( + switchMap((presenceTypes) => + this.updateService.update( + this.state.selected, + presenceTypes, + category, + confirmationValue, + absenceTypeId, + incidentId + ) + ), + finalize(() => this.saving$.next(false)) ) - ); + .subscribe(this.onSaveSuccess.bind(this)); } private onSaveSuccess(): void { diff --git a/src/app/edit-absences/components/edit-absences-list/edit-absences-list.component.ts b/src/app/edit-absences/components/edit-absences-list/edit-absences-list.component.ts index 10930606e..6a1b83f67 100644 --- a/src/app/edit-absences/components/edit-absences-list/edit-absences-list.component.ts +++ b/src/app/edit-absences/components/edit-absences-list/edit-absences-list.component.ts @@ -51,9 +51,9 @@ export class EditAbsencesListComponent .subscribe(() => this.selectionService.clear()); // Remember selected entries - this.selectionService.selectedIds$ + this.selectionService.selection$ .pipe(takeUntil(this.destroy$)) - .subscribe((ids) => (this.state.selected = ids)); + .subscribe((selected) => (this.state.selected = selected)); // Reload entries for current filter when ?reload=true this.route.queryParams diff --git a/src/app/edit-absences/services/edit-absences-selection.service.ts b/src/app/edit-absences/services/edit-absences-selection.service.ts index 401aaf0d6..68d26edb1 100644 --- a/src/app/edit-absences/services/edit-absences-selection.service.ts +++ b/src/app/edit-absences/services/edit-absences-selection.service.ts @@ -1,13 +1,35 @@ import { Injectable } from '@angular/core'; -import { map } from 'rxjs/operators'; +import { Observable } from 'rxjs'; +import { map, switchMap } from 'rxjs/operators'; +import { uniq } from 'lodash-es'; import { SelectionService } from 'src/app/shared/services/selection.service'; import { LessonPresence } from 'src/app/shared/models/lesson-presence.model'; import { getIdsGroupedByPerson } from 'src/app/shared/utils/lesson-presences'; +import { PresenceType } from 'src/app/shared/models/presence-type.model'; +import { PresenceTypesService } from 'src/app/shared/services/presence-types.service'; @Injectable() export class EditAbsencesSelectionService extends SelectionService< LessonPresence > { - selectedIds$ = this.selection$.pipe(map(getIdsGroupedByPerson)); + // selectedIds$ = this.selection$.pipe(map(getIdsGroupedByPerson)); + // // Presence types that are referenced by the selected entries + // selectedPresenceTypes$ = this.selection$.pipe( + // switchMap(this.getDistinctPresenceTypes.bind(this)) + // ); + // getDistinctPresenceTypes( + // lessonPresences: ReadonlyArray + // ): Observable> { + // return this.presenceTypesService.presenceTypes$.pipe( + // map((presenceTypes) => { + // const presenceTypeIds = uniq( + // lessonPresences + // .filter((p) => p.TypeRef.Id != null) + // .map((p) => p.TypeRef.Id) + // ); + // return presenceTypes.filter((t) => presenceTypeIds.includes(t.Id)); + // }) + // ); + // } } diff --git a/src/app/edit-absences/services/edit-absences-state.service.ts b/src/app/edit-absences/services/edit-absences-state.service.ts index 7d2c446d3..a1ce15735 100644 --- a/src/app/edit-absences/services/edit-absences-state.service.ts +++ b/src/app/edit-absences/services/edit-absences-state.service.ts @@ -52,10 +52,7 @@ export class EditAbsencesStateService this.absenceConfirmationStates$, ]).pipe(map(spread(buildPresenceControlEntries)), shareReplay(1)); - selected: ReadonlyArray<{ - lessonIds: ReadonlyArray; - personIds: ReadonlyArray; - }> = []; + selected: ReadonlyArray = []; constructor( location: Location, diff --git a/src/app/edit-absences/services/edit-absences-update.service.spec.ts b/src/app/edit-absences/services/edit-absences-update.service.spec.ts new file mode 100644 index 000000000..93c45e172 --- /dev/null +++ b/src/app/edit-absences/services/edit-absences-update.service.spec.ts @@ -0,0 +1,17 @@ +import { TestBed } from '@angular/core/testing'; + +import { EditAbsencesUpdateService } from './edit-absences-update.service'; +import { buildTestModuleMetadata } from 'src/spec-helpers'; + +describe('EditAbsencesUpdateService', () => { + let service: EditAbsencesUpdateService; + + beforeEach(() => { + TestBed.configureTestingModule(buildTestModuleMetadata({})); + service = TestBed.inject(EditAbsencesUpdateService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/app/edit-absences/services/edit-absences-update.service.ts b/src/app/edit-absences/services/edit-absences-update.service.ts new file mode 100644 index 000000000..88c9f6fc0 --- /dev/null +++ b/src/app/edit-absences/services/edit-absences-update.service.ts @@ -0,0 +1,151 @@ +import { Injectable, Inject } from '@angular/core'; +import { Observable, combineLatest } from 'rxjs'; +import { mapTo } from 'rxjs/operators'; + +import { LessonPresencesUpdateRestService } from 'src/app/shared/services/lesson-presences-update-rest.service'; +import { LessonPresence } from 'src/app/shared/models/lesson-presence.model'; +import { getIdsGroupedByPerson } from 'src/app/shared/utils/lesson-presences'; +import { SETTINGS, Settings } from 'src/app/settings'; +import { PresenceType } from 'src/app/shared/models/presence-type.model'; +import { not } from 'src/app/shared/utils/filter'; + +export enum Category { + Absent = 'absent', + Dispensation = 'dispensation', + HalfDay = 'half-day', + Incident = 'incident', + Present = 'present', +} + +@Injectable({ + providedIn: 'root', +}) +export class EditAbsencesUpdateService { + constructor( + private updateService: LessonPresencesUpdateRestService, + @Inject(SETTINGS) private settings: Settings + ) {} + + update( + entries: ReadonlyArray, + presenceTypes: ReadonlyArray, + category: Category, + confirmationValue: Option, + absenceTypeId: Option, + incidentId: Option + ): Observable { + let requests: ReadonlyArray> = []; + switch (category) { + case Category.Present: + requests = this.createResetBulkRequests(entries); + break; + case Category.Absent: + requests = this.createAbsentEditBulkRequests( + entries, + presenceTypes, + confirmationValue, + absenceTypeId + ); + break; + case Category.Dispensation: + requests = this.createEditBulkRequests( + entries, + null, + this.settings.dispensationPresenceTypeId + ); + break; + case Category.HalfDay: + requests = this.createEditBulkRequests( + entries, + null, + this.settings.halfDayPresenceTypeId + ); + break; + case Category.Incident: + requests = this.createEditBulkRequests(entries, null, incidentId); + break; + } + + return combineLatest(requests).pipe(mapTo(undefined)); + } + + private createAbsentEditBulkRequests( + entries: ReadonlyArray, + presenceTypes: ReadonlyArray, + confirmationValue: Option, + absenceTypeId: Option + ): ReadonlyArray> { + if (confirmationValue === this.settings.excusedAbsenceStateId) { + // Update all entries to the absence type selected by the user + return this.createEditBulkRequests( + entries, + confirmationValue, + absenceTypeId + ); + } else if (confirmationValue === this.settings.unexcusedAbsenceStateId) { + // Update all entries to the default absence type (possibly + // overriding the existing absence type) + return this.createEditBulkRequests( + entries, + confirmationValue, + this.settings.absencePresenceTypeId + ); + } else { + return [ + // Update presences, dispensations, half days an incidents + // to the default absence type + ...this.createEditBulkRequests( + entries.filter(overrideAbsenceType(presenceTypes, this.settings)), + confirmationValue, + this.settings.absencePresenceTypeId + ), + // Keep the existing absence type for all other entries + ...this.createEditBulkRequests( + entries.filter( + not(overrideAbsenceType(presenceTypes, this.settings)) + ), + confirmationValue, + null + ), + ]; + } + } + + private createResetBulkRequests( + entries: ReadonlyArray + ): ReadonlyArray> { + return getIdsGroupedByPerson(entries).map(({ lessonIds, personIds }) => + this.updateService.removeLessonPresences(lessonIds, personIds) + ); + } + + private createEditBulkRequests( + entries: ReadonlyArray, + confirmationValue: Option, + absenceTypeId: Option + ): ReadonlyArray> { + return getIdsGroupedByPerson(entries).map(({ lessonIds, personIds }) => + this.updateService.editLessonPresences( + lessonIds, + personIds, + absenceTypeId || undefined, + confirmationValue || undefined + ) + ); + } +} + +function overrideAbsenceType( + presenceTypes: ReadonlyArray, + settings: Settings +): (entry: LessonPresence) => boolean { + return (entry) => { + const presenceType = presenceTypes.find((t) => t.Id === entry.TypeRef.Id); + return ( + !presenceType || + presenceType.Id === settings.dispensationPresenceTypeId || + presenceType.Id === settings.halfDayPresenceTypeId || + presenceType.IsIncident + ); + }; +} diff --git a/src/app/my-absences/components/my-absences-confirm/my-absences-abstract-confirm.component.ts b/src/app/my-absences/components/my-absences-confirm/my-absences-abstract-confirm.component.ts index 9ffe270ea..1a2443380 100644 --- a/src/app/my-absences/components/my-absences-confirm/my-absences-abstract-confirm.component.ts +++ b/src/app/my-absences/components/my-absences-confirm/my-absences-abstract-confirm.component.ts @@ -4,15 +4,7 @@ import { Router } from '@angular/router'; import { ToastrService } from 'ngx-toastr'; import { TranslateService } from '@ngx-translate/core'; import { BehaviorSubject, combineLatest, Observable, Subject, of } from 'rxjs'; -import { - finalize, - map, - filter, - startWith, - switchMap, - take, - pluck, -} from 'rxjs/operators'; +import { finalize, map, filter, switchMap, take, pluck } from 'rxjs/operators'; import { LessonPresencesUpdateRestService } from 'src/app/shared/services/lesson-presences-update-rest.service'; import { PresenceTypesService } from 'src/app/shared/services/presence-types.service'; @@ -41,13 +33,10 @@ export abstract class MyAbsencesAbstractConfirmComponent ) ); - absenceTypeIdErrors$ = combineLatest([ - getValidationErrors(this.formGroup.get('absenceTypeId')), + absenceTypeIdErrors$ = getValidationErrors( + of(this.formGroup), this.submitted$, - ]).pipe( - filter((v) => v[1]), - map((v) => v[0]), - startWith([]) + 'absenceTypeId' ); abstract selectedLessonIds$: Observable>; diff --git a/src/app/my-profile/components/my-profile-edit/my-profile-edit.component.ts b/src/app/my-profile/components/my-profile-edit/my-profile-edit.component.ts index 8352dd2cf..f82ea51ee 100644 --- a/src/app/my-profile/components/my-profile-edit/my-profile-edit.component.ts +++ b/src/app/my-profile/components/my-profile-edit/my-profile-edit.component.ts @@ -3,7 +3,7 @@ import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { Router } from '@angular/router'; import { ToastrService } from 'ngx-toastr'; import { TranslateService } from '@ngx-translate/core'; -import { BehaviorSubject, combineLatest } from 'rxjs'; +import { BehaviorSubject } from 'rxjs'; import { pluck, map, @@ -11,14 +11,12 @@ import { switchMap, finalize, shareReplay, - filter, - startWith, } from 'rxjs/operators'; import { Person } from 'src/app/shared/models/person.model'; import { MyProfileService } from '../../services/my-profile.service'; import { PersonsRestService } from 'src/app/shared/services/persons-rest.service'; -import { getControlValidationErrors } from 'src/app/shared/utils/form'; +import { getValidationErrors } from 'src/app/shared/utils/form'; @Component({ selector: 'erz-my-profile-edit', @@ -36,14 +34,10 @@ export class MyProfileEditComponent implements OnInit { saving$ = new BehaviorSubject(false); private submitted$ = new BehaviorSubject(false); - email2Errors$ = combineLatest([ - this.formGroup$.pipe(switchMap(getControlValidationErrors('email2'))), + email2Errors$ = getValidationErrors( + this.formGroup$, this.submitted$, - ]).pipe( - filter((v) => v[1]), - map((v) => v[0]), - startWith([]), - shareReplay(1) + 'email2' ); constructor( diff --git a/src/app/shared/components/confirm-absences/confirm-absences.component.ts b/src/app/shared/components/confirm-absences/confirm-absences.component.ts index 0e4a3b1f3..73ae5a443 100644 --- a/src/app/shared/components/confirm-absences/confirm-absences.component.ts +++ b/src/app/shared/components/confirm-absences/confirm-absences.component.ts @@ -26,7 +26,6 @@ import { TranslateService } from '@ngx-translate/core'; import { notNull } from 'src/app/shared/utils/filter'; import { getValidationErrors, - getControlValidationErrors, getControl, getControlValueChanges, } from 'src/app/shared/utils/form'; @@ -57,24 +56,11 @@ export class ConfirmAbsencesComponent implements OnInit, OnDestroy { saving$ = new BehaviorSubject(false); private submitted$ = new BehaviorSubject(false); - formErrors$ = combineLatest([ - this.formGroup$.pipe(switchMap(getValidationErrors)), + formErrors$ = getValidationErrors(this.formGroup$, this.submitted$); + absenceTypeIdErrors$ = getValidationErrors( + this.formGroup$, this.submitted$, - ]).pipe( - filter((v) => v[1]), - map((v) => v[0]), - startWith([]) - ); - - absenceTypeIdErrors$ = combineLatest([ - this.formGroup$.pipe( - switchMap(getControlValidationErrors('absenceTypeId')) - ), - this.submitted$, - ]).pipe( - filter((v) => v[1]), - map((v) => v[0]), - startWith([]) + 'absenceTypeId' ); private confirmationStates$ = this.dropDownItemsService diff --git a/src/app/shared/utils/form.spec.ts b/src/app/shared/utils/form.spec.ts new file mode 100644 index 000000000..245197f08 --- /dev/null +++ b/src/app/shared/utils/form.spec.ts @@ -0,0 +1,148 @@ +import { FormGroup, FormControl, Validators } from '@angular/forms'; +import { Subject } from 'rxjs'; + +import { + getControl, + getControlValueChanges, + getValidationErrors, +} from './form'; + +describe('form utils', () => { + let formGroup$: Subject; + let submitted$: Subject; + let callback: jasmine.Spy; + + beforeEach(() => { + formGroup$ = new Subject(); + submitted$ = new Subject(); + callback = jasmine.createSpy('callback'); + }); + + describe('getValidationErrors', () => { + it('returns an observable that emits errors on the form itself', () => { + getValidationErrors(formGroup$, submitted$).subscribe(callback); + expect(callback).toHaveBeenCalledWith([]); + + callback.calls.reset(); + const formGroup = buildFormGroup(); + formGroup$.next(formGroup); + expect(callback).not.toHaveBeenCalled(); + + callback.calls.reset(); + formGroup.setErrors({ required: true }); + expect(callback).not.toHaveBeenCalled(); + + callback.calls.reset(); + submitted$.next(true); + expect(callback).toHaveBeenCalledWith([ + { error: 'required', params: null }, + ]); + + callback.calls.reset(); + formGroup.setErrors(null); + expect(callback).toHaveBeenCalledWith([]); + + callback.calls.reset(); + const anotherFormGroup = buildFormGroup(); + formGroup$.next(anotherFormGroup); + anotherFormGroup.setErrors({ min: { min: 3, actual: 2 } }); + expect(callback).toHaveBeenCalledWith([ + { error: 'min', params: { min: 3, actual: 2 } }, + ]); + }); + + it('returns an observable that emits errors on the form control with the given name', () => { + getValidationErrors(formGroup$, submitted$, 'foo').subscribe(callback); + expect(callback).toHaveBeenCalledWith([]); + + callback.calls.reset(); + const formGroup = buildFormGroup(); + formGroup$.next(formGroup); + expect(callback).not.toHaveBeenCalled(); + + callback.calls.reset(); + formGroup.get('foo')?.setValue(''); + expect(callback).not.toHaveBeenCalled(); + + callback.calls.reset(); + submitted$.next(true); + expect(callback).toHaveBeenCalledWith([ + { error: 'required', params: null }, + ]); + + callback.calls.reset(); + formGroup.get('foo')?.setValue('456'); + expect(callback).toHaveBeenCalledWith([]); + + callback.calls.reset(); + formGroup.get('bar')?.setErrors({ required: true }); + expect(callback).not.toHaveBeenCalled(); + + callback.calls.reset(); + const anotherFormGroup = buildFormGroup(); + formGroup$.next(anotherFormGroup); + anotherFormGroup.get('foo')?.setValue(''); + expect(callback).toHaveBeenCalledWith([ + { error: 'required', params: null }, + ]); + }); + }); + + describe('getControl', () => { + it('returns an observable that emits the form control with given name', () => { + getControl(formGroup$, 'foo').subscribe(callback); + expect(callback).not.toHaveBeenCalled(); + + const formGroup = buildFormGroup(); + formGroup$.next(formGroup); + expect(callback).toHaveBeenCalledWith(formGroup.get('foo')); + + callback.calls.reset(); + const anotherFormGroup = buildFormGroup(); + formGroup$.next(anotherFormGroup); + expect(callback).not.toHaveBeenCalledWith(formGroup.get('foo')); + expect(callback).toHaveBeenCalledWith(anotherFormGroup.get('foo')); + }); + + it('returns an observable that emits nothing if no control with given name exists', () => { + getControl(formGroup$, 'baz').subscribe(callback); + expect(callback).not.toHaveBeenCalled(); + + const formGroup = buildFormGroup(); + formGroup$.next(formGroup); + expect(callback).toHaveBeenCalledWith(null); + }); + }); + + describe('getControlValueChanges', () => { + it("returns an observable that emits a form control's values", () => { + getControlValueChanges(formGroup$, 'foo').subscribe(callback); + expect(callback).not.toHaveBeenCalled(); + + const formGroup = buildFormGroup(); + formGroup$.next(formGroup); + expect(callback).not.toHaveBeenCalled(); + + formGroup.get('foo')?.setValue('456'); + expect(callback).toHaveBeenCalledWith('456'); + + callback.calls.reset(); + const anotherFormGroup = buildFormGroup(); + formGroup$.next(anotherFormGroup); + expect(callback).not.toHaveBeenCalled(); + + formGroup.get('foo')?.setValue('789'); + expect(callback).not.toHaveBeenCalled(); + + anotherFormGroup.get('foo')?.setValue('999'); + expect(callback).toHaveBeenCalledWith('999'); + }); + }); +}); + +function buildFormGroup(): FormGroup { + return new FormGroup({ + foo: new FormControl('123', Validators.required), + bar: new FormControl(), + }); +} diff --git a/src/app/shared/utils/form.ts b/src/app/shared/utils/form.ts index 42518fb30..6bd578c6b 100644 --- a/src/app/shared/utils/form.ts +++ b/src/app/shared/utils/form.ts @@ -1,50 +1,43 @@ import { AbstractControl, FormGroup } from '@angular/forms'; -import { Observable, of, empty } from 'rxjs'; -import { startWith, map, switchMap } from 'rxjs/operators'; - -export function getValidationErrors( - control: Option -): Observable> { - if (control) { - return control.statusChanges.pipe( - startWith(control.status), - map(() => validatationErrorsToArray(control)) - ); - } - return of([]); -} +import { Observable, of, empty, combineLatest } from 'rxjs'; +import { startWith, map, switchMap, filter, shareReplay } from 'rxjs/operators'; /** - * Emits the validation errors of the control with the given name. + * Emits the validation errors of the form group or the control with + * the given name. * - * Example: - * emailErrors$ = this.formGroup$.pipe( - * switchMap(getControlValidationErrors('email')) + * Examples: + * genericFormErrors$ = getControlValidationErrors( + * this.formGroup$, + * this.submitted$ + * ); + * + * emailErrors$ = getControlValidationErrors( + * this.formGroup$, + * this.submitted$, + * 'email' * ); */ -export function getControlValidationErrors( - controlName: string -): ( - group: Option -) => Observable> { - return (group) => { - return getValidationErrors(group?.get(controlName) || null); - }; -} - -function validatationErrorsToArray( - control: AbstractControl | null -): ReadonlyArray<{ error: string; params: any }> { - if (!control) { - return []; - } - return Object.keys(control.errors || {}).map((e) => ({ - error: e, - params: - control.errors && control.errors[e] instanceof Object - ? control.errors[e] - : null, - })); +export function getValidationErrors( + formGroup$: Observable>, + submitted$: Observable, + controlName?: string +): Observable> { + return combineLatest([formGroup$, submitted$]).pipe( + filter(([_, submitted]) => submitted), + switchMap(([group, _]) => { + const control = controlName ? group?.get(controlName) || null : group; + if (control) { + return control.statusChanges.pipe( + startWith(control.status), + map(() => validatationErrorsToArray(control)) + ); + } + return of([]); + }), + startWith([]), + shareReplay(1) + ); } export function getControl( @@ -67,3 +60,18 @@ export function getControlValueChanges( switchMap((control) => (control ? control.valueChanges : empty())) ); } + +function validatationErrorsToArray( + control: AbstractControl | null +): ReadonlyArray<{ error: string; params: any }> { + if (!control) { + return []; + } + return Object.keys(control.errors || {}).map((e) => ({ + error: e, + params: + control.errors && control.errors[e] instanceof Object + ? control.errors[e] + : null, + })); +}