LP1830432: Make the org-family-select reusable
authorJane Sandberg <sandbej@linnbenton.edu>
Sun, 23 Jun 2019 17:22:20 +0000 (10:22 -0700)
committerBill Erickson <berickxx@gmail.com>
Wed, 10 Jul 2019 17:55:46 +0000 (13:55 -0400)
This commit removes Bill Erickson's automagic org unit select with
+Ancestors and +Descendants checkboxes from the admin-page component,
and gives it a component of its own, called <eg-org-family-select>.

This commit also makes it compatible with [(ngModel)], reactive forms,
and any custom Angular validators you might want to throw at it.
Examples of all three are available in the sandbox.

Also includes some component tests.

Signed-off-by: Jane Sandberg <sandbej@linnbenton.edu>
Signed-off-by: Bill Erickson <berickxx@gmail.com>

Open-ILS/src/eg2/src/app/share/org-family-select/org-family-select.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/org-family-select/org-family-select.component.spec.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/org-family-select/org-family-select.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/common.module.ts
Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.html
Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.ts
Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.module.ts
Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.component.html
Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.component.ts

diff --git a/Open-ILS/src/eg2/src/app/share/org-family-select/org-family-select.component.html b/Open-ILS/src/eg2/src/app/share/org-family-select/org-family-select.component.html
new file mode 100644 (file)
index 0000000..3134751
--- /dev/null
@@ -0,0 +1,26 @@
+<div class="input-group">
+  <div class="input-group-prepend">
+    <span class="input-group-text">{{labelText}}</span>
+  </div>
+  <eg-org-select [domId]="domId"
+    (onChange)="orgOnChange($event)"
+    [limitPerms]="limitPerms"
+    [initialOrgId]="selectedOrgId">
+  </eg-org-select>
+</div>
+<form class="pl-2" [formGroup]="familySelectors">
+  <div class="form-check" *ngIf="!hideAncestorSelector">
+    <input type="checkbox"
+      formControlName="includeAncestors"
+      (blur)="registerOnTouched()"
+      class="form-check-input" id="{{domId}}-include-ancestors">
+    <label class="form-check-label" for="{{domId}}-include-ancestors" i18n>+ Ancestors</label>
+  </div>
+  <div class="form-check" *ngIf="!hideDescendantSelector">
+    <input type="checkbox"
+      formControlName="includeDescendants"
+      (blur)="registerOnTouched()"
+      class="form-check-input" id="{{domId}}-include-descendants">
+    <label class="form-check-label" for="{{domId}}-include-descendants" i18n>+ Descendants</label>
+  </div>
+</form>
diff --git a/Open-ILS/src/eg2/src/app/share/org-family-select/org-family-select.component.spec.ts b/Open-ILS/src/eg2/src/app/share/org-family-select/org-family-select.component.spec.ts
new file mode 100644 (file)
index 0000000..3e7117c
--- /dev/null
@@ -0,0 +1,108 @@
+import {ComponentFixture, TestBed} from '@angular/core/testing';
+import {Component, DebugElement, Input} from '@angular/core';
+import {By} from '@angular/platform-browser';
+import {OrgFamilySelectComponent} from './org-family-select.component';
+import {ReactiveFormsModule} from '@angular/forms';
+import {CookieService} from 'ngx-cookie';
+import {OrgService} from '@eg/core/org.service';
+
+@Component({
+    selector: 'eg-org-select',
+    template: ''
+})
+class MockOrgSelectComponent {
+    @Input() domId: string;
+    @Input() limitPerms: string;
+    @Input() initialOrgId: number;
+}
+
+describe('Component: OrgFamilySelect', () => {
+    let component: OrgFamilySelectComponent;
+    let fixture: ComponentFixture<OrgFamilySelectComponent>;
+    let includeAncestors: DebugElement;
+    let includeDescendants: DebugElement;
+    let orgServiceStub: Partial<OrgService>;
+    let cookieServiceStub: Partial<CookieService>;
+
+    beforeEach(() => {
+        // stub of OrgService for testing
+        // with a very simple org structure:
+        // 1 is the root note
+        // 2 is its child
+        orgServiceStub = {
+            root: () => {
+                return {
+                    a: [],
+                    classname: 'aou',
+                    _isfieldmapper: true,
+                    id: () => 1};
+            },
+            get: (ouId: number) => {
+                return {
+                    a: [],
+                    classname: 'aou',
+                    _isfieldmapper: true,
+                    children: () => Array(2 - ouId) };
+            }
+        };
+        cookieServiceStub = {};
+        TestBed.configureTestingModule({
+            imports: [
+                ReactiveFormsModule,
+            ], providers: [
+                { provide: CookieService, useValue: cookieServiceStub },
+                { provide: OrgService, useValue: orgServiceStub},
+            ], declarations: [
+                OrgFamilySelectComponent,
+                MockOrgSelectComponent,
+        ]});
+        fixture = TestBed.createComponent(OrgFamilySelectComponent);
+        component = fixture.componentInstance;
+        component.domId = 'family-test';
+        fixture.detectChanges();
+    });
+
+
+    it('provides includeAncestors checkbox by default', () => {
+        fixture.whenStable().then(() => {
+            includeAncestors = fixture.debugElement.query(By.css('#family-test-include-ancestors'));
+            expect(includeAncestors.nativeElement).toBeTruthy();
+        });
+    });
+
+    it('provides includeDescendants checkbox by default', () => {
+        fixture.whenStable().then(() => {
+            includeDescendants = fixture.debugElement.query(By.css('#family-test-include-descendants'));
+            expect(includeDescendants.nativeElement).toBeTruthy();
+        });
+    });
+
+    it('allows user to turn off includeAncestors checkbox', () => {
+        fixture.whenStable().then(() => {
+            component.hideAncestorSelector = true;
+            fixture.detectChanges();
+            includeAncestors = fixture.debugElement.query(By.css('#family-test-include-ancestors'));
+            expect(includeAncestors).toBeNull();
+        });
+    });
+
+    it('allows user to turn off includeDescendants checkbox', () => {
+        fixture.whenStable().then(() => {
+            component.hideDescendantSelector = true;
+            fixture.detectChanges();
+            includeDescendants = fixture.debugElement.query(By.css('#family-test-include-descendants'));
+            expect(includeDescendants).toBeNull();
+        });
+    });
+
+    it('disables includeAncestors checkbox when root OU is chosen', () => {
+        fixture.whenStable().then(() => {
+            component.selectedOrgId = 1;
+            fixture.detectChanges();
+            includeAncestors = fixture.debugElement.query(By.css('#family-test-include-ancestors'));
+            expect(includeAncestors.nativeElement.disabled).toBe(true);
+        });
+    });
+
+});
+
diff --git a/Open-ILS/src/eg2/src/app/share/org-family-select/org-family-select.component.ts b/Open-ILS/src/eg2/src/app/share/org-family-select/org-family-select.component.ts
new file mode 100644 (file)
index 0000000..6fd1790
--- /dev/null
@@ -0,0 +1,156 @@
+import {Component, EventEmitter, OnInit, Input, Output, ViewChild, forwardRef} from '@angular/core';
+import {ControlValueAccessor, FormGroup, FormControl, NG_VALUE_ACCESSOR} from '@angular/forms';
+import {AuthService} from '@eg/core/auth.service';
+import {IdlObject} from '@eg/core/idl.service';
+import {OrgService} from '@eg/core/org.service';
+
+export interface OrgFamily {
+  primaryOrgId: number;
+  includeAncestors?: boolean;
+  includeDescendants?: boolean;
+  orgIds?: number[];
+}
+
+@Component({
+    selector: 'eg-org-family-select',
+    templateUrl: 'org-family-select.component.html',
+    providers: [
+    {
+      provide: NG_VALUE_ACCESSOR,
+      useExisting: forwardRef(() => OrgFamilySelectComponent),
+      multi: true
+    }
+  ]
+})
+export class OrgFamilySelectComponent implements ControlValueAccessor, OnInit {
+
+    // The label for this input
+    @Input() labelText = 'Library';
+
+    // Should the Ancestors checkbox be hidden?
+    @Input() hideAncestorSelector = false;
+
+    // Should the Descendants checkbox be hidden?
+    @Input() hideDescendantSelector = false;
+
+    // Should the Ancestors checkbox be checked by default?
+    //
+    // Ignored if [hideAncestorSelector]="true"
+    @Input() ancestorSelectorChecked = false;
+
+    // Should the Descendants checkbox be checked by default?
+    //
+    // Ignored if [hideDescendantSelector]="true"
+    @Input() descendantSelectorChecked = false;
+
+    // Default org unit
+    @Input() selectedOrgId: number;
+
+    // Only show the OUs that the user has certain permissions at
+    @Input() limitPerms: string[];
+
+    @Input() domId: string;
+
+    // this is the most up-to-date value used for ngModel and reactive form
+    // subscriptions
+    options: OrgFamily;
+
+    orgOnChange: ($event: IdlObject) => void;
+    emitArray: () => void;
+
+    familySelectors: FormGroup;
+
+    propagateChange = (_: OrgFamily) => {};
+
+    constructor(
+        private auth: AuthService,
+        private org: OrgService
+    ) {
+    }
+
+    ngOnInit() {
+        if (this.selectedOrgId) {
+            this.options = {primaryOrgId: this.selectedOrgId};
+        } else if (this.auth.user()) {
+            this.options = {primaryOrgId: this.auth.user().ws_ou()};
+        }
+
+        this.familySelectors = new FormGroup({
+            'includeAncestors': new FormControl({
+                value: this.ancestorSelectorChecked,
+                disabled: this.disableAncestorSelector()}),
+            'includeDescendants': new FormControl({
+                value: this.descendantSelectorChecked,
+                disabled: this.disableDescendantSelector()}),
+        });
+
+        if (!this.domId) {
+            this.domId = 'org-family-select-' + Math.floor(Math.random() * 100000);
+        }
+
+        this.familySelectors.valueChanges.subscribe(val => {
+            this.emitArray();
+        });
+
+        this.orgOnChange = ($event: IdlObject) => {
+            this.options.primaryOrgId = $event.id();
+            this.disableAncestorSelector() ? this.includeAncestors.disable() : this.includeAncestors.enable();
+            this.disableDescendantSelector() ? this.includeDescendants.disable() : this.includeDescendants.enable();
+            this.emitArray();
+        };
+
+        this.emitArray = () => {
+            // Prepare and emit an array containing the primary org id and
+            // optionally ancestor and descendant org units.
+
+            this.options.orgIds = [this.options.primaryOrgId];
+
+            if (this.includeAncestors.value) {
+                this.options.orgIds = this.org.ancestors(this.options.primaryOrgId, true);
+            }
+
+            if (this.includeDescendants.value) {
+                // can result in duplicate workstation org IDs... meh
+                this.options.orgIds = this.options.orgIds.concat(
+                    this.org.descendants(this.options.primaryOrgId, true));
+            }
+
+            this.propagateChange(this.options);
+        };
+
+    }
+
+    writeValue(value: OrgFamily) {
+        if (value) {
+            this.selectedOrgId = value['primaryOrgId'];
+            this.familySelectors.patchValue({
+                'includeAncestors': value['includeAncestors'] ? value['includeAncestors'] : false,
+                'includeDescendants': value['includeDescendants'] ? value['includeDescendants'] : false,
+            });
+        }
+    }
+
+    registerOnChange(fn) {
+        this.propagateChange = fn;
+    }
+
+    registerOnTouched() {}
+
+    disableAncestorSelector(): boolean {
+        return this.options.primaryOrgId === this.org.root().id();
+    }
+
+    disableDescendantSelector(): boolean {
+        const contextOrg = this.org.get(this.options.primaryOrgId);
+        return contextOrg.children().length === 0;
+    }
+
+    get includeAncestors() {
+        return this.familySelectors.get('includeAncestors');
+    }
+    get includeDescendants() {
+        return this.familySelectors.get('includeDescendants');
+    }
+
+}
+
index 5575b70..bbf959c 100644 (file)
@@ -6,6 +6,7 @@ import {StaffBannerComponent} from './share/staff-banner.component';
 import {ComboboxComponent} from '@eg/share/combobox/combobox.component';
 import {ComboboxEntryComponent} from '@eg/share/combobox/combobox-entry.component';
 import {OrgSelectComponent} from '@eg/share/org-select/org-select.component';
+import {OrgFamilySelectComponent} from '@eg/share/org-family-select/org-family-select.component';
 import {AccessKeyDirective} from '@eg/share/accesskey/accesskey.directive';
 import {AccessKeyService} from '@eg/share/accesskey/accesskey.service';
 import {AccessKeyInfoComponent} from '@eg/share/accesskey/accesskey-info.component';
@@ -22,6 +23,7 @@ import {BibSummaryComponent} from '@eg/staff/share/bib-summary/bib-summary.compo
 import {TranslateComponent} from '@eg/staff/share/translate/translate.component';
 import {AdminPageComponent} from '@eg/staff/share/admin-page/admin-page.component';
 import {EgHelpPopoverComponent} from '@eg/share/eg-help-popover/eg-help-popover.component';
+import {ReactiveFormsModule} from '@angular/forms';
 
 /**
  * Imports the EG common modules and adds modules common to all staff UI's.
@@ -33,6 +35,7 @@ import {EgHelpPopoverComponent} from '@eg/share/eg-help-popover/eg-help-popover.
     ComboboxComponent,
     ComboboxEntryComponent,
     OrgSelectComponent,
+    OrgFamilySelectComponent,
     AccessKeyDirective,
     AccessKeyInfoComponent,
     ToastComponent,
@@ -49,7 +52,8 @@ import {EgHelpPopoverComponent} from '@eg/share/eg-help-popover/eg-help-popover.
   ],
   imports: [
     EgCommonModule,
-    GridModule
+    GridModule,
+    ReactiveFormsModule
   ],
   exports: [
     EgCommonModule,
@@ -58,6 +62,7 @@ import {EgHelpPopoverComponent} from '@eg/share/eg-help-popover/eg-help-popover.
     ComboboxComponent,
     ComboboxEntryComponent,
     OrgSelectComponent,
+    OrgFamilySelectComponent,
     AccessKeyDirective,
     AccessKeyInfoComponent,
     ToastComponent,
index 9087d04..fa58e90 100644 (file)
 <h4>PCRUD auto flesh and FormatService detection</h4>
 <div *ngIf="aMetarecord">Fingerprint: {{aMetarecord}}</div>
 
+<div class="row">
+  <div class="card col-md-6">
+    <div class="card-body">
+      <h3 class="card-title">Do you like template-driven forms?</h3>
+      <div class="card-text">
+        <eg-org-family-select
+          [ancestorSelectorChecked]="true"
+          [hideDescendantSelector]="true"
+          selectedOrgId="7"
+          labelText="Choose the best libraries"
+          ngModel #bestOnes="ngModel">
+        </eg-org-family-select>
+        The best libraries are: {{bestOnes.value | json}}
+      </div>
+    </div>
+  </div>
+  <form class="card col-md-6" [formGroup]="badOrgForm">
+    <div class="card-body">
+      <h3 class="card-title">Or perhaps reactive forms interest you?</h3>
+      <div class="card-text">
+        <eg-org-family-select
+          formControlName="badOrgSelector"
+          labelText="Choose the worst libraries">
+        </eg-org-family-select>
+       <div *ngIf="!badOrgForm.valid" class="alert alert-danger">
+          <span class="material-icons">error</span>
+          <span i18n>Too many bad libraries!</span>
+        </div>
+      </div>
+    </div>
+  </form>
+</div>
index de94b5e..17c6e6d 100644 (file)
@@ -15,6 +15,7 @@ import {PrintService} from '@eg/share/print/print.service';
 import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
 import {FormatService} from '@eg/core/format.service';
 import {FmRecordEditorComponent} from '@eg/share/fm-editor/fm-editor.component';
+import {FormGroup, FormControl} from '@angular/forms';
 
 @Component({
   templateUrl: 'sandbox.component.html'
@@ -60,6 +61,8 @@ export class SandboxComponent implements OnInit {
 
     dynamicTitleText: string;
 
+    badOrgForm: FormGroup;
+
     complimentEvergreen: (rows: IdlObject[]) => void;
     notOneSelectedRow: (rows: IdlObject[]) => boolean;
 
@@ -86,6 +89,21 @@ export class SandboxComponent implements OnInit {
     }
 
     ngOnInit() {
+        this.badOrgForm = new FormGroup({
+            'badOrgSelector': new FormControl(
+                {'id': 4, 'includeAncestors': false, 'includeDescendants': true}, (c: FormControl) => {
+                    // An Angular custom validator
+                    if (c.value.orgIds && c.value.orgIds.length > 5) {
+                        return { tooMany: 'That\'s too many bad libraries!' };
+                    } else {
+                        return null;
+                    }
+            } )
+        });
+
+        this.badOrgForm.get('badOrgSelector').valueChanges.subscribe(bad => {
+            this.toast.danger('The worst libraries are: ' + JSON.stringify(bad.orgIds));
+        });
 
         this.gridDataSource.data = [
             {name: 'Jane', state: 'AZ'},
index 58910dd..ec817d0 100644 (file)
@@ -2,6 +2,7 @@ import {NgModule} from '@angular/core';
 import {StaffCommonModule} from '@eg/staff/common.module';
 import {SandboxRoutingModule} from './routing.module';
 import {SandboxComponent} from './sandbox.component';
+import {FormsModule, ReactiveFormsModule} from '@angular/forms';
 
 @NgModule({
   declarations: [
@@ -10,6 +11,8 @@ import {SandboxComponent} from './sandbox.component';
   imports: [
     StaffCommonModule,
     SandboxRoutingModule,
+    FormsModule,
+    ReactiveFormsModule
   ],
   providers: [
   ]
index 855f196..ab6c263 100644 (file)
 <eg-string #createErrString [template]="createErrStrTmpl"></eg-string>
 
 <ng-container *ngIf="orgField">
-  <div class="d-flex">
-    <div>
-      <div class="input-group">
-        <div class="input-group-prepend">
-          <span class="input-group-text">{{orgFieldLabel}}</span>
-        </div>
-        <eg-org-select 
-          [limitPerms]="viewPerms"
-          [initialOrg]="contextOrg"
-          (onChange)="orgOnChange($event)">
-        </eg-org-select>
-      </div>
-    </div>
-    <div class="pl-2">
-      <div class="form-check">
-        <input type="checkbox" (click)="grid.reload()" 
-          [disabled]="disableAncestorSelector()"
-          [(ngModel)]="includeOrgAncestors"
-          class="form-check-input" id="include-ancestors">
-        <label class="form-check-label" for="include-ancestors" i18n>+ Ancestors</label>
-      </div>
-      <div class="form-check">
-        <input type="checkbox" (click)="grid.reload()" 
-          [disabled]="disableDescendantSelector()"
-          [(ngModel)]="includeOrgDescendants" 
-          class="form-check-input" id="include-descendants">
-        <label class="form-check-label" for="include-descendants" i18n>+ Descendants</label>
-      </div>
-    </div>
-  </div>
+  <eg-org-family-select
+    [limitPerms]="viewPerms" 
+    [selectedOrgId]="contextOrg.id()"
+    [(ngModel)]="searchOrgs"
+    (ngModelChange)="grid.reload()">
+  </eg-org-family-select>
   <hr/>
 </ng-container>
 
index a1dc1c6..88f9525 100644 (file)
@@ -12,6 +12,7 @@ import {PermService} from '@eg/core/perm.service';
 import {AuthService} from '@eg/core/auth.service';
 import {FmRecordEditorComponent} from '@eg/share/fm-editor/fm-editor.component';
 import {StringComponent} from '@eg/share/string/string.component';
+import {OrgFamily} from '@eg/share/org-family-select/org-family-select.component';
 
 /**
  * General purpose CRUD interface for IDL objects
@@ -83,6 +84,7 @@ export class AdminPageComponent implements OnInit {
     translatableFields: string[];
 
     contextOrg: IdlObject;
+    searchOrgs: OrgFamily;
     orgFieldLabel: string;
     viewPerms: string;
     canCreate: boolean;
@@ -124,6 +126,7 @@ export class AdminPageComponent implements OnInit {
         if (this.orgField) {
             this.orgFieldLabel = this.idlClassDef.field_map[this.orgField].label;
             this.contextOrg = this.org.get(orgId) || this.org.root();
+            this.searchOrgs = {primaryOrgId: this.contextOrg.id()};
         }
     }
 
@@ -188,11 +191,6 @@ export class AdminPageComponent implements OnInit {
         });
     }
 
-    orgOnChange(org: IdlObject) {
-        this.contextOrg = org;
-        this.grid.reload();
-    }
-
     initDataSource() {
         this.dataSource = new GridDataSource();
 
@@ -222,24 +220,7 @@ export class AdminPageComponent implements OnInit {
 
             const search: any = {};
 
-            if (this.contextOrg) {
-                // Filter rows by those linking to the context org and
-                // optionally ancestor and descendant org units.
-
-                let orgs = [this.contextOrg.id()];
-
-                if (this.includeOrgAncestors) {
-                    orgs = this.org.ancestors(this.contextOrg, true);
-                }
-
-                if (this.includeOrgDescendants) {
-                    // can result in duplicate workstation org IDs... meh
-                    orgs = orgs.concat(
-                        this.org.descendants(this.contextOrg, true));
-                }
-
-                search[this.orgField] = orgs;
-            }
+            search[this.orgField] = this.searchOrgs.orgIds || [this.contextOrg.id()];
 
             if (this.gridFilters) {
                 // Lay the URL grid filters over our search object.
@@ -253,15 +234,6 @@ export class AdminPageComponent implements OnInit {
         };
     }
 
-    disableAncestorSelector(): boolean {
-        return this.contextOrg &&
-            this.contextOrg.id() === this.org.root().id();
-    }
-
-    disableDescendantSelector(): boolean {
-        return this.contextOrg && this.contextOrg.children().length === 0;
-    }
-
     showEditDialog(idlThing: IdlObject): Promise<any> {
         this.editDialog.mode = 'update';
         this.editDialog.recId = idlThing[this.pkeyField]();