From b0dccbcf551a580fac4dcdd60bad6c5e2114d87e Mon Sep 17 00:00:00 2001 From: Jane Sandberg Date: Sun, 2 Jun 2019 15:21:45 -0700 Subject: [PATCH] LP1816475: Turning Create Reservation controls into tabbed reactive form Signed-off-by: Jane Sandberg --- .../patron_barcode_validator.directive.spec.ts | 43 +++ .../patron_barcode_validator.directive.ts | 56 ++++ .../eg2/src/app/staff/booking/booking.module.ts | 2 + .../create-reservation-dialog.component.html | 46 ++++ .../booking/create-reservation-dialog.component.ts | 78 ++++++ .../booking/create-reservation.component.html | 299 ++++++++++----------- .../staff/booking/create-reservation.component.ts | 156 ++++------- .../booking/manage-reservations.component.html | 2 +- Open-ILS/src/eg2/src/app/staff/common.module.ts | 4 + 9 files changed, 421 insertions(+), 265 deletions(-) create mode 100644 Open-ILS/src/eg2/src/app/share/validators/patron_barcode_validator.directive.spec.ts create mode 100644 Open-ILS/src/eg2/src/app/share/validators/patron_barcode_validator.directive.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/booking/create-reservation-dialog.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/booking/create-reservation-dialog.component.ts diff --git a/Open-ILS/src/eg2/src/app/share/validators/patron_barcode_validator.directive.spec.ts b/Open-ILS/src/eg2/src/app/share/validators/patron_barcode_validator.directive.spec.ts new file mode 100644 index 0000000000..1e1208e0cf --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/validators/patron_barcode_validator.directive.spec.ts @@ -0,0 +1,43 @@ +import {PatronBarcodeValidator} from './patron_barcode_validator.directive'; +import {of} from 'rxjs'; +import {NetService} from '@eg/core/net.service'; +import {AuthService} from '@eg/core/auth.service'; +import {EventService} from '@eg/core/event.service'; +import {StoreService} from '@eg/core/store.service'; + +let netService: NetService; +let authService: AuthService; +let evtService: EventService; +let storeService: StoreService; + +beforeEach(() => { + evtService = new EventService(); + storeService = new StoreService(null /* CookieService */); + netService = new NetService(evtService); + authService = new AuthService(evtService, netService, storeService); +}); + +describe('PatronBarcodeValidator', () => { + it('should not throw an error if there is exactly 1 match', () => { + const pbv = new PatronBarcodeValidator(authService, netService); + pbv['parseActorCall'](of(1)) + .subscribe((val) => { + expect(val).toBeNull(); + }); + }); + it('should throw an error if there is more than 1 match', () => { + const pbv = new PatronBarcodeValidator(authService, netService); + pbv['parseActorCall'](of(1, 2, 3)) + .subscribe((val) => { + expect(val).not.toBeNull(); + }); + }); + it('should throw an error if there is no match', () => { + const pbv = new PatronBarcodeValidator(authService, netService); + pbv['parseActorCall'](of()) + .subscribe((val) => { + expect(val).not.toBeNull(); + }); + }); +}); + diff --git a/Open-ILS/src/eg2/src/app/share/validators/patron_barcode_validator.directive.ts b/Open-ILS/src/eg2/src/app/share/validators/patron_barcode_validator.directive.ts new file mode 100644 index 0000000000..81d1b159b0 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/validators/patron_barcode_validator.directive.ts @@ -0,0 +1,56 @@ +import { Directive, forwardRef } from '@angular/core'; +import { NG_VALIDATORS, NG_ASYNC_VALIDATORS, AbstractControl, ValidationErrors, AsyncValidator, FormControl } from '@angular/forms'; +import {NetService} from '@eg/core/net.service'; +import {AuthService} from '@eg/core/auth.service'; +import {EmptyError, Observable, of} from 'rxjs'; +import {single, switchMap, catchError} from 'rxjs/operators'; +import {Injectable} from '@angular/core'; + +@Injectable({providedIn: 'root'}) +export class PatronBarcodeValidator implements AsyncValidator { + constructor( + private auth: AuthService, + private net: NetService) { + } + + validate = (control: FormControl) => { + return this.parseActorCall(this.net.request( + 'open-ils.actor', + 'open-ils.actor.get_barcodes', + this.auth.token(), + this.auth.user().ws_ou(), + 'actor', control.value)); + } + + private parseActorCall = (actorCall: Observable) => { + return actorCall + .pipe(single(), + switchMap(() => of(null)), + catchError((err) => { + if (err instanceof EmptyError) { + return of({ patronBarcode: 'No patron found with that barcode' }); + } else if ('Sequence contains more than one element' === err) { + return of({ patronBarcode: 'Barcode matches more than one patron' }); + } + })); + } +} + +@Directive({ + selector: '[egValidPatronBarcode]', + providers: [{ + provide: NG_ASYNC_VALIDATORS, + useExisting: forwardRef(() => PatronBarcodeValidator), + multi: true + }] +}) +export class PatronBarcodeValidatorDirective { + constructor( + private pbv: PatronBarcodeValidator + ) { } + + validate = (control: FormControl) => { + this.pbv.validate(control); + } +} + diff --git a/Open-ILS/src/eg2/src/app/staff/booking/booking.module.ts b/Open-ILS/src/eg2/src/app/staff/booking/booking.module.ts index df56a570bc..9ebce77c1e 100644 --- a/Open-ILS/src/eg2/src/app/staff/booking/booking.module.ts +++ b/Open-ILS/src/eg2/src/app/staff/booking/booking.module.ts @@ -3,6 +3,7 @@ import {ReactiveFormsModule} from '@angular/forms'; import {StaffCommonModule} from '@eg/staff/common.module'; import {BookingRoutingModule} from './routing.module'; import {CreateReservationComponent} from './create-reservation.component'; +import {CreateReservationDialogComponent} from './create-reservation-dialog.component'; import {ManageReservationsComponent} from './manage-reservations.component'; import {OrgSelectWithDescendantsComponent} from './org-select-with-descendants.component'; import {ReservationsGridComponent} from './reservations-grid.component'; @@ -23,6 +24,7 @@ import {PatronService} from '@eg/staff/share/patron.service'; providers: [PatronService], declarations: [ CreateReservationComponent, + CreateReservationDialogComponent, ManageReservationsComponent, NoTimezoneSetComponent, OrgSelectWithDescendantsComponent, diff --git a/Open-ILS/src/eg2/src/app/staff/booking/create-reservation-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/booking/create-reservation-dialog.component.html new file mode 100644 index 0000000000..a1b40a5890 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/booking/create-reservation-dialog.component.html @@ -0,0 +1,46 @@ + + + + + + + diff --git a/Open-ILS/src/eg2/src/app/staff/booking/create-reservation-dialog.component.ts b/Open-ILS/src/eg2/src/app/staff/booking/create-reservation-dialog.component.ts new file mode 100644 index 0000000000..0157bd90bf --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/booking/create-reservation-dialog.component.ts @@ -0,0 +1,78 @@ +import {Component, Input, OnInit, ViewChild} from '@angular/core'; +import {FormGroup, FormControl, Validators} from '@angular/forms'; +import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; +import {AuthService} from '@eg/core/auth.service'; +import {DialogComponent} from '@eg/share/dialog/dialog.component'; +import {NetService} from '@eg/core/net.service'; +import {PatronBarcodeValidator} from '@eg/share/validators/patron_barcode_validator.directive'; +import {ToastService} from '@eg/share/toast/toast.service'; +import {AlertDialogComponent} from '@eg/share/dialog/alert.component'; + +import * as Moment from 'moment-timezone'; + +@Component({ + selector: 'eg-create-reservation-dialog', + templateUrl: './create-reservation-dialog.component.html' +}) + +export class CreateReservationDialogComponent + extends DialogComponent implements OnInit { + + constructor( + private auth: AuthService, + private net: NetService, + private modal: NgbModal, + private pbv: PatronBarcodeValidator, + private toast: ToastService + ) { + super(modal); + } + + create: FormGroup; + + addBresv: () => void; + + @Input() startTime: Moment; + @Input() endTime: Moment; + @Input() targetResource: number; + @Input() targetResourceBarcode: string; + @Input() attributes: any[]; + + @ViewChild('fail') private fail: AlertDialogComponent; + + ngOnInit() { + + this.create = new FormGroup({ + 'patronBarcode': new FormControl('', + [ Validators.required ], + [this.pbv.validate] + ), + 'emailNotify': new FormControl(true), + }); + + this.addBresv = () => { + this.net.request( + 'open-ils.booking', + 'open-ils.booking.reservations.create', + this.auth.token(), + '99999382659', // patron barcode + ['2019-09-09 10:00', '2019-09-09 14:00'], // start/end + 7, // pickup lib + 555, // brt + this.targetResource ? [this.targetResource] : null, + [], // bravm + 0 // email + ).subscribe( + (success) => { + this.toast.success('Reservation successfully created'); + this.close(); + }, (fail) => { + console.warn(fail); + this.fail.open(); + } + ); + } + + } +} + diff --git a/Open-ILS/src/eg2/src/app/staff/booking/create-reservation.component.html b/Open-ILS/src/eg2/src/app/staff/booking/create-reservation.component.html index b4d459b71a..e36922c024 100644 --- a/Open-ILS/src/eg2/src/app/staff/booking/create-reservation.component.html +++ b/Open-ILS/src/eg2/src/app/staff/booking/create-reservation.component.html @@ -2,138 +2,167 @@ -
-
- - -
-
-
-
- -
-
- - -
- - +
+ + + + category + Choose resource by type + + +
+
+ + +
+
+
+
+ +
+ +
-
-
-
-
-
- -
- - -
-
-
-
-
- + + + + + + assignment + Choose resource by barcode + + +
+
+
+
+ +
+ +
+
- -
-
-
-
-
- + + + + + + calendar_today + Select dates - () selected + + +
+
+
+
+ +
+ +
+
+
+
+
+ +
+ + +
+
- -
-
-
-
- + + -
-
-
Display options
-
    -
  • - - - + + + filter_list + Limit by attributes + + +
      +
    • + + + + + + + + - - -
    • -
    • - - - +
    • +
    +
    +
    + + + + settings + Schedule settings + + +
      +
    • + + + + + - - -
    • -
    • - - - +
    • +
    • + + + + + - - - - - - -
    • -
    -
-
-
Filter by attributes
-
    -
  • - - - +
  • +
  • + + + + + + + + + - - - - - -
  • -
-
-
+ + + + + + + + - + - - + + @@ -144,51 +173,3 @@ - - - - - -
-
- - -
-
-
- - - - - - - - - -
Pickup library uses a different timezone than your library does. Please choose times in the pickup library's timezone.
-
- - - - - - - - - - - - - - - diff --git a/Open-ILS/src/eg2/src/app/staff/booking/create-reservation.component.ts b/Open-ILS/src/eg2/src/app/staff/booking/create-reservation.component.ts index c2fce8683c..015e9bc553 100644 --- a/Open-ILS/src/eg2/src/app/staff/booking/create-reservation.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/booking/create-reservation.component.ts @@ -1,13 +1,13 @@ import {Component, Input, OnInit, AfterViewInit, QueryList, ViewChildren, ViewChild} from '@angular/core'; +import {FormGroup, FormControl} from "@angular/forms"; import {Router, ActivatedRoute, ParamMap} from '@angular/router'; -import {forkJoin} from 'rxjs'; -import {single} from 'rxjs/operators'; +import {forkJoin, of, timer} from 'rxjs'; +import {catchError, debounceTime, mapTo, single, switchMap} from 'rxjs/operators'; import {NgbDateStruct, NgbTimeStruct} from '@ng-bootstrap/ng-bootstrap'; import {AuthService} from '@eg/core/auth.service'; import {ComboboxEntry} from '@eg/share/combobox/combobox.component'; import {DateSelectComponent} from '@eg/share/date-select/date-select.component'; import {DateRangeSelectComponent} from '@eg/share/daterange-select/daterange-select.component'; -import {FmRecordEditorComponent} from '@eg/share/fm-editor/fm-editor.component'; import {FormatService} from '@eg/core/format.service'; import {GridComponent} from '@eg/share/grid/grid.component'; import {GridDataSource, GridRowFlairEntry} from '@eg/share/grid/grid'; @@ -16,6 +16,7 @@ import {NetService} from '@eg/core/net.service'; import {OrgService} from '@eg/core/org.service'; import {PatronService} from '@eg/staff/share/patron.service'; import {PcrudService} from '@eg/core/pcrud.service'; +import {CreateReservationDialogComponent} from './create-reservation-dialog.component'; import {ResourceTypeComboboxComponent} from './resource-type-combobox.component'; import {ServerStoreService} from '@eg/core/server-store.service'; import {ToastService} from '@eg/share/toast/toast.service'; @@ -30,7 +31,8 @@ import * as Moment from 'moment-timezone'; export class CreateReservationComponent implements OnInit, AfterViewInit { - advancedCollapsed = true; + criteria: FormGroup; + attributes: IdlObject[] = []; selectedAttributes: number[] = []; multiday = false; @@ -60,12 +62,10 @@ export class CreateReservationComponent implements OnInit, AfterViewInit { minuteStep: () => number; - openCreateDialog: (rows: IdlObject[]) => void; openTheDialog: (rows: IdlObject[]) => any; resources: IdlObject[] = []; limitByAttr: (attributeId: number, $event: ComboboxEntry) => void; - useCurrentResourceBarcode: () => void; findPatronByBarcode: () => void; setGranularity: () => void; @@ -78,8 +78,8 @@ export class CreateReservationComponent implements OnInit, AfterViewInit { @ViewChildren('dateLimiter') dateLimiters: QueryList; @ViewChildren('dateRangeLimiter') dateRangeLimiters: QueryList; @ViewChildren('scheduleGrid') scheduleGrids: QueryList; - @ViewChild('newDialog') newDialog: FmRecordEditorComponent; @ViewChild('rt') rt: ResourceTypeComboboxComponent; + @ViewChild('createDialog') createDialog: CreateReservationDialogComponent; idealDate = new Date(); @@ -155,6 +155,37 @@ export class CreateReservationComponent implements OnInit, AfterViewInit { } }); + this.criteria = new FormGroup({ + 'resourceBarcode': new FormControl(this.resourceBarcode ? this.resourceBarcode : '', + [], (rb) => + timer(800).pipe(switchMap(() => + this.pcrud.search('brsrc', + {'barcode' : rb.value}, + {'limit': 1})), + single(), + mapTo(null), + catchError(() => of({ resourceBarcode: 'No resource found with that barcode' })) + )), + 'startOfDay': new FormControl(this.startOfDay), + 'endOfDay': new FormControl(this.endOfDay), + 'reservationType': new FormControl(this.multiday ? 'multi' : 'single'), + }); + + this.criteria.get('resourceBarcode').valueChanges + .pipe(debounceTime(1000)) + .subscribe((barcode) => { + if ('INVALID' === this.criteria.get('resourceBarcode').status) { + this.toast.danger('No resource found with this barcode'); + } else { + this.router.navigate(['/staff', 'booking', 'create_reservation', 'for_resource', barcode]); + } + }); + + this.criteria.get('reservationType').valueChanges.subscribe((val) => + this.store.setItem('eg.booking.create.multiday', ('multi' === val))); + + this.criteria.valueChanges.subscribe(() => { this.fetchData(); }); + this.limitByAttr = (attributeId: number, $event: ComboboxEntry) => { this.selectedAttributes[attributeId] = $event.id; this.fetchData(); @@ -196,18 +227,6 @@ export class CreateReservationComponent implements OnInit, AfterViewInit { this.fetchData(); }; - this.handleMultiDayReservation = () => { - this.multiday = true; - this.store.setItem('eg.booking.create.multiday', true); - this.fetchData(); - }; - - this.handleSingleDayReservation = () => { - this.multiday = false; - this.store.setItem('eg.booking.create.multiday', false); - this.handleDateChange(new Date()); - }; - this.changeGranularity = ($event) => { this.granularity = $event.id; this.store.setItem('eg.booking.create.granularity', $event.id) @@ -215,7 +234,6 @@ export class CreateReservationComponent implements OnInit, AfterViewInit { }; this.handlePickupLibChange = ($event) => { - this.newDialog.record.pickup_lib($event); this.org.settings('lib.timezone', $event.id()).then((tz) => { if (tz['lib.timezone'] && (this.format.wsOrgTimezone !== tz['lib.timezone'])) { this.pickupLibUsesDifferentTz = tz['lib.timezone']; @@ -225,28 +243,6 @@ export class CreateReservationComponent implements OnInit, AfterViewInit { }); }; - this.handleTargetResourceChange = ($event) => { - if ('any' !== $event) { - this.newDialog.record.current_resource($event); - this.newDialog.record.target_resource($event); - } - }; - - this.useCurrentResourceBarcode = () => { - if (this.resourceBarcode) { - this.router.navigate(['/staff', 'booking', 'create_reservation', 'for_resource', this.resourceBarcode]); - } - }; - - this.findPatronByBarcode = () => { - if (this.patronBarcode) { - this.patron.bcSearch(this.patronBarcode).pipe(single()).subscribe( - resp => { this.newDialog.record.usr(resp[0].id); }, - err => { this.toast.danger('No patron found with this barcode'); }, - ); - } - }; - this.minuteStep = () => { return (this.granularity < 60) ? this.granularity : 30; }; @@ -258,7 +254,7 @@ export class CreateReservationComponent implements OnInit, AfterViewInit { this.fetchData(); this.openTheDialog = (rows: IdlObject[]) => { - return this.newDialog.open({size: 'lg'}).subscribe( + return this.createDialog.open({size: 'lg'}).subscribe( response => { this.toast.success('Reservation successfully created'); // TODO: needs i18n, pluralization this.fetchData(); @@ -267,63 +263,6 @@ export class CreateReservationComponent implements OnInit, AfterViewInit { ); }; - this.openCreateDialog = (rows: IdlObject[]) => { - if (rows.length) { - if (this.multiday) { - this.defaultTimes['start_time'] = this.format.momentizeDateString(rows[0]['time'], this.format.wsOrgTimezone); - this.defaultTimes['end_time'] = this.format.momentizeDateString( - rows[rows.length - 1]['time'], this.format.wsOrgTimezone).clone() - .add(this.granularity, 'minutes'); - } else { - this.defaultTimes['start_time'] = Moment.tz('' + - this.idealDate.getFullYear() + '-' + - (this.idealDate.getMonth() + 1) + '-' + - (this.idealDate.getDate()) + ' ' + rows[0]['time'], - 'YYYY-MM-DD LT', this.format.wsOrgTimezone); - this.defaultTimes['end_time'] = Moment.tz('' + - this.idealDate.getFullYear() + '-' + - (this.idealDate.getMonth() + 1) + '-' + - (this.idealDate.getDate()) + ' ' + rows[rows.length - 1]['time'], - 'YYYY-MM-DD LT', this.format.wsOrgTimezone).clone().add(this.granularity, 'minutes'); - } - } else { - if (this.multiday) { this.defaultTimes['end_time'] = this.defaultTimes['start_time'].clone().add(1, 'days'); } - } - if (this.resourceId && !this.resourceTypeId) { - this.pcrud.search('brsrc', {id: this.resourceId}, { - flesh: 1, - limit: 1, - flesh_fields: {'brsrc': ['type']} - }).subscribe( r => { - this.transferable = r.type().transferable(); - this.resourceTypeId = r.type().id(); - this.resourceOwner = r.owner(); - this.openTheDialog(rows); - }); - } else if (this.resourceTypeId) { - this.pcrud.search('brt', {id: this.resourceTypeId}, { - }).subscribe( t => { - this.transferable = t.transferable(); - this.openTheDialog(rows).then(newId => { - if (this.selectedAttributes.length) { - const creates$ = []; - this.selectedAttributes.forEach(attrValue => { - if (attrValue) { - const bravm = this.idl.create('bravm'); - bravm.attr_value(attrValue); - bravm.reservation(newId); - creates$.push(this.pcrud.create(bravm)); - } - }); - forkJoin(...creates$).subscribe(() => { - this.net.request('open-ils.storage', 'open-ils.storage.booking.reservation.resource_targeter', [newId]); }); - } else { - this.net.request('open-ils.storage', 'open-ils.storage.booking.reservation.resource_targeter', [newId]); - } - }); - }); - } - }; } handleResourceTypeChange($event: ComboboxEntry) { this.resourceBarcode = null; @@ -349,12 +288,13 @@ export class CreateReservationComponent implements OnInit, AfterViewInit { fetchData () { this.setGranularity(); this.resources = []; - const where = {'owner': this.owningLibraries}; + let where = {}; if (this.resourceId) { where['id'] = this.resourceId; } else if (this.resourceTypeId) { where['type'] = this.resourceTypeId; + where['owner'] = this.owningLibraries; } else { return; } @@ -388,15 +328,15 @@ export class CreateReservationComponent implements OnInit, AfterViewInit { dl.current.year, dl.current.month - 1, dl.current.day, - this.startOfDay.hour, - this.startOfDay.minute], + this.userStartOfDay.hour, + this.userStartOfDay.minute], this.format.wsOrgTimezone); endTime = Moment.tz([ dl.current.year, dl.current.month - 1, dl.current.day, - this.endOfDay.hour, - this.endOfDay.minute], + this.userEndOfDay.hour, + this.userEndOfDay.minute], this.format.wsOrgTimezone); }); } @@ -438,6 +378,12 @@ export class CreateReservationComponent implements OnInit, AfterViewInit { }); }); } + get userStartOfDay() { + return this.criteria.get('startOfDay').value; + } + get userEndOfDay() { + return this.criteria.get('startOfDay').value; + } } diff --git a/Open-ILS/src/eg2/src/app/staff/booking/manage-reservations.component.html b/Open-ILS/src/eg2/src/app/staff/booking/manage-reservations.component.html index 9926147907..c77b28c356 100644 --- a/Open-ILS/src/eg2/src/app/staff/booking/manage-reservations.component.html +++ b/Open-ILS/src/eg2/src/app/staff/booking/manage-reservations.component.html @@ -21,7 +21,7 @@
- +
diff --git a/Open-ILS/src/eg2/src/app/staff/common.module.ts b/Open-ILS/src/eg2/src/app/staff/common.module.ts index e4f1fadbd9..6d5a763643 100644 --- a/Open-ILS/src/eg2/src/app/staff/common.module.ts +++ b/Open-ILS/src/eg2/src/app/staff/common.module.ts @@ -28,6 +28,8 @@ import {EgHelpPopoverComponent} from '@eg/share/eg-help-popover/eg-help-popover. import {ReactiveFormsModule} from '@angular/forms'; import {DatetimeValidatorDirective} from '@eg/share/validators/datetime_validator.directive'; +import {PatronBarcodeValidatorDirective} from '@eg/share/validators/patron_barcode_validator.directive'; + /** * Imports the EG common modules and adds modules common to all staff UI's. */ @@ -55,6 +57,7 @@ import {DatetimeValidatorDirective} from '@eg/share/validators/datetime_validato AdminPageComponent, EgHelpPopoverComponent, DatetimeValidatorDirective, + PatronBarcodeValidatorDirective ], imports: [ EgCommonModule, @@ -85,6 +88,7 @@ import {DatetimeValidatorDirective} from '@eg/share/validators/datetime_validato AdminPageComponent, EgHelpPopoverComponent, DatetimeValidatorDirective, + PatronBarcodeValidatorDirective ] }) -- 2.11.0