[open-ils-commits] [GIT] Evergreen ILS branch master updated. c839ede49b09f6f24c31f293f1d49f4a8dc53f82

Evergreen Git git at git.evergreen-ils.org
Fri Sep 6 13:39:50 EDT 2019


This is an automated email from the git hooks/post-receive script. It was
generated because a ref change was pushed to the repository containing
the project "Evergreen ILS".

The branch, master has been updated
       via  c839ede49b09f6f24c31f293f1d49f4a8dc53f82 (commit)
       via  d20011d9220b06077194dc76058f6a147f4c4a7d (commit)
       via  df0c2559f4e42234359869f6e1fd650ba685d822 (commit)
       via  296dca77f83f01bd78783ae27817c8ae84bbef29 (commit)
       via  028e70f6916a872a0b7b8c0fd73cbae70ed03648 (commit)
       via  529d556773255b1b2d86241a9d1d4d528d059d37 (commit)
       via  5a6a7a9e593a8d19792ba07d10dbc49e9a6a2c84 (commit)
       via  286ec1d75e2a8ec053119c1aec46e72e46128ec6 (commit)
      from  8f88f6892cf4163fdc6c9ad26cd8f83364832ef8 (commit)

Those revisions listed above that are new to this repository have
not appeared on any other notification email; so we list those
revisions in full, below.

- Log -----------------------------------------------------------------
commit c839ede49b09f6f24c31f293f1d49f4a8dc53f82
Author: Bill Erickson <berickxx at gmail.com>
Date:   Fri Aug 16 17:01:51 2019 -0400

    LP1840050 Angular Org Unit Admin page port
    
    Migrate the Dojo Org Unit administration page to Angular:
    
    Administration => Server Administration => Organizational Units
    
    Includes org unit, hours of operation, and address settings.
    
    The new UI adds details on which org unit addresses are shared, offering a
    way to clone a shared address to a new address.
    
    Includes release notes.
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>

diff --git a/Open-ILS/src/eg2/src/app/core/org.service.ts b/Open-ILS/src/eg2/src/app/core/org.service.ts
index ba2b4e39f8..6615851312 100644
--- a/Open-ILS/src/eg2/src/app/core/org.service.ts
+++ b/Open-ILS/src/eg2/src/app/core/org.service.ts
@@ -27,6 +27,9 @@ export class OrgService {
     private orgMap: {[id: number]: IdlObject} = {};
     private settingsCache: OrgSettingsBatch = {};
 
+    private orgTypeMap: {[id: number]: IdlObject} = {};
+    private orgTypeList: IdlObject[] = [];
+
     constructor(
         private net: NetService,
         private auth: AuthService,
@@ -44,15 +47,12 @@ export class OrgService {
         return this.orgList;
     }
 
-    // Returns a list of org unit type objects
     typeList(): IdlObject[] {
-        const types = [];
-        this.list().forEach(org => {
-            if ((types.filter(t => t.id() === org.ou_type().id())).length === 0) {
-                types.push(org.ou_type());
-            }
-        });
-        return types;
+        return this.orgTypeList;
+    }
+
+    typeMap(): {[id: number]: IdlObject} {
+        return this.orgTypeMap;
     }
 
     /**
@@ -173,9 +173,16 @@ export class OrgService {
             node = this.orgTree;
             this.orgMap = {};
             this.orgList = [];
+            this.orgTypeMap = {};
         }
         this.orgMap[node.id()] = node;
         this.orgList.push(node);
+
+        this.orgTypeMap[node.ou_type().id()] = node.ou_type();
+        if (!this.orgTypeList.filter(t => t.id() === node.ou_type().id())[0]) {
+            this.orgTypeList.push(node.ou_type());
+        }
+
         node.children().forEach(c => this.absorbTree(c));
     }
 
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/server/admin-server-splash.component.html b/Open-ILS/src/eg2/src/app/staff/admin/server/admin-server-splash.component.html
index a6e3d2b2a9..ab8ed5b09e 100644
--- a/Open-ILS/src/eg2/src/app/staff/admin/server/admin-server-splash.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/admin/server/admin-server-splash.component.html
@@ -78,7 +78,7 @@
     <eg-link-table-link i18n-label label="Org Unit Setting Types"  
       routerLink="/staff/admin/server/config/org_unit_setting_type"></eg-link-table-link>
     <eg-link-table-link i18n-label label="Organizational Units"  
-      url="/eg/staff/admin/server/legacy/actor/org_unit"></eg-link-table-link>
+      routerLink="/staff/admin/server/actor/org_unit"></eg-link-table-link>
     <eg-link-table-link i18n-label label="Permission Groups"  
       routerLink="/staff/admin/server/permission/grp_tree"></eg-link-table-link>
     <eg-link-table-link i18n-label label="Permissions"  
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/server/admin-server.module.ts b/Open-ILS/src/eg2/src/app/staff/admin/server/admin-server.module.ts
index cd628a39c2..9f49a7d98f 100644
--- a/Open-ILS/src/eg2/src/app/staff/admin/server/admin-server.module.ts
+++ b/Open-ILS/src/eg2/src/app/staff/admin/server/admin-server.module.ts
@@ -1,7 +1,7 @@
 import {NgModule} from '@angular/core';
 import {TreeModule} from '@eg/share/tree/tree.module';
-import {AdminServerRoutingModule} from './routing.module';
 import {AdminCommonModule} from '@eg/staff/admin/common.module';
+import {AdminServerRoutingModule} from './routing.module';
 import {AdminServerSplashComponent} from './admin-server-splash.component';
 import {OrgUnitTypeComponent} from './org-unit-type.component';
 import {PrintTemplateComponent} from './print-template.component';
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/server/org-addr.component.html b/Open-ILS/src/eg2/src/app/staff/admin/server/org-addr.component.html
new file mode 100644
index 0000000000..b3a787b5f4
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/admin/server/org-addr.component.html
@@ -0,0 +1,36 @@
+
+<ngb-tabset #addressTabs *ngIf="orgUnit" (tabChange)="tabChanged($event)">
+  <ng-container *ngFor="let type of addrTypes()">
+    <b>type = {{type}}</b>
+
+    <ngb-tab *ngIf="addr(type)"
+      i18n-title id="{{type}}"
+      title="{{type === 'billing_address' ? 'Physical Address' : 
+        (type === 'holds_address' ? 'Holds Address' : 
+        (type === 'mailing_address' ? 'Mailing Address' : 'ILL Address'))}}">
+
+      <ng-template ngbTabContent>
+        <eg-fm-record-editor idlClass="aoa" readonlyFields="org_unit" 
+          [mode]="addr(type).isnew() ? 'create': 'update'" 
+          [hideBanner]="true" displayMode="inline" hiddenFields="id"
+          (recordSaved)="addrSaved($event)" 
+          [record]="addr(type)"
+          fieldOrder="address_type,street1,street2,city,county,state,country,post_code,san,valid"
+          >
+          <eg-fm-record-editor-action i18n-label label="Delete" *ngIf="!addr(type).isnew()"
+            (actionClick)="deleteAddress($event)" buttonCss="btn-warning">
+          </eg-fm-record-editor-action>
+        </eg-fm-record-editor>
+
+        <ng-container *ngIf="sharedAddress(addr(type).id())">
+          <div class="alert alert-info">
+            <span i18n>This address is used for multiple address types.</span>
+            <button (click)="cloneAddress(type)" 
+              class="btn btn-light ml-3" i18n>Clone As New Address</button>
+          </div>
+        </ng-container>
+      </ng-template>
+    </ngb-tab>
+  </ng-container>
+</ngb-tabset>
+
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/server/org-addr.component.ts b/Open-ILS/src/eg2/src/app/staff/admin/server/org-addr.component.ts
new file mode 100644
index 0000000000..2092075104
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/admin/server/org-addr.component.ts
@@ -0,0 +1,163 @@
+import {Component, Input, Output, EventEmitter} from '@angular/core';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {OrgService} from '@eg/core/org.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {NgbTabChangeEvent} from '@ng-bootstrap/ng-bootstrap';
+
+const ADDR_TYPES =
+    ['billing_address', 'holds_address', 'mailing_address', 'ill_address'];
+
+ at Component({
+    selector: 'eg-admin-org-address',
+    templateUrl: './org-addr.component.html'
+})
+export class OrgAddressComponent {
+
+    orgUnit: IdlObject = null;
+    private tabName: string;
+
+    private _orgId: number;
+
+    get orgId(): number { return this._orgId; }
+
+    @Input() set orgId(newId: number) {
+        if (newId) {
+            if (!this._orgId || this._orgId !== newId) {
+                this._orgId = newId;
+                this.init();
+            }
+        } else {
+            this._orgId = null;
+        }
+    }
+
+    @Output() addrChange: EventEmitter<IdlObject>;
+
+    constructor(
+        private idl: IdlService,
+        private org: OrgService,
+        private pcrud: PcrudService
+    ) {
+        this.addrChange = new EventEmitter<IdlObject>();
+        this.tabName = 'billing_address';
+    }
+
+    init() {
+        if (!this.orgId) { return; }
+
+        return this.pcrud.retrieve('aou', this.orgId,
+            {flesh : 1, flesh_fields : {aou : ADDR_TYPES}},
+            {authoritative: true}
+        ).subscribe(org => {
+            this.orgUnit = org;
+            ADDR_TYPES.forEach(aType => {
+                if (!this.addr(aType)) {
+                    this.createAddress(aType);
+                }
+            });
+        });
+    }
+
+    tabChanged($event: NgbTabChangeEvent) {
+        this.tabName = $event.nextId;
+    }
+
+    addrTypes(): string[] { // for UI
+        return ADDR_TYPES;
+    }
+
+    // Template shorthand -- get a specific address by type.
+    addr(addrType: string) {
+        return this.orgUnit ? this.orgUnit[addrType]() : null;
+    }
+
+    createAddress(addrType: string) {
+        const addr = this.idl.create('aoa');
+        addr.isnew(true);
+        addr.valid('t');
+        addr.org_unit(this.orgId);
+        this.orgUnit[addrType](addr);
+    }
+
+    cloneAddress(addrType: string) {
+
+        // Find the address
+        let fromAddr: IdlObject;
+        ADDR_TYPES.forEach(aType => {
+            if (aType !== addrType &&
+                this.addr(aType).id() === this.addr(addrType).id()) {
+                fromAddr = this.addr(aType);
+            }
+        });
+
+        const addr = this.idl.clone(fromAddr);
+        addr.id(null);
+        addr.isnew(true);
+        addr.valid('t');
+        this.orgUnit[addrType](addr);
+    }
+
+    // True if the provided address is used for more than one addr type.
+    sharedAddress(addrId: number): boolean {
+        return ADDR_TYPES.filter(aType => {
+            return (
+                !this.addr(aType).isnew() && this.addr(aType).id() === addrId
+            );
+        }).length > 1;
+    }
+
+    deleteAddress($event: any) {
+        const addr = $event.record;
+        const tmpOrg = this.updatableOrg();
+
+        // Set the FKey to NULL on the org unit for deleted addresses
+        ADDR_TYPES.forEach(aType => {
+            const a = this.addr(aType);
+            if (a && a.id() === addr.id()) {
+                tmpOrg[aType](null);
+                this.createAddress(aType);
+            }
+        });
+
+        this.pcrud.update(tmpOrg).toPromise()
+        .then(_ => this.pcrud.remove(addr).toPromise())
+        .then(_ => this.addrChange.emit(addr));
+    }
+
+    // Addr saved by fm-editor.
+    // In the case of new address creation, point the org unit at
+    // the new address ID.
+    addrSaved(addr: number | IdlObject) {
+
+        if (typeof addr !== 'object') {
+            // pcrud returns a number on 'update' calls.  No need to
+            // reload the data on a simple address change. it's changed
+            // in place.
+            return;
+        }
+
+        // update local copy with version that has an ID.
+        this.orgUnit[this.tabName](addr);
+
+        const org = this.updatableOrg();
+        org[this.tabName](addr.id());
+
+        // Creating a new address -- tell our org about it.
+        this.pcrud.update(org).toPromise().then(_ => this.addrChange.emit(addr));
+    }
+
+    // Create an unfleshed org unit object that's a clone of this.orgUnit
+    // to use when pushing updates to the server.
+    updatableOrg(): IdlObject {
+        const org = this.idl.clone(this.orgUnit);
+
+        ADDR_TYPES.forEach(aType => {
+            const addr = this.addr(aType);
+            if (addr) { org[aType](addr.id()); }
+        });
+
+        return org;
+    }
+
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/server/org-unit-routing.module.ts b/Open-ILS/src/eg2/src/app/staff/admin/server/org-unit-routing.module.ts
new file mode 100644
index 0000000000..cbd36804f8
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/admin/server/org-unit-routing.module.ts
@@ -0,0 +1,19 @@
+import {NgModule} from '@angular/core';
+import {RouterModule, Routes} from '@angular/router';
+import {OrgUnitComponent} from './org-unit.component';
+
+// Since org-unit admin has its own module with page-level components,
+// it needs its own routing module as well to define which component
+// to display at page load time.
+
+const routes: Routes = [{
+    path: '',
+    component: OrgUnitComponent
+}];
+
+ at NgModule({
+  imports: [RouterModule.forChild(routes)],
+  exports: [RouterModule]
+})
+
+export class OrgUnitRoutingModule {}
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/server/org-unit.component.html b/Open-ILS/src/eg2/src/app/staff/admin/server/org-unit.component.html
new file mode 100644
index 0000000000..294d62c2bf
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/admin/server/org-unit.component.html
@@ -0,0 +1,125 @@
+<eg-staff-banner bannerText="Org Unit Configuration" i18n-bannerText>
+</eg-staff-banner>
+
+<ng-template #editStrTmpl i18n>Update Succeeded</ng-template>
+<eg-string #editString [template]="editStrTmpl"></eg-string>
+
+<ng-template #errorStrTmpl i18n>Update Failed</ng-template>
+<eg-string #errorString [template]="errorStrTmpl"></eg-string>
+
+<eg-confirm-dialog #delConfirm 
+  i18n-dialogTitle i18n-dialogBody
+  dialogTitle="Confirm Delete"
+  dialogBody="Delete Org Unit {{selected ? selected.label : ''}}?">
+</eg-confirm-dialog>
+
+<ng-template #treeNodeLabelTmpl let-org="org">
+  <span *ngIf="org" i18n>{{org.name()}} -- {{org.shortname()}}</span>
+</ng-template>
+<eg-string #treeNodeLabel key='admin.server.org_unit.treenode' 
+  [template]="treeNodeLabelTmpl"></eg-string>
+
+<div class="row">
+  <div class="col-lg-4">
+    <h3 i18n>Org Units</h3>
+    <eg-tree [tree]="tree" (nodeClicked)="nodeClicked($event)"></eg-tree>
+  </div>
+  <div class="col-lg-8">
+    <div class="alert alert-info">
+      <div *ngIf="currentOrg()">
+        <span *ngIf="currentOrg().name()" i18n>
+          {{currentOrg().name()}} ({{currentOrg().shortname()}})
+        </span>
+        <span *ngIf="!currentOrg().name()" class="font-italic" i18n>
+          Add Name
+        </span>
+      </div>
+    </div>
+    <ngb-tabset #rootTabs (tabChange)="tabChanged($event)" *ngIf="currentOrg()">
+      <ngb-tab title="Main Settings" i18n-title id="main">
+        <ng-template ngbTabContent>
+          <div class="mt-2">
+            <eg-fm-record-editor *ngIf="currentOrg()" #editDialog idlClass="aou" 
+              [mode]="currentOrg().isnew() ? 'create': 'update'" [hideBanner]="true" 
+              (recordSaved)="orgSaved($event)" displayMode="inline" 
+              (recordDeleted)="orgDeleted()"
+              readonlyFields="parent,parent_ou" [preloadLinkedValues]="true"
+              [fieldOptions]="{ou_type: {customValues: orgTypeOptions()}}"
+              [record]="currentOrg().isnew() ? currentOrg() : null"
+              [recordId]="currentOrg().isnew() ? null : currentOrg().id()"
+              [showDelete]="!orgHasChildren()"
+              fieldOrder="parent_ou,ou_type,name,shortname,phone,email,opac_visible,fiscal_calendar"
+              hiddenFields="id,billing_address,mailing_address,holds_address,ill_address">
+              <eg-fm-record-editor-action label="Add Child" i18n-label 
+                [disabled]="orgChildTypes().length === 0 || currentOrg().isnew()"
+                buttonCss="btn-outline-info"
+                (actionClick)="addChild()"></eg-fm-record-editor-action>
+            </eg-fm-record-editor>
+          </div>
+        </ng-template>
+      </ngb-tab>
+      <ngb-tab title="Hours of Operation" i18n-title id="hours" 
+        [disabled]="currentOrg().isnew()">
+        <ng-template ngbTabContent>
+          <div class="mt-2 common-form striped-even">
+            <div class="row font-weight-bold mb-2">
+              <div class="col-lg-3 offset-lg-2" i18n>Open Time</div>
+              <div class="col-lg-3" i18n>Close Time</div>
+            </div>
+            <div class="row mb-2" *ngFor="let dow of [0,1,2,3,4,5,6]">
+              <div class="col-lg-2" [ngSwitch]="dow">
+                <span *ngSwitchCase="0" i18n>Monday</span>
+                <span *ngSwitchCase="1" i18n>Tuesday</span>
+                <span *ngSwitchCase="2" i18n>Wednesday</span>
+                <span *ngSwitchCase="3" i18n>Thursday</span>
+                <span *ngSwitchCase="4" i18n>Friday</span>
+                <span *ngSwitchCase="5" i18n>Saturday</span>
+                <span *ngSwitchCase="6" i18n>Sunday</span>
+              </div>
+              <div class="col-lg-3">
+                <input class="form-control" type='time' step="60" 
+                  [ngModel]="hours(dow, 'open')" min="00:00:00" max="23:59:59"
+                  (ngModelChange)="hours(dow, 'open', $event)"/>
+              </div>
+              <div class="col-lg-3">
+                <input  class="form-control" type='time' step="60"
+                  [ngModel]="hours(dow, 'close')" min="00:00:00" max="23:59:59"
+                  (ngModelChange)="hours(dow, 'close', $event)"/>
+              </div>
+              <div class="col-lg-2">
+                <button class="btn btn-outline-dark" (click)="closedOn(dow)" 
+                  [disabled]="isClosed(dow)" i18n>Closed</button>
+              </div>
+            </div>
+            <div class="row d-flex justify-content-end">
+              <div class="alert alert-warning mr-2 p-1" 
+                *ngIf="currentOrg().hours_of_operation().isnew()">
+                Hours of Operation Have Not Yet Been Saved.
+              </div>
+              <div class="mr-2">
+                <button class="btn btn-warning" (click)="deleteHours()" i18n>
+                  Clear Hours of Operation
+                </button>
+              </div>
+              <div>
+                <button class="btn btn-info" (click)="saveHours()" i18n>
+                  Apply Changes
+                </button>
+              </div>
+              <div class="col-lg-2"><!-- alignment --></div>
+            </div>
+          </div>
+        </ng-template>
+      </ngb-tab>
+      <ngb-tab title="Addresses" i18n-title id="addresses" 
+        [disabled]="currentOrg().isnew()">
+        <ng-template ngbTabContent>
+          <div class="mt-2">
+            <eg-admin-org-address [orgId]="currentOrg().id()" (addrChange)="addressChanged($event)">
+            </eg-admin-org-address>
+          </div>
+        </ng-template>
+      </ngb-tab>
+    </ngb-tabset>
+  </div>
+</div>
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/server/org-unit.component.ts b/Open-ILS/src/eg2/src/app/staff/admin/server/org-unit.component.ts
new file mode 100644
index 0000000000..714d7b7e51
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/admin/server/org-unit.component.ts
@@ -0,0 +1,274 @@
+import {Component, Input, ViewChild, OnInit} from '@angular/core';
+import {Tree, TreeNode} from '@eg/share/tree/tree';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {NgbTabset, NgbTabChangeEvent} from '@ng-bootstrap/ng-bootstrap';
+import {OrgService} from '@eg/core/org.service';
+import {AuthService} from '@eg/core/auth.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {ToastService} from '@eg/share/toast/toast.service';
+import {StringComponent} from '@eg/share/string/string.component';
+import {StringService} from '@eg/share/string/string.service';
+import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component';
+import {FmRecordEditorComponent} from '@eg/share/fm-editor/fm-editor.component';
+import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
+
+ at Component({
+    templateUrl: './org-unit.component.html'
+})
+export class OrgUnitComponent implements OnInit {
+
+    tree: Tree;
+    selected: TreeNode;
+    @ViewChild('editString') editString: StringComponent;
+    @ViewChild('errorString') errorString: StringComponent;
+    @ViewChild('delConfirm') delConfirm: ConfirmDialogComponent;
+
+    constructor(
+        private idl: IdlService,
+        private org: OrgService,
+        private auth: AuthService,
+        private pcrud: PcrudService,
+        private strings: StringService,
+        private toast: ToastService
+    ) {}
+
+
+    ngOnInit() {
+        this.loadAouTree(this.org.root().id());
+    }
+
+    tabChanged(evt: NgbTabChangeEvent) {
+        const tab = evt.nextId;
+        // stubbing out in case we need it.
+    }
+
+    orgSaved(orgId: number | IdlObject) {
+        let id;
+
+        if (orgId) { // new org created, focus it.
+            id = typeof orgId === 'object' ? orgId.id() : orgId;
+        } else if (this.currentOrg()) {
+            id = this.currentOrg().id();
+        }
+
+        this.loadAouTree(id).then(_ => this.postUpdate(this.editString));
+    }
+
+    orgDeleted() {
+        this.loadAouTree();
+    }
+
+    loadAouTree(selectNodeId?: number): Promise<any> {
+
+        const flesh = ['children', 'ou_type', 'hours_of_operation'];
+
+        return this.pcrud.search('aou', {parent_ou : null},
+            {flesh : -1, flesh_fields : {aou : flesh}}, {authoritative: true}
+
+        ).toPromise().then(tree => {
+            this.ingestAouTree(tree);
+            if (!selectNodeId) { selectNodeId = this.org.root().id(); }
+
+            const node = this.tree.findNode(selectNodeId);
+            this.selected = node;
+            this.tree.selectNode(node);
+        });
+    }
+
+    // Translate the org unt type tree into a structure EgTree can use.
+    ingestAouTree(aouTree) {
+
+        const handleNode = (orgNode: IdlObject): TreeNode => {
+            if (!orgNode) { return; }
+
+            if (!orgNode.hours_of_operation()) {
+                this.generateHours(orgNode);
+            }
+
+            const treeNode = new TreeNode({
+                id: orgNode.id(),
+                label: orgNode.name(),
+                callerData: {orgUnit: orgNode}
+            });
+
+            // Apply the compiled label asynchronously
+            this.strings.interpolate(
+                'admin.server.org_unit.treenode', {org: orgNode}
+            ).then(label => treeNode.label = label);
+
+            orgNode.children().forEach(childNode =>
+                treeNode.children.push(handleNode(childNode))
+            );
+
+            return treeNode;
+        };
+
+        const rootNode = handleNode(aouTree);
+        this.tree = new Tree(rootNode);
+    }
+
+    nodeClicked($event: any) {
+        this.selected = $event;
+    }
+
+    generateHours(org: IdlObject) {
+        const hours = this.idl.create('aouhoo');
+        hours.id(org.id());
+        hours.isnew(true);
+
+        [0, 1, 2, 3, 4, 5, 6].forEach(dow => {
+            this.hours(dow, 'open', '09:00:00', hours);
+            this.hours(dow, 'close', '17:00:00', hours);
+        });
+
+        org.hours_of_operation(hours);
+    }
+
+    // if a 'value' is passed, it will be applied to the optional
+    // hours-of-operation object, otherwise the hours on the currently
+    // selected org unit.
+    hours(dow: number, which: 'open' | 'close', value?: string, hoo?: IdlObject): string {
+        if (!hoo && !this.selected) { return null; }
+
+        const hours = hoo || this.selected.callerData.orgUnit.hours_of_operation();
+
+        if (value) {
+            hours[`dow_${dow}_${which}`](value);
+            hours.ischanged(true);
+        }
+
+        return hours[`dow_${dow}_${which}`]();
+    }
+
+    isClosed(dow: number): boolean {
+        return (
+            this.hours(dow, 'open') === '00:00:00' &&
+            this.hours(dow, 'close') === '00:00:00'
+        );
+    }
+
+    closedOn(dow: number) {
+        this.hours(dow, 'open', '00:00:00');
+        this.hours(dow, 'close', '00:00:00');
+    }
+
+    saveHours() {
+        const org = this.currentOrg();
+        const hours = org.hours_of_operation();
+        this.pcrud.autoApply(hours).subscribe(
+            result => {
+                console.debug('Hours saved ', result);
+                this.editString.current()
+                    .then(msg => this.toast.success(msg));
+            },
+            error => {
+                this.errorString.current()
+                    .then(msg => this.toast.danger(msg));
+            },
+            () => this.loadAouTree(this.selected.id)
+        );
+    }
+
+    deleteHours() {
+        const hours = this.currentOrg().hours_of_operation();
+        const promise = hours.isnew() ? Promise.resolve() :
+            this.pcrud.remove(hours).toPromise();
+
+        promise.then(_ => this.generateHours(this.currentOrg()));
+    }
+
+    currentOrg(): IdlObject {
+        return this.selected ? this.selected.callerData.orgUnit : null;
+    }
+
+    orgHasChildren(): boolean {
+        const org = this.currentOrg();
+        return (org && org.children().length > 0);
+    }
+
+    postUpdate(message: StringComponent) {
+        // Modifying org unit types means refetching the org unit
+        // data normally fetched on page load, since it includes
+        // org unit type data.
+        this.org.fetchOrgs().then(() =>
+            message.current().then(str => this.toast.success(str)));
+    }
+
+    remove() {
+        this.delConfirm.open().subscribe(confirmed => {
+            if (!confirmed) { return; }
+
+            const org = this.selected.callerData.orgUnit;
+
+            this.pcrud.remove(org).subscribe(
+                ok2 => {},
+                err => {
+                    this.errorString.current()
+                      .then(str => this.toast.danger(str));
+                },
+                ()  => {
+                    // Avoid updating until we know the entire
+                    // pcrud action/transaction completed.
+                    // After removal, select the parent org if available
+                    // otherwise the root org.
+                    const orgId = org.parent_ou() ?
+                        org.parent_ou() : this.org.root().id();
+                    this.loadAouTree(orgId).then(_ =>
+                        this.postUpdate(this.editString));
+                }
+            );
+        });
+    }
+
+    orgTypeOptions(): ComboboxEntry[] {
+        let ouType = this.currentOrg().ou_type();
+
+        if (typeof ouType === 'number') {
+            // May not be fleshed for new org units
+            ouType = this.org.typeMap()[ouType];
+        }
+        const curDepth = ouType.depth();
+
+        return this.org.typeList()
+            .filter(type_ => type_.depth() === curDepth)
+            .map(type_ => ({id: type_.id(), label: type_.name()}));
+    }
+
+    orgChildTypes(): IdlObject[] {
+        let ouType = this.currentOrg().ou_type();
+
+        if (typeof ouType === 'number') {
+            // May not be fleshed for new org units
+            ouType = this.org.typeMap()[ouType];
+        }
+
+        const depth = ouType.depth();
+        return this.org.typeList()
+            .filter(type_ => type_.depth() === depth + 1);
+    }
+
+    addChild() {
+        const parentTreeNode = this.selected;
+        const parentOrg = this.currentOrg();
+        const newType = this.orgChildTypes()[0];
+
+        const org = this.idl.create('aou');
+        org.isnew(true);
+        org.parent_ou(parentOrg.id());
+        org.ou_type(newType.id());
+        org.children([]);
+
+        // Create a dummy, detached org node to keep the UI happy.
+        this.selected = new TreeNode({
+            id: org.id(),
+            label: org.name(),
+            callerData: {orgUnit: org}
+        });
+    }
+
+    addressChanged(thing: any) {
+        // Reload to pick up org unit address changes.
+        this.orgSaved(this.currentOrg().id());
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/server/org-unit.module.ts b/Open-ILS/src/eg2/src/app/staff/admin/server/org-unit.module.ts
new file mode 100644
index 0000000000..09150a803a
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/admin/server/org-unit.module.ts
@@ -0,0 +1,27 @@
+import {NgModule} from '@angular/core';
+import {TreeModule} from '@eg/share/tree/tree.module';
+import {AdminCommonModule} from '@eg/staff/admin/common.module';
+import {OrgUnitComponent} from './org-unit.component';
+import {OrgAddressComponent} from './org-addr.component';
+import {OrgUnitRoutingModule} from './org-unit-routing.module';
+
+ at NgModule({
+  declarations: [
+    OrgUnitComponent,
+    OrgAddressComponent
+  ],
+  imports: [
+    AdminCommonModule,
+    OrgUnitRoutingModule,
+    TreeModule
+  ],
+  exports: [
+  ],
+  providers: [
+  ]
+})
+
+export class OrgUnitModule {
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/server/routing.module.ts b/Open-ILS/src/eg2/src/app/staff/admin/server/routing.module.ts
index 20228783ef..6ce93653e4 100644
--- a/Open-ILS/src/eg2/src/app/staff/admin/server/routing.module.ts
+++ b/Open-ILS/src/eg2/src/app/staff/admin/server/routing.module.ts
@@ -19,6 +19,9 @@ const routes: Routes = [{
     path: 'permission/grp_tree',
     component: PermGroupTreeComponent
 }, {
+    path: 'actor/org_unit',
+    loadChildren: '@eg/staff/admin/server/org-unit.module#OrgUnitModule'
+}, {
     path: ':schema/:table',
     component: BasicAdminPageComponent
 }];
diff --git a/docs/RELEASE_NOTES_NEXT/Administration/org-unit-admin.adoc b/docs/RELEASE_NOTES_NEXT/Administration/org-unit-admin.adoc
new file mode 100644
index 0000000000..58a8f5eb0b
--- /dev/null
+++ b/docs/RELEASE_NOTES_NEXT/Administration/org-unit-admin.adoc
@@ -0,0 +1,6 @@
+Angular Org Unit Admin Page
+^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Migrate the Administration => Server Administration => Organizational Units 
+page to Angular.
+

commit d20011d9220b06077194dc76058f6a147f4c4a7d
Author: Bill Erickson <berickxx at gmail.com>
Date:   Fri Aug 16 17:00:53 2019 -0400

    LP1840050 Angular staff banner sets title
    
    Any UI that declares a staff banner gets the same text set as the
    page/title title as well.
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>

diff --git a/Open-ILS/src/eg2/src/app/staff/share/staff-banner.component.ts b/Open-ILS/src/eg2/src/app/staff/share/staff-banner.component.ts
index 13ac684e34..1274086f32 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/staff-banner.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/staff-banner.component.ts
@@ -2,10 +2,12 @@ import {Component, OnInit, Input} from '@angular/core';
 
 @Component({
   selector: 'eg-staff-banner',
-  template:
-    '<div class="lead alert alert-primary text-center pt-1 pb-1" role="alert">' +
-      '<span>{{bannerText}}</span>' +
-    '</div>'
+  template: `
+    <div class="lead alert alert-primary text-center pt-1 pb-1" role="alert">
+      <eg-title i18n-prefix [prefix]="bannerText"></eg-title>
+       <span>{{bannerText}}</span>
+    </div>
+    `
 })
 
 export class StaffBannerComponent {

commit df0c2559f4e42234359869f6e1fd650ba685d822
Author: Bill Erickson <berickxx at gmail.com>
Date:   Fri Aug 16 17:00:11 2019 -0400

    LP1840050 Modularize various standalone components + more.
    
    Create container modules for the String, Translate, FM record editor, and
    Admin Page components & services.  This simplifies imports and allows us
    to avoid requiring these modules on pages that don't need them.  In
    particular, the staff splash page now loads fewer imports, which should
    improve initial load/login time.
    
    Additionally some components were enhanced.
    
    FM record editor now has a eg-fm-record-editor-action component so users
    can pass in an action, rendered as a button at the bottom of the editor.
    
    FM record editor gets a delete record option and hideBanner option.
    
    FM record editor now better handles real-time updates of its underlying
    recordId and record values, including updates to some editor callers to
    migrate to the modified API (replace recId with recordId).
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>

diff --git a/Open-ILS/src/eg2/src/app/common.module.ts b/Open-ILS/src/eg2/src/app/common.module.ts
index af1f2b0705..d61eeabb3a 100644
--- a/Open-ILS/src/eg2/src/app/common.module.ts
+++ b/Open-ILS/src/eg2/src/app/common.module.ts
@@ -28,8 +28,7 @@ import {ProgressDialogComponent} from '@eg/share/dialog/progress.component';
 import {BoolDisplayComponent} from '@eg/share/util/bool.component';
 import {ToastService} from '@eg/share/toast/toast.service';
 import {ToastComponent} from '@eg/share/toast/toast.component';
-import {StringComponent} from '@eg/share/string/string.component';
-import {StringService} from '@eg/share/string/string.service';
+import {StringModule} from '@eg/share/string/string.module';
 
 
 @NgModule({
@@ -42,7 +41,6 @@ import {StringService} from '@eg/share/string/string.service';
     ProgressInlineComponent,
     ProgressDialogComponent,
     ToastComponent,
-    StringComponent,
     BoolDisplayComponent
   ],
   imports: [
@@ -51,7 +49,8 @@ import {StringService} from '@eg/share/string/string.service';
     ReactiveFormsModule,
     RouterModule,
     NgbModule,
-    EgCoreModule
+    EgCoreModule,
+    StringModule
   ],
   exports: [
     CommonModule,
@@ -59,6 +58,7 @@ import {StringService} from '@eg/share/string/string.service';
     NgbModule,
     FormsModule,
     EgCoreModule,
+    StringModule,
     ReactiveFormsModule,
     PrintComponent,
     DialogComponent,
@@ -68,8 +68,7 @@ import {StringService} from '@eg/share/string/string.service';
     ProgressInlineComponent,
     ProgressDialogComponent,
     BoolDisplayComponent,
-    ToastComponent,
-    StringComponent
+    ToastComponent
   ]
 })
 
@@ -82,7 +81,6 @@ export class EgCommonModule {
             providers: [
                 HatchService,
                 PrintService,
-                StringService,
                 ToastService
             ]
         };
diff --git a/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor-action.component.ts b/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor-action.component.ts
new file mode 100644
index 0000000000..298856d684
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor-action.component.ts
@@ -0,0 +1,31 @@
+import {Component, Input, Output, EventEmitter, Host, OnInit} from '@angular/core';
+import {FmRecordEditorComponent} from './fm-editor.component';
+
+ at Component({
+  selector: 'eg-fm-record-editor-action',
+  template: '<ng-template></ng-template>' // no-op
+})
+
+export class FmRecordEditorActionComponent implements OnInit {
+
+    // unique identifier
+    @Input() key: string;
+
+    @Input() label: string;
+
+    @Input() buttonCss = 'btn-outline-dark';
+
+    // Emits the 'key' of the clicked action.
+    @Output() actionClick: EventEmitter<string>;
+
+    @Input() disabled: boolean;
+
+    constructor(@Host() private editor: FmRecordEditorComponent) {
+        this.actionClick = new EventEmitter<string>();
+    }
+
+    ngOnInit() {
+        this.editor.actions.push(this);
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.html b/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.html
index bb0e34785e..e1ba382b63 100644
--- a/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.html
+++ b/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.html
@@ -4,8 +4,13 @@
 <eg-string #successStr text="Update Succeeded" i18n-text></eg-string>
 <eg-string #failStr text="Update Failed" i18n-text></eg-string>
 
+<eg-confirm-dialog #confirmDel
+  dialogTitle="Delete?" i18n-dialogTitle
+  dialogBody="Delete {{recordLabel}}?" i18n-dialogBody>
+</eg-confirm-dialog>
+
 <ng-template #dialogContent>
-  <div class="modal-header bg-info">
+  <div class="modal-header bg-info" *ngIf="!hideBanner">
     <h4 class="modal-title" i18n>Record Editor: {{recordLabel}}</h4>
     <ng-container *ngIf="isDialog()">
       <button type="button" class="close" 
@@ -79,7 +84,7 @@
                 [limitPerms]="modePerms[mode]"
                 [readOnly]="field.readOnly"
                 [applyDefault]="field.orgDefaultAllowed"
-                [initialOrgId]="record[field.name]()"
+                [applyOrgId]="record[field.name]()"
                 (onChange)="record[field.name]($event)">
               </eg-org-select>
             </ng-container>
@@ -160,7 +165,7 @@
                 [required]="field.isRequired()"
                 [entries]="field.linkedValues"
                 [asyncDataSource]="field.linkedValuesSource"
-                [startId]="record[field.name]()"
+                [selectedId]="record[field.name]()"
                 (onChange)="record[field.name]($event ? $event.id : null)">
               </eg-combobox>
             </ng-container>
@@ -179,12 +184,23 @@
     </form>
   </div>
   <div class="modal-footer">
+    <button type="button" class="btn {{action.buttonCss}}"
+      *ngFor="let action of actions" [disabled]="action.disabled"
+      (click)="action.actionClick.emit({action: action.key, record: record})">
+      {{action.label}}
+    </button>
     <ng-container *ngIf="isDialog()">
       <button type="button" class="btn btn-success" *ngIf="mode == 'view'"
         (click)="closeEditor()" i18n>Close</button>
       <button type="button" class="btn btn-warning ml-2" *ngIf="mode != 'view'"
         (click)="cancel()" i18n>Cancel</button>
     </ng-container>
+
+    <ng-container *ngIf="showDelete && mode != 'view'">
+      <button type="button" class="btn btn-warning" (click)="remove()"
+        [disabled]="record && record.isnew()" i18n>Delete</button>
+    </ng-container>
+
     <button type="button" class="btn btn-info" 
       [disabled]="fmEditForm.invalid" *ngIf="mode != 'view'"
       (click)="save()" i18n>Save</button>
diff --git a/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.ts b/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.ts
index 730084adc9..a4fc62ba73 100644
--- a/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.ts
+++ b/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.ts
@@ -10,8 +10,10 @@ import {ToastService} from '@eg/share/toast/toast.service';
 import {StringComponent} from '@eg/share/string/string.component';
 import {NgbModal, NgbModalOptions} from '@ng-bootstrap/ng-bootstrap';
 import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
-import {TranslateComponent} from '@eg/staff/share/translate/translate.component';
 import {FormatService} from '@eg/core/format.service';
+import {TranslateComponent} from '@eg/share/translate/translate.component';
+import {FmRecordEditorActionComponent} from './fm-editor-action.component';
+import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component';
 
 interface CustomFieldTemplate {
     template: TemplateRef<any>;
@@ -82,14 +84,9 @@ export class FmRecordEditorComponent
     // IDL class hint (e.g. "aou")
     @Input() idlClass: string;
 
-    recId: any;
-
     // Show datetime fields in this particular timezone
     timezone: string = this.format.wsOrgTimezone;
 
-    // IDL record we are editing
-    record: IdlObject;
-
     // Permissions extracted from the permacrud defs in the IDL
     // for the current IDL class
     modePerms: {[mode: string]: string};
@@ -130,18 +127,26 @@ export class FmRecordEditorComponent
     // Display within a modal dialog window or inline in the page.
     @Input() displayMode: 'dialog' | 'inline' = 'dialog';
 
+    // Hide the top 'Record Editor: ...' banner.  Primarily useful
+    // for displayMode === 'inline'
+    @Input() hideBanner: boolean;
+
+    // Emit the modified object when the save action completes.
+    @Output() recordSaved = new EventEmitter<IdlObject>();
+
     // Emit the modified object when the save action completes.
-    @Output() onSave$ = new EventEmitter<IdlObject>();
+    @Output() recordDeleted = new EventEmitter<IdlObject>();
 
     // Emit the original object when the save action is canceled.
-    @Output() onCancel$ = new EventEmitter<IdlObject>();
+    @Output() recordCanceled = new EventEmitter<IdlObject>();
 
     // Emit an error message when the save action fails.
-    @Output() onError$ = new EventEmitter<string>();
+    @Output() recordError = new EventEmitter<string>();
 
     @ViewChild('translator') private translator: TranslateComponent;
     @ViewChild('successStr') successStr: StringComponent;
     @ViewChild('failStr') failStr: StringComponent;
+    @ViewChild('confirmDel') confirmDel: ConfirmDialogComponent;
 
     // IDL info for the the selected IDL class
     idlDef: any;
@@ -161,16 +166,64 @@ export class FmRecordEditorComponent
     //       'view' for viewing an existing record without editing
     @Input() mode: 'create' | 'update' | 'view' = 'create';
 
-    // Record ID to view/update.  Value is dynamic.  Records are not
-    // fetched until .open() is called.
-    @Input() set recordId(id: any) {
-        if (id) { this.recId = id; }
-    }
-
     // custom function for munging the record before it gets saved;
     // will get passed mode and the record itself
     @Input() preSave: Function;
 
+    // recordId and record getters and setters.
+    // Note that setting the this.recordId to NULL does not clear the
+    // current value of this.record and vice versa.  Only viable data
+    // is actionable.  This allows the caller to use both @Input()'s
+    // without each clobbering the other.
+
+    // Record ID to view/update.
+    _recordId: any = null;
+    @Input() set recordId(id: any) {
+        if (id) {
+            if (id !== this._recordId) {
+                this._recordId = id;
+                this._record = null; // force re-fetch
+                this.handleRecordChange();
+            }
+        } else {
+            this._recordId = null;
+        }
+    }
+
+    get recordId(): any {
+        return this._recordId;
+    }
+
+    // IDL record we are editing
+    _record: IdlObject = null;
+    @Input() set record(r: IdlObject) {
+        if (r) {
+            if (!this.idl.pkeyMatches(this.record, r)) {
+                this._record = r;
+                this._recordId = null; // avoid mismatch
+                this.handleRecordChange();
+            }
+        } else {
+            this._record = null;
+        }
+    }
+
+    get record(): IdlObject {
+        return this._record;
+    }
+
+    actions: FmRecordEditorActionComponent[] = [];
+
+    initDone: boolean;
+
+    // Comma-separated list of field names defining the order in which
+    // fields should be rendered in the form.  Any fields not represented
+    // will be rendered alphabetically by label after the named fields.
+    @Input() fieldOrder: string;
+
+    // When true, show a delete button and support delete operations.
+    @Input() showDelete: boolean;
+
     constructor(
       private modal: NgbModal, // required for passing to parent
       private idl: IdlService,
@@ -200,6 +253,16 @@ export class FmRecordEditorComponent
         } else {
             this.initRecord();
         }
+        this.initDone = true;
+    }
+
+    // If the record ID changes after ngOnInit has been called
+    // and we're using displayMode=inline, force the data to
+    // resync in real time
+    handleRecordChange() {
+        if (this.initDone && !this.isDialog()) {
+            this.initRecord();
+        }
     }
 
     open(args?: NgbModalOptions): Observable<any> {
@@ -219,11 +282,11 @@ export class FmRecordEditorComponent
         return this.displayMode === 'dialog';
     }
 
-    // Set the record value and clear the recId value to
-    // indicate the record is our current source of data.
+    // DEPRECATED: This is a duplicate of this.record = abc;
     setRecord(record: IdlObject) {
-        this.record = record;
-        this.recId = null;
+        console.warn('fm-editor:setRecord() is deprecated. ' +
+            'Use editor.record = abc or [record]="abc" instead');
+        this.record = record; // this calls the setter
     }
 
     // Translate comma-separated string versions of various inputs
@@ -260,21 +323,25 @@ export class FmRecordEditorComponent
         if (this.mode === 'update' || this.mode === 'view') {
 
             let promise;
-            if (this.record && this.recId === null) {
+            if (this.record && this.recordId === null) {
                 promise = Promise.resolve(this.record);
-            } else {
+            } else if (this.recordId) {
                 promise =
-                    this.pcrud.retrieve(this.idlClass, this.recId).toPromise();
+                    this.pcrud.retrieve(this.idlClass, this.recordId).toPromise();
+            } else {
+                // Not enough data yet to fetch anything
+                return Promise.resolve();
             }
 
             return promise.then(rec => {
 
                 if (!rec) {
                     return Promise.reject(`No '${this.idlClass}'
-                        record found with id ${this.recId}`);
+                        record found with id ${this.recordId}`);
                 }
 
-                this.record = rec;
+                // Set this._record (not this.record) to avoid loop in initRecord()
+                this._record = rec;
                 this.convertDatatypesToJs();
                 return this.getFieldList();
             });
@@ -284,7 +351,9 @@ export class FmRecordEditorComponent
         //
         // Create a new record from the stub record provided by the
         // caller or a new from-scratch record
-        this.setRecord(this.record || this.idl.create(this.idlClass));
+        // Set this._record (not this.record) to avoid loop in initRecord()
+        this._record = this.record || this.idl.create(this.idlClass);
+        this._recordId = null; // avoid future confusion
 
         return this.getFieldList();
     }
@@ -306,7 +375,8 @@ export class FmRecordEditorComponent
     // Modifies the provided FM record in place, replacing JS values
     // with IDL-compatible values.
     convertDatatypesToIdl(rec: IdlObject) {
-        const fields = this.idlDef.fields;
+        const fields = this.idlDef.fields.filter(f => !f.virtual);
+
         fields.forEach(field => {
             if (field.datatype === 'bool') {
                 if (rec[field.name]() === true) {
@@ -350,13 +420,35 @@ export class FmRecordEditorComponent
 
     private getFieldList(): Promise<any> {
 
-        this.fields = this.idlDef.fields.filter(f =>
-            !f.virtual && !this.hiddenFieldsList.includes(f.name)
-        );
+        const fields = this.idlDef.fields.filter(f =>
+            !f.virtual && !this.hiddenFieldsList.includes(f.name));
 
         // Wait for all network calls to complete
         return Promise.all(
-            this.fields.map(field => this.constructOneField(field)));
+            fields.map(field => this.constructOneField(field))
+
+        ).then(() => {
+
+            if (!this.fieldOrder) {
+                this.fields = fields.sort((a, b) => a.label < b.label ? -1 : 1);
+                return;
+            }
+
+            let newList = [];
+            const ordered = this.fieldOrder.split(/,/);
+
+            ordered.forEach(name => {
+                const f1 = fields.filter(f2 => f2.name === name)[0];
+                if (f1) { newList.push(f1); }
+            });
+
+            // Sort remaining fields by label
+            const remainder = fields.filter(f => !ordered.includes(f.name));
+            remainder.sort((a, b) => a.label < b.label ? -1 : 1);
+            newList = newList.concat(remainder);
+
+            this.fields = newList;
+        });
     }
 
     private constructOneField(field: any): Promise<any> {
@@ -511,20 +603,39 @@ export class FmRecordEditorComponent
         this.convertDatatypesToIdl(recToSave);
         this.pcrud[this.mode]([recToSave]).toPromise().then(
             result => {
-                this.onSave$.emit(result);
+                this.recordSaved.emit(result);
                 this.successStr.current().then(msg => this.toast.success(msg));
                 if (this.isDialog()) { this.record = undefined; this.close(result); }
             },
             error => {
-                this.onError$.emit(error);
+                this.recordError.emit(error);
                 this.failStr.current().then(msg => this.toast.warning(msg));
                 if (this.isDialog()) { this.error(error); }
             }
         );
     }
 
+    remove() {
+        this.confirmDel.open().subscribe(confirmed => {
+            if (!confirmed) { return; }
+            const recToRemove = this.idl.clone(this.record);
+            this.pcrud.remove(recToRemove).toPromise().then(
+                result => {
+                    this.recordDeleted.emit(result);
+                    this.successStr.current().then(msg => this.toast.success(msg));
+                    if (this.isDialog()) { this.close(result); }
+                },
+                error => {
+                    this.recordError.emit(error);
+                    this.failStr.current().then(msg => this.toast.warning(msg));
+                    if (this.isDialog()) { this.error(error); }
+                }
+            );
+        });
+    }
+
     cancel() {
-        this.onCancel$.emit(this.record);
+        this.recordCanceled.emit(this.record);
         this.record = undefined;
         this.close();
     }
diff --git a/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.module.ts b/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.module.ts
new file mode 100644
index 0000000000..6e9c5c3ec8
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.module.ts
@@ -0,0 +1,30 @@
+import {NgModule} from '@angular/core';
+import {EgCommonModule} from '@eg/common.module';
+import {CommonWidgetsModule} from '@eg/share/common-widgets.module';
+import {StringModule} from '@eg/share/string/string.module';
+import {TranslateModule} from '@eg/share/translate/translate.module';
+import {FmRecordEditorComponent} from './fm-editor.component';
+import {FmRecordEditorActionComponent} from './fm-editor-action.component';
+
+
+ at NgModule({
+    declarations: [
+        FmRecordEditorComponent,
+        FmRecordEditorActionComponent
+    ],
+    imports: [
+        EgCommonModule,
+        StringModule,
+        TranslateModule,
+        CommonWidgetsModule
+    ],
+    exports: [
+        FmRecordEditorComponent,
+        FmRecordEditorActionComponent
+    ],
+    providers: [
+    ]
+})
+
+export class FmRecordEditorModule { }
+
diff --git a/Open-ILS/src/eg2/src/app/share/org-family-select/org-family-select.module.ts b/Open-ILS/src/eg2/src/app/share/org-family-select/org-family-select.module.ts
new file mode 100644
index 0000000000..ea5fc2f4c7
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/share/org-family-select/org-family-select.module.ts
@@ -0,0 +1,26 @@
+import {NgModule} from '@angular/core';
+import {EgCommonModule} from '@eg/common.module';
+import {EgCoreModule} from '@eg/core/core.module';
+import {CommonWidgetsModule} from '@eg/share/common-widgets.module';
+import {OrgFamilySelectComponent} from './org-family-select.component';
+import {ReactiveFormsModule} from '@angular/forms';
+
+ at NgModule({
+    declarations: [
+        OrgFamilySelectComponent
+    ],
+    imports: [
+        EgCommonModule,
+        EgCoreModule,
+        CommonWidgetsModule,
+        ReactiveFormsModule
+    ],
+    exports: [
+        OrgFamilySelectComponent
+    ],
+    providers: [
+    ]
+})
+
+export class OrgFamilySelectModule { }
+
diff --git a/Open-ILS/src/eg2/src/app/share/string/string.module.ts b/Open-ILS/src/eg2/src/app/share/string/string.module.ts
new file mode 100644
index 0000000000..286e6425b0
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/share/string/string.module.ts
@@ -0,0 +1,23 @@
+import {NgModule} from '@angular/core';
+import {EgCoreModule} from '@eg/core/core.module';
+import {StringComponent} from '@eg/share/string/string.component';
+import {StringService} from '@eg/share/string/string.service';
+
+
+ at NgModule({
+    declarations: [
+        StringComponent
+    ],
+    imports: [
+        EgCoreModule
+    ],
+    exports: [
+        StringComponent
+    ],
+    providers: [
+        StringService
+    ]
+})
+
+export class StringModule { }
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/translate/translate.component.html b/Open-ILS/src/eg2/src/app/share/translate/translate.component.html
similarity index 100%
rename from Open-ILS/src/eg2/src/app/staff/share/translate/translate.component.html
rename to Open-ILS/src/eg2/src/app/share/translate/translate.component.html
diff --git a/Open-ILS/src/eg2/src/app/staff/share/translate/translate.component.ts b/Open-ILS/src/eg2/src/app/share/translate/translate.component.ts
similarity index 100%
rename from Open-ILS/src/eg2/src/app/staff/share/translate/translate.component.ts
rename to Open-ILS/src/eg2/src/app/share/translate/translate.component.ts
diff --git a/Open-ILS/src/eg2/src/app/share/translate/translate.module.ts b/Open-ILS/src/eg2/src/app/share/translate/translate.module.ts
new file mode 100644
index 0000000000..41dbd5db7b
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/share/translate/translate.module.ts
@@ -0,0 +1,23 @@
+import {NgModule} from '@angular/core';
+import {EgCommonModule} from '@eg/common.module';
+import {EgCoreModule} from '@eg/core/core.module';
+import {TranslateComponent} from './translate.component';
+
+
+ at NgModule({
+    declarations: [
+        TranslateComponent
+    ],
+    imports: [
+        EgCommonModule,
+        EgCoreModule
+    ],
+    exports: [
+        TranslateComponent
+    ],
+    providers: [
+    ]
+})
+
+export class TranslateModule { }
+
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/common.module.ts b/Open-ILS/src/eg2/src/app/staff/admin/common.module.ts
index 5bd71d3efb..4bb5b30278 100644
--- a/Open-ILS/src/eg2/src/app/staff/admin/common.module.ts
+++ b/Open-ILS/src/eg2/src/app/staff/admin/common.module.ts
@@ -1,6 +1,9 @@
 import {NgModule} from '@angular/core';
 import {StaffCommonModule} from '@eg/staff/common.module';
 import {LinkTableComponent, LinkTableLinkComponent} from '@eg/staff/share/link-table/link-table.component';
+import {TranslateModule} from '@eg/share/translate/translate.module';
+import {FmRecordEditorModule} from '@eg/share/fm-editor/fm-editor.module';
+import {AdminPageModule} from '@eg/staff/share/admin-page/admin-page.module';
 import {BasicAdminPageComponent} from '@eg/staff/admin/basic-admin-page.component';
 
 @NgModule({
@@ -10,10 +13,16 @@ import {BasicAdminPageComponent} from '@eg/staff/admin/basic-admin-page.componen
     BasicAdminPageComponent
   ],
   imports: [
-    StaffCommonModule
+    StaffCommonModule,
+    TranslateModule,
+    FmRecordEditorModule,
+    AdminPageModule
   ],
   exports: [
     StaffCommonModule,
+    TranslateModule,
+    FmRecordEditorModule,
+    AdminPageModule,
     LinkTableComponent,
     LinkTableLinkComponent,
     BasicAdminPageComponent
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 65da637f14..9b14137243 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
@@ -13,6 +13,8 @@ import {ReturnComponent} from './return.component';
 import {NoTimezoneSetComponent} from './no-timezone-set.component';
 import {PatronService} from '@eg/staff/share/patron.service';
 import {BookingResourceBarcodeValidatorDirective} from './booking_resource_validator.directive';
+import {FmRecordEditorModule} from '@eg/share/fm-editor/fm-editor.module';
+import {OrgFamilySelectModule} from '@eg/share/org-family-select/org-family-select.module';
 
 
 @NgModule({
@@ -20,6 +22,8 @@ import {BookingResourceBarcodeValidatorDirective} from './booking_resource_valid
         StaffCommonModule,
         BookingRoutingModule,
         ReactiveFormsModule,
+        FmRecordEditorModule,
+        OrgFamilySelectModule
     ],
     providers: [PatronService],
     declarations: [
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 6dcfe7c683..bb38c926c4 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
@@ -375,7 +375,7 @@ export class CreateReservationComponent implements OnInit, AfterViewInit, OnDest
 
     openReservationViewer = (id: number): void => {
         this.viewReservation.mode = 'view';
-        this.viewReservation.recId = id;
+        this.viewReservation.recordId = id;
         this.viewReservation.open({ size: 'lg' });
     }
 
diff --git a/Open-ILS/src/eg2/src/app/staff/booking/reservations-grid.component.ts b/Open-ILS/src/eg2/src/app/staff/booking/reservations-grid.component.ts
index d4e39735b2..12dcc47294 100644
--- a/Open-ILS/src/eg2/src/app/staff/booking/reservations-grid.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/booking/reservations-grid.component.ts
@@ -281,7 +281,7 @@ export class ReservationsGridComponent implements OnInit {
     }
 
     showEditDialog(idlThing: IdlObject) {
-        this.editDialog.recId = idlThing.id();
+        this.editDialog.recordId = idlThing.id();
         this.editDialog.timezone = idlThing['timezone'];
         return new Promise((resolve, reject) => {
             this.editDialog.open({size: 'lg'}).subscribe(
diff --git a/Open-ILS/src/eg2/src/app/staff/cat/vandelay/match-set-list.component.ts b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/match-set-list.component.ts
index c33999a282..8efe6b5d5b 100644
--- a/Open-ILS/src/eg2/src/app/staff/cat/vandelay/match-set-list.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/match-set-list.component.ts
@@ -59,7 +59,7 @@ export class MatchSetListComponent implements AfterViewInit {
         this.grid.onRowActivate.subscribe(
             (matchSet: IdlObject) => {
                 this.editDialog.mode = 'update';
-                this.editDialog.recId = matchSet.id();
+                this.editDialog.recordId = matchSet.id();
                 this.editDialog.open({size: 'lg'})
                     .subscribe(() => this.grid.reload());
             }
diff --git a/Open-ILS/src/eg2/src/app/staff/cat/vandelay/vandelay.module.ts b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/vandelay.module.ts
index 9bbfd46a26..0f394285e3 100644
--- a/Open-ILS/src/eg2/src/app/staff/cat/vandelay/vandelay.module.ts
+++ b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/vandelay.module.ts
@@ -1,8 +1,10 @@
 import {NgModule} from '@angular/core';
+import {FmRecordEditorModule} from '@eg/share/fm-editor/fm-editor.module';
 import {StaffCommonModule} from '@eg/staff/common.module';
 import {CatalogCommonModule} from '@eg/share/catalog/catalog-common.module';
 import {HttpClientModule} from '@angular/common/http';
 import {TreeModule} from '@eg/share/tree/tree.module';
+import {AdminPageModule} from '@eg/staff/share/admin-page/admin-page.module';
 import {VandelayRoutingModule} from './routing.module';
 import {VandelayService} from './vandelay.service';
 import {VandelayComponent} from './vandelay.component';
@@ -48,6 +50,8 @@ import {RecentImportsComponent} from './recent-imports.component';
   imports: [
     TreeModule,
     StaffCommonModule,
+    FmRecordEditorModule,
+    AdminPageModule,
     CatalogCommonModule,
     VandelayRoutingModule,
     HttpClientModule,
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/catalog.module.ts b/Open-ILS/src/eg2/src/app/staff/catalog/catalog.module.ts
index 064c21553c..d56168908a 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/catalog.module.ts
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/catalog.module.ts
@@ -1,4 +1,5 @@
 import {NgModule} from '@angular/core';
+import {FmRecordEditorModule} from '@eg/share/fm-editor/fm-editor.module';
 import {StaffCommonModule} from '@eg/staff/common.module';
 import {CatalogCommonModule} from '@eg/share/catalog/catalog-common.module';
 import {CatalogRoutingModule} from './routing.module';
@@ -55,6 +56,7 @@ import {MarcEditModule} from '@eg/staff/share/marc-edit/marc-edit.module';
   ],
   imports: [
     StaffCommonModule,
+    FmRecordEditorModule,
     CatalogCommonModule,
     CatalogRoutingModule,
     HoldsModule,
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/parts.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/record/parts.component.ts
index 2f59374084..9766c7f8d3 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/record/parts.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/parts.component.ts
@@ -80,7 +80,7 @@ export class PartsComponent implements OnInit {
         this.partsGrid.onRowActivate.subscribe(
             (part: IdlObject) => {
                 this.editDialog.mode = 'update';
-                this.editDialog.recId = part.id();
+                this.editDialog.recordId = part.id();
                 this.editDialog.open()
                     .subscribe(ok => this.partsGrid.reload());
             }
@@ -89,7 +89,7 @@ export class PartsComponent implements OnInit {
         this.createNew = () => {
 
             const part = this.idl.create('bmp');
-            part.record(this.recId);
+            part.record(this.recordId);
             this.editDialog.record = part;
 
             this.editDialog.mode = 'create';
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 458b0f5ffb..c0fe41b621 100644
--- a/Open-ILS/src/eg2/src/app/staff/common.module.ts
+++ b/Open-ILS/src/eg2/src/app/staff/common.module.ts
@@ -4,17 +4,13 @@ import {CommonWidgetsModule} from '@eg/share/common-widgets.module';
 import {AudioService} from '@eg/share/util/audio.service';
 import {GridModule} from '@eg/share/grid/grid.module';
 import {StaffBannerComponent} from './share/staff-banner.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';
 import {OpChangeComponent} from '@eg/staff/share/op-change/op-change.component';
 import {TitleComponent} from '@eg/share/title/title.component';
-import {FmRecordEditorComponent} from '@eg/share/fm-editor/fm-editor.component';
 import {BucketDialogComponent} from '@eg/staff/share/buckets/bucket-dialog.component';
 import {BibSummaryComponent} from '@eg/staff/share/bib-summary/bib-summary.component';
-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 {DatetimeValidatorDirective} from '@eg/share/validators/datetime_validator.directive';
 import {MultiSelectComponent} from '@eg/share/multi-select/multi-select.component';
@@ -28,16 +24,12 @@ import {PatronBarcodeValidatorDirective} from '@eg/share/validators/patron_barco
 @NgModule({
   declarations: [
     StaffBannerComponent,
-    OrgFamilySelectComponent,
     AccessKeyDirective,
     AccessKeyInfoComponent,
     TitleComponent,
     OpChangeComponent,
-    FmRecordEditorComponent,
     BucketDialogComponent,
     BibSummaryComponent,
-    TranslateComponent,
-    AdminPageComponent,
     EgHelpPopoverComponent,
     DatetimeValidatorDirective,
     MultiSelectComponent,
@@ -54,16 +46,12 @@ import {PatronBarcodeValidatorDirective} from '@eg/share/validators/patron_barco
     CommonWidgetsModule,
     GridModule,
     StaffBannerComponent,
-    OrgFamilySelectComponent,
     AccessKeyDirective,
     AccessKeyInfoComponent,
     TitleComponent,
     OpChangeComponent,
-    FmRecordEditorComponent,
     BucketDialogComponent,
     BibSummaryComponent,
-    TranslateComponent,
-    AdminPageComponent,
     EgHelpPopoverComponent,
     DatetimeValidatorDirective,
     MultiSelectComponent,
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 88716f30bb..ee46d1eb54 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
@@ -33,9 +33,10 @@
   <!-- note: fieldOptions would be best defined in the .ts file, but
       want to demostrate it can be set in the template as well -->
   <eg-fm-record-editor #fmRecordEditor 
-      idlClass="cmrcfld" mode="create" 
-      [fieldOptions]="{marc_record_type:{customValues:[{id:'biblio'},{id:'serial'},{id:'authority'}]},description:{customTemplate:{template:descriptionTemplate,context:{'hello':'goodbye'}}}}"
-      recordId="1" orgDefaultAllowed="owner">
+    idlClass="cmrcfld" mode="create" hiddenFields="id"
+    fieldOrder="owner,name,description,marc_format,marc_record_type,tag"
+    [fieldOptions]="{marc_record_type:{customValues:[{id:'biblio'},{id:'serial'},{id:'authority'}]},description:{customTemplate:{template:descriptionTemplate,context:{'hello':'goodbye'}}}}"
+    recordId="1" orgDefaultAllowed="owner">
   </eg-fm-record-editor>
   <button class="btn btn-dark" (click)="openEditor()">
       Fm Record Editor
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 7b17c2d33c..3557532d4f 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
@@ -385,7 +385,7 @@ export class SandboxComponent implements OnInit {
 
     showEditDialog(idlThing: IdlObject): Promise<any> {
         this.editDialog.mode = 'update';
-        this.editDialog.recId = idlThing['id']();
+        this.editDialog.recordId = idlThing['id']();
         return new Promise((resolve, reject) => {
             this.editDialog.open({size: 'lg'}).subscribe(
                 ok => {
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 0fc739e7cf..a33deb86ce 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
@@ -1,9 +1,12 @@
 import {NgModule} from '@angular/core';
+import {FmRecordEditorModule} from '@eg/share/fm-editor/fm-editor.module';
 import {StaffCommonModule} from '@eg/staff/common.module';
+import {TranslateModule} from '@eg/share/translate/translate.module';
 import {SandboxRoutingModule} from './routing.module';
 import {SandboxComponent} from './sandbox.component';
 import {ReactiveFormsModule} from '@angular/forms';
 import {SampleDataService} from '@eg/share/util/sample-data.service';
+import {OrgFamilySelectModule} from '@eg/share/org-family-select/org-family-select.module';
 
 @NgModule({
   declarations: [
@@ -11,6 +14,9 @@ import {SampleDataService} from '@eg/share/util/sample-data.service';
   ],
   imports: [
     StaffCommonModule,
+    TranslateModule,
+    FmRecordEditorModule,
+    OrgFamilySelectModule,
     SandboxRoutingModule,
     ReactiveFormsModule
   ],
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 b853a6aef5..9c76b4d32b 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
@@ -3,7 +3,7 @@ import {ActivatedRoute} from '@angular/router';
 import {IdlService, IdlObject} from '@eg/core/idl.service';
 import {GridDataSource} from '@eg/share/grid/grid';
 import {GridComponent} from '@eg/share/grid/grid.component';
-import {TranslateComponent} from '@eg/staff/share/translate/translate.component';
+import {TranslateComponent} from '@eg/share/translate/translate.component';
 import {ToastService} from '@eg/share/toast/toast.service';
 import {Pager} from '@eg/share/util/pager';
 import {PcrudService} from '@eg/core/pcrud.service';
@@ -246,7 +246,7 @@ export class AdminPageComponent implements OnInit {
 
     showEditDialog(idlThing: IdlObject): Promise<any> {
         this.editDialog.mode = 'update';
-        this.editDialog.recId = idlThing[this.pkeyField]();
+        this.editDialog.recordId = idlThing[this.pkeyField]();
         return new Promise((resolve, reject) => {
             this.editDialog.open({size: this.dialogSize}).subscribe(
                 result => {
@@ -297,7 +297,7 @@ export class AdminPageComponent implements OnInit {
         this.editDialog.mode = 'create';
         // We reuse the same editor for all actions.  Be sure
         // create action does not try to modify an existing record.
-        this.editDialog.recId = null;
+        this.editDialog.recordId = null;
         this.editDialog.record = null;
         this.editDialog.open({size: this.dialogSize}).subscribe(
             ok => {
diff --git a/Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.module.ts b/Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.module.ts
new file mode 100644
index 0000000000..af6df24104
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.module.ts
@@ -0,0 +1,34 @@
+import {NgModule} from '@angular/core';
+import {EgCommonModule} from '@eg/common.module';
+import {EgCoreModule} from '@eg/core/core.module';
+import {GridModule} from '@eg/share/grid/grid.module';
+import {StringModule} from '@eg/share/string/string.module';
+import {TranslateModule} from '@eg/share/translate/translate.module';
+import {FmRecordEditorModule} from '@eg/share/fm-editor/fm-editor.module';
+import {AdminPageComponent} from './admin-page.component';
+import {OrgFamilySelectModule} from '@eg/share/org-family-select/org-family-select.module';
+
+
+ at NgModule({
+    declarations: [
+        AdminPageComponent
+    ],
+    imports: [
+        EgCommonModule,
+        EgCoreModule,
+        StringModule,
+        OrgFamilySelectModule,
+        TranslateModule,
+        FmRecordEditorModule,
+        GridModule
+    ],
+    exports: [
+        OrgFamilySelectModule,
+        AdminPageComponent
+    ],
+    providers: [
+    ]
+})
+
+export class AdminPageModule { }
+

commit 296dca77f83f01bd78783ae27817c8ae84bbef29
Author: Bill Erickson <berickxx at gmail.com>
Date:   Fri Aug 16 16:56:42 2019 -0400

    LP1840050 Org select sanity checks on selected value
    
    Avoid use of org-select 'selected' value when it's not defined/null.
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>

diff --git a/Open-ILS/src/eg2/src/app/share/org-select/org-select.component.html b/Open-ILS/src/eg2/src/app/share/org-select/org-select.component.html
index d49217c6fb..32bc94e2b9 100644
--- a/Open-ILS/src/eg2/src/app/share/org-select/org-select.component.html
+++ b/Open-ILS/src/eg2/src/app/share/org-select/org-select.component.html
@@ -4,7 +4,7 @@
 {{r.label}}
 </ng-template>
 
-<ng-container *ngIf="readOnly">
+<ng-container *ngIf="readOnly && selected">
   <span>{{selected.label}}</span>
 </ng-container>
 
diff --git a/Open-ILS/src/eg2/src/app/share/org-select/org-select.component.ts b/Open-ILS/src/eg2/src/app/share/org-select/org-select.component.ts
index dee3248939..dc14debcbf 100644
--- a/Open-ILS/src/eg2/src/app/share/org-select/org-select.component.ts
+++ b/Open-ILS/src/eg2/src/app/share/org-select/org-select.component.ts
@@ -182,7 +182,7 @@ export class OrgSelectComponent implements OnInit {
     }
 
     // Remove the tree-padding spaces when matching.
-    formatter = (result: OrgDisplay) => result.label.trim();
+    formatter = (result: OrgDisplay) => result ? result.label.trim() : '';
 
     // reset the state of the component
     reset() {

commit 028e70f6916a872a0b7b8c0fd73cbae70ed03648
Author: Bill Erickson <berickxx at gmail.com>
Date:   Fri Aug 16 16:52:50 2019 -0400

    LP1840050 Combobox responds favoribly to value changes
    
    Using the power of getters/setters, teach the combobox to respond to
    updates of the selected entry, particulary when the selected value is
    applied before the set of underlying entries is modified.
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>

diff --git a/Open-ILS/src/eg2/src/app/share/combobox/combobox.component.ts b/Open-ILS/src/eg2/src/app/share/combobox/combobox.component.ts
index 85225faa41..c98da6cd93 100644
--- a/Open-ILS/src/eg2/src/app/share/combobox/combobox.component.ts
+++ b/Open-ILS/src/eg2/src/app/share/combobox/combobox.component.ts
@@ -70,6 +70,26 @@ export class ComboboxComponent implements ControlValueAccessor, OnInit {
     @Input() startId: any = null;
     @Input() startIdFiresOnChange: boolean;
 
+    // Allow the selected entry ID to be passed via the template
+    // This does NOT not emit onChange events.
+    @Input() set selectedId(id: any) {
+        if (id) {
+            if (this.entrylist.length) {
+                this.selected = this.entrylist.filter(e => e.id === id)[0];
+            }
+
+            if (!this.selected) {
+                // It's possible the selected ID lives in a set of entries
+                // that are yet to be provided.
+                this.startId = id;
+            }
+        }
+    }
+
+    get selectedId(): any {
+        return this.selected ? this.selected.id : null;
+    }
+
     @Input() idlClass: string;
     @Input() idlField: string;
     @Input() idlIncludeLibraryInLabel: string;
@@ -90,6 +110,9 @@ export class ComboboxComponent implements ControlValueAccessor, OnInit {
     @Input() set entries(el: ComboboxEntry[]) {
         if (el) {
             this.entrylist = el;
+
+            // new set of entries essentially means a new instance. reset.
+            this.defaultSelectionApplied = false;
             this.applySelection();
 
             // It's possible to provide an entrylist at load time, but
@@ -212,6 +235,7 @@ export class ComboboxComponent implements ControlValueAccessor, OnInit {
 
     // Manually set the selected value by ID.
     // This does NOT fire the onChange handler.
+    // DEPRECATED: use this.selectedId = abc or [selectedId]="abc" instead.
     applyEntryId(entryId: any) {
         this.selected = this.entrylist.filter(e => e.id === entryId)[0];
     }

commit 529d556773255b1b2d86241a9d1d4d528d059d37
Author: Bill Erickson <berickxx at gmail.com>
Date:   Fri Aug 16 16:50:34 2019 -0400

    LP1840050 Anguar tree component realtime updates
    
    Teach the Angular tree component to re-render itself when its underyling
    tree has been replaced.
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>

diff --git a/Open-ILS/src/eg2/src/app/share/tree/tree.component.ts b/Open-ILS/src/eg2/src/app/share/tree/tree.component.ts
index f519268f46..64dd298215 100644
--- a/Open-ILS/src/eg2/src/app/share/tree/tree.component.ts
+++ b/Open-ILS/src/eg2/src/app/share/tree/tree.component.ts
@@ -37,7 +37,18 @@ nodeClicked(node: TreeNode) {
 })
 export class TreeComponent implements OnInit {
 
-    @Input() tree: Tree;
+    _tree: Tree;
+    @Input() set tree(t: Tree) {
+        if (t) {
+            this._tree = t;
+            this._tree.nodeList(); // reindex nodes
+        }
+    }
+
+    get tree(): Tree {
+        return this._tree;
+    }
+
     @Output() nodeClicked: EventEmitter<TreeNode>;
 
     constructor() {

commit 5a6a7a9e593a8d19792ba07d10dbc49e9a6a2c84
Author: Bill Erickson <berickxx at gmail.com>
Date:   Fri Aug 16 16:48:51 2019 -0400

    LP1840050 IDL pkeyMatches function/tests
    
    Function to compare whether two IdlObjects are of the same class and
    have matching pkey values.
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>

diff --git a/Open-ILS/src/eg2/src/app/core/idl.service.ts b/Open-ILS/src/eg2/src/app/core/idl.service.ts
index 21ec24a274..b5abf61d78 100644
--- a/Open-ILS/src/eg2/src/app/core/idl.service.ts
+++ b/Open-ILS/src/eg2/src/app/core/idl.service.ts
@@ -196,5 +196,14 @@ export class IdlService {
 
         return hash;
     }
+
+    // Returns true if both objects have the same IDL class and pkey value.
+    pkeyMatches(obj1: IdlObject, obj2: IdlObject) {
+        if (!obj1 || !obj2) { return false; }
+        const idlClass = obj1.classname;
+        if (idlClass !== obj2.classname) { return false; }
+        const pkeyField = this.classes[idlClass].pkey || 'id';
+        return obj1[pkeyField]() === obj2[pkeyField]();
+    }
 }
 
diff --git a/Open-ILS/src/eg2/src/app/core/idl.spec.ts b/Open-ILS/src/eg2/src/app/core/idl.spec.ts
index 8138bf453c..49439fdb10 100644
--- a/Open-ILS/src/eg2/src/app/core/idl.spec.ts
+++ b/Open-ILS/src/eg2/src/app/core/idl.spec.ts
@@ -24,5 +24,33 @@ describe('IdlService', () => {
         expect(org.name()).toBe('AN ORG');
     });
 
+    it('should correctly compare IDL pkey values', () => {
+        service.parseIdl();
+        const org1 = service.create('aou');
+        const org2 = service.create('aou');
+        org1.id(123);
+        org2.id(123);
+        expect(service.pkeyMatches(org1, org2)).toBe(true);
+    });
+
+    it('should correctly compare IDL pkey values', () => {
+        service.parseIdl();
+        const org1 = service.create('aou');
+        const org2 = service.create('aou');
+        org1.id(123);
+        org2.id(456);
+        expect(service.pkeyMatches(org1, org2)).toBe(false);
+    });
+
+    it('should correctly compare IDL classes in pkey match', () => {
+        service.parseIdl();
+        const org = service.create('aou');
+        const user = service.create('au');
+        org.id(123);
+        user.id(123);
+        expect(service.pkeyMatches(org, user)).toBe(false);
+    });
+
+
 });
 

commit 286ec1d75e2a8ec053119c1aec46e72e46128ec6
Author: Bill Erickson <berickxx at gmail.com>
Date:   Fri Aug 16 16:43:46 2019 -0400

    LP1840050 IDL Org and Org Address additions
    
    Adds missing 'required' attributes to org_unit and org_address and adds
    labels for org_address fields.
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>

diff --git a/Open-ILS/examples/fm_IDL.xml b/Open-ILS/examples/fm_IDL.xml
index 0dfe7831e7..b8fde1a4c6 100644
--- a/Open-ILS/examples/fm_IDL.xml
+++ b/Open-ILS/examples/fm_IDL.xml
@@ -6206,18 +6206,18 @@ SELECT  usr,
 	</class>
 	<class id="aoa" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="actor::org_address" oils_persist:tablename="actor.org_address" reporter:label="Org Address">
 		<fields oils_persist:primary="id" oils_persist:sequence="actor.org_address_id_seq">
-			<field name="address_type"  reporter:datatype="text"/>
-			<field name="city"  reporter:datatype="text"/>
-			<field name="country"  reporter:datatype="text"/>
-			<field name="county"  reporter:datatype="text"/>
-			<field name="id" reporter:datatype="id" />
-			<field name="org_unit" reporter:datatype="org_unit"/>
-			<field name="post_code"  reporter:datatype="text"/>
-			<field name="state"  reporter:datatype="text"/>
-			<field name="street1"  reporter:datatype="text"/>
-			<field name="street2"  reporter:datatype="text"/>
-			<field name="valid" reporter:datatype="bool"/>
-			<field name="san" reporter:datatype="text" reporter:label="SAN"/>
+			<field name="address_type" reporter:label="Address Type" reporter:datatype="text" oils_obj:required="true" />
+			<field name="city" reporter:label="City" reporter:datatype="text" oils_obj:required="true"/>
+			<field name="country" reporter:label="Country" reporter:datatype="text" oils_obj:required="true"/>
+			<field name="county" reporter:label="County" reporter:datatype="text"/>
+			<field name="id" reporter:label="ID" reporter:datatype="id" />
+			<field name="org_unit" reporter:label="Org Unit" reporter:datatype="org_unit" oils_obj:required="true"/>
+			<field name="post_code" reporter:label="Post Code" reporter:datatype="text" oils_obj:required="true"/>
+			<field name="state" reporter:label="State" reporter:datatype="text"/>
+			<field name="street1" reporter:label="Street1" reporter:datatype="text" oils_obj:required="true"/>
+			<field name="street2" reporter:label="Street2" reporter:datatype="text"/>
+			<field name="valid" reporter:label="Is Valid?" reporter:datatype="bool" oils_obj:required="true"/>
+			<field name="san" reporter:label="SAN" reporter:datatype="text"/>
 		</fields>
 		<links>
 			<link field="org_unit" reltype="has_a" key="id" map="" class="aou"/>
@@ -6743,14 +6743,14 @@ SELECT  usr,
 			<field reporter:label="Organizational Unit ID" name="id" reporter:datatype="org_unit" reporter:selector="shortname"/>
 			<field reporter:label="ILL Receiving Address" name="ill_address" reporter:datatype="link"/>
 			<field reporter:label="Mailing Address" name="mailing_address" reporter:datatype="link"/>
-			<field reporter:label="Name" name="name" reporter:datatype="text" oils_persist:i18n="true"/>
-			<field reporter:label="Organizational Unit Type" name="ou_type" reporter:datatype="link"/>
+			<field reporter:label="Name" name="name" reporter:datatype="text" oils_persist:i18n="true" oils_obj:required="true"/>
+			<field reporter:label="Organizational Unit Type" name="ou_type" reporter:datatype="link" oils_obj:required="true"/>
 			<field reporter:label="Parent Organizational Unit" name="parent_ou" reporter:datatype="link"/>
 			<field reporter:label="Short (Policy) Name" name="shortname" reporter:datatype="text" oils_obj:required="true" oils_obj:validate="^.+$"/>
 			<field reporter:label="Email Address" name="email" reporter:datatype="text"/>
 			<field reporter:label="Phone Number" name="phone" reporter:datatype="text"/>
 			<field reporter:label="OPAC Visible" name="opac_visible" reporter:datatype="bool"/>
-			<field reporter:label="Fiscal Calendar" name="fiscal_calendar" reporter:datatype="link"/>
+			<field reporter:label="Fiscal Calendar" name="fiscal_calendar" reporter:datatype="link" oils_obj:required="true"/>
 			<field reporter:label="Users" name="users" oils_persist:virtual="true" reporter:datatype="link"/>
 			<field reporter:label="Closed Dates" name="closed_dates" oils_persist:virtual="true" reporter:datatype="link"/>
 			<field reporter:label="Circulations" name="circulations" oils_persist:virtual="true" reporter:datatype="link"/>

-----------------------------------------------------------------------

Summary of changes:
 Open-ILS/examples/fm_IDL.xml                       |  30 +--
 Open-ILS/src/eg2/src/app/common.module.ts          |  12 +-
 Open-ILS/src/eg2/src/app/core/idl.service.ts       |   9 +
 Open-ILS/src/eg2/src/app/core/idl.spec.ts          |  28 +++
 Open-ILS/src/eg2/src/app/core/org.service.ts       |  23 +-
 .../src/app/share/combobox/combobox.component.ts   |  24 ++
 .../share/fm-editor/fm-editor-action.component.ts  |  31 +++
 .../app/share/fm-editor/fm-editor.component.html   |  22 +-
 .../src/app/share/fm-editor/fm-editor.component.ts | 177 ++++++++++---
 .../src/app/share/fm-editor/fm-editor.module.ts    |  30 +++
 .../org-family-select/org-family-select.module.ts  |  26 ++
 .../app/share/org-select/org-select.component.html |   2 +-
 .../app/share/org-select/org-select.component.ts   |   2 +-
 .../src/eg2/src/app/share/string/string.module.ts  |  23 ++
 .../share/translate/translate.component.html       |   0
 .../share/translate/translate.component.ts         |   0
 .../src/app/share/translate/translate.module.ts    |  23 ++
 .../src/eg2/src/app/share/tree/tree.component.ts   |  13 +-
 .../src/eg2/src/app/staff/admin/common.module.ts   |  11 +-
 .../server/admin-server-splash.component.html      |   2 +-
 .../app/staff/admin/server/admin-server.module.ts  |   2 +-
 .../app/staff/admin/server/org-addr.component.html |  36 +++
 .../app/staff/admin/server/org-addr.component.ts   | 163 ++++++++++++
 .../staff/admin/server/org-unit-routing.module.ts  |  19 ++
 .../app/staff/admin/server/org-unit.component.html | 125 ++++++++++
 .../app/staff/admin/server/org-unit.component.ts   | 274 +++++++++++++++++++++
 .../src/app/staff/admin/server/org-unit.module.ts  |  27 ++
 .../src/app/staff/admin/server/routing.module.ts   |   3 +
 .../eg2/src/app/staff/booking/booking.module.ts    |   4 +
 .../staff/booking/create-reservation.component.ts  |   2 +-
 .../staff/booking/reservations-grid.component.ts   |   2 +-
 .../staff/cat/vandelay/match-set-list.component.ts |   2 +-
 .../src/app/staff/cat/vandelay/vandelay.module.ts  |   4 +
 .../eg2/src/app/staff/catalog/catalog.module.ts    |   2 +
 .../app/staff/catalog/record/parts.component.ts    |   4 +-
 Open-ILS/src/eg2/src/app/staff/common.module.ts    |  12 -
 .../src/app/staff/sandbox/sandbox.component.html   |   7 +-
 .../eg2/src/app/staff/sandbox/sandbox.component.ts |   2 +-
 .../eg2/src/app/staff/sandbox/sandbox.module.ts    |   6 +
 .../staff/share/admin-page/admin-page.component.ts |   6 +-
 .../staff/share/admin-page/admin-page.module.ts    |  34 +++
 .../src/app/staff/share/staff-banner.component.ts  |  10 +-
 .../Administration/org-unit-admin.adoc             |   6 +
 43 files changed, 1140 insertions(+), 100 deletions(-)
 create mode 100644 Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor-action.component.ts
 create mode 100644 Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.module.ts
 create mode 100644 Open-ILS/src/eg2/src/app/share/org-family-select/org-family-select.module.ts
 create mode 100644 Open-ILS/src/eg2/src/app/share/string/string.module.ts
 rename Open-ILS/src/eg2/src/app/{staff => }/share/translate/translate.component.html (100%)
 rename Open-ILS/src/eg2/src/app/{staff => }/share/translate/translate.component.ts (100%)
 create mode 100644 Open-ILS/src/eg2/src/app/share/translate/translate.module.ts
 create mode 100644 Open-ILS/src/eg2/src/app/staff/admin/server/org-addr.component.html
 create mode 100644 Open-ILS/src/eg2/src/app/staff/admin/server/org-addr.component.ts
 create mode 100644 Open-ILS/src/eg2/src/app/staff/admin/server/org-unit-routing.module.ts
 create mode 100644 Open-ILS/src/eg2/src/app/staff/admin/server/org-unit.component.html
 create mode 100644 Open-ILS/src/eg2/src/app/staff/admin/server/org-unit.component.ts
 create mode 100644 Open-ILS/src/eg2/src/app/staff/admin/server/org-unit.module.ts
 create mode 100644 Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.module.ts
 create mode 100644 docs/RELEASE_NOTES_NEXT/Administration/org-unit-admin.adoc


hooks/post-receive
-- 
Evergreen ILS


More information about the open-ils-commits mailing list