From a4490662f5f81b11bb6a38507dc8eae2d669a8b9 Mon Sep 17 00:00:00 2001 From: Jane Sandberg Date: Sun, 23 Jun 2019 10:22:20 -0700 Subject: [PATCH] LP1830432: Make the org-family-select reusable 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 . 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 Signed-off-by: Bill Erickson --- .../org-family-select.component.html | 26 +++ .../org-family-select.component.spec.ts | 108 ++++++++++++ .../org-family-select.component.ts | 156 ++++++++++++++++++ .../src/eg2/src/app/staff/common.module.ts | 7 +- .../app/staff/sandbox/sandbox.component.html | 32 ++++ .../app/staff/sandbox/sandbox.component.ts | 18 ++ .../src/app/staff/sandbox/sandbox.module.ts | 3 + .../admin-page/admin-page.component.html | 36 +--- .../share/admin-page/admin-page.component.ts | 36 +--- 9 files changed, 359 insertions(+), 63 deletions(-) create mode 100644 Open-ILS/src/eg2/src/app/share/org-family-select/org-family-select.component.html create mode 100644 Open-ILS/src/eg2/src/app/share/org-family-select/org-family-select.component.spec.ts create mode 100644 Open-ILS/src/eg2/src/app/share/org-family-select/org-family-select.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 index 0000000000..31347512a8 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/org-family-select/org-family-select.component.html @@ -0,0 +1,26 @@ +
+
+ {{labelText}} +
+ + +
+
+
+ + +
+
+ + +
+
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 index 0000000000..3e7117cb31 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/org-family-select/org-family-select.component.spec.ts @@ -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; + let includeAncestors: DebugElement; + let includeDescendants: DebugElement; + let orgServiceStub: Partial; + let cookieServiceStub: Partial; + + 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 index 0000000000..6fd1790c98 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/org-family-select/org-family-select.component.ts @@ -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'); + } + +} + 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 5575b70c3a..bbf959c98c 100644 --- a/Open-ILS/src/eg2/src/app/staff/common.module.ts +++ b/Open-ILS/src/eg2/src/app/staff/common.module.ts @@ -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, diff --git a/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.html b/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.html index 9087d044f6..fa58e905e0 100644 --- a/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.html +++ b/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.html @@ -178,3 +178,35 @@

PCRUD auto flesh and FormatService detection

Fingerprint: {{aMetarecord}}
+
+
+
+

Do you like template-driven forms?

+
+ + + The best libraries are: {{bestOnes.value | json}} +
+
+
+
+
+

Or perhaps reactive forms interest you?

+
+ + +
+ error + Too many bad libraries! +
+
+
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.ts b/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.ts index de94b5ef74..17c6e6deee 100644 --- a/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.ts @@ -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'}, diff --git a/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.module.ts b/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.module.ts index 58910dddbb..ec817d0d51 100644 --- a/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.module.ts +++ b/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.module.ts @@ -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: [ ] diff --git a/Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.component.html b/Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.component.html index 855f196e51..ab6c263249 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.component.html +++ b/Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.component.html @@ -12,36 +12,12 @@ -
-
-
-
- {{orgFieldLabel}} -
- - -
-
-
-
- - -
-
- - -
-
-
+ +
diff --git a/Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.component.ts b/Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.component.ts index a1dc1c61aa..88f9525d7d 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.component.ts @@ -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 { this.editDialog.mode = 'update'; this.editDialog.recId = idlThing[this.pkeyField](); -- 2.43.2