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

Evergreen Git git at git.evergreen-ils.org
Fri Feb 21 11:45:53 EST 2020


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  81a018c686c96385d358d378609b200532b51be0 (commit)
       via  4155a7a2abeffe73db0403a25f7a514514090ba3 (commit)
       via  4325faeb1271cfa7c087c0ec890d2a0a765d5d04 (commit)
       via  1e35be4827254524e229ead11f89c5de1340113d (commit)
       via  63c7000c3d4166a7b832ee5e28a399dc555401a1 (commit)
       via  0df5d1f8f7e0d907489feaa0156b966440310fb3 (commit)
       via  28ecd418244d2974498470d7cbe45a525df14e27 (commit)
       via  c6b0494759c0755654fcdf9125cd5717361e0167 (commit)
       via  e01c8ad84163468fe9c76584e56c90382fe46069 (commit)
       via  d1ea5bc27b6b0cc67fd3d99561da5dc69178cf18 (commit)
       via  de3d6edee1cc948238c443772ff9b278d541a1c1 (commit)
       via  9194d9c4bc99f9c8f13632ef6f50e72793f392d1 (commit)
       via  4717051494a67d70f1cd7e604c5df91b66b56f94 (commit)
       via  7dc6adfcca7e56b53906adb7110a25545483d1e1 (commit)
       via  54032550a5db3690cea5a75b3c183d5e690b08b2 (commit)
       via  5eb9013776160b9cc24cc8653f63859bca418c82 (commit)
       via  abd661656a4d3308aca3aa5ed4a5f6a408322502 (commit)
       via  37885d3a0ba1008747f674387530d0497b1a6992 (commit)
       via  fa921ae9d3cc1e418e85de379274588afb393eea (commit)
       via  48ad50466df4ef921036216a15f8c8f22254d2a3 (commit)
       via  3187adcc2004f3fef0756f106b6fb104dd668ca6 (commit)
       via  7ed43007e22b07c7debe881aa74b20a3471d84fe (commit)
       via  fad0e712ccba16dde72eb26352c3c6013a58057b (commit)
       via  cad0d77286bf4d7d3b2686f3a4030e2640b52078 (commit)
       via  9f3c2229737fc8170a0bfc721b7d39bf04e5ecd7 (commit)
       via  7f83d0319fe961edb9478d5f26a93f6683153ea1 (commit)
       via  90d93ea18314597f7a31a0450f8f7f652d26864e (commit)
       via  6343177c6f4dda71016976b17389e75ded7a2fcb (commit)
       via  95331c22c6c0a7557404dd0185ead17e120759ba (commit)
       via  393373cc1509d8e4b0056b42c2ab9397571072fe (commit)
       via  dfe56547d235d65298ab4e4322621a1afba28f30 (commit)
       via  f9fd5df8f4f83e043792c7934c249fbc4ef0d0a0 (commit)
       via  4162057c4e291163a9cda692f63f0777b21fc3d1 (commit)
       via  992402e003347e4e06e53a13b0801677b858eb1b (commit)
       via  c83db17612f518ca8b6468aa9d1903dcf822ba2b (commit)
       via  1ee7ef92ab92879b44f50d84ea41bab53882bbd1 (commit)
       via  41231d34639ac05d8b811ac5afa87e1899d4bd62 (commit)
       via  73e7e0c54d082af4d408caab8465e2654f8825e0 (commit)
       via  22e046d33c8bfce2c59f9de075c72ee0b4d9bdbd (commit)
       via  086f54a1e8d56bc7fc8f649b44eddae6dae12e7c (commit)
      from  a3cd173a646097fdd09d3b13ae489fbcca41ba99 (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 81a018c686c96385d358d378609b200532b51be0
Author: Bill Erickson <berickxx at gmail.com>
Date:   Mon Jan 6 11:05:52 2020 -0500

    LP1850546 Record detail shelf browse
    
    Adds support for browsing call numbers directly from a record detail
    page, similar to the TPAC's 'Shelf Browser' tab in its detail page.
    
    Add support for jumping to a record detail page or a new author search
    from each shelf browse entry.
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Ruth Frasur <rfrasur at gmail.com>

diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/catalog.service.ts b/Open-ILS/src/eg2/src/app/staff/catalog/catalog.service.ts
index 05f58814bf..e46c1b4a1a 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/catalog.service.ts
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/catalog.service.ts
@@ -5,6 +5,7 @@ import {OrgService} from '@eg/core/org.service';
 import {CatalogService} from '@eg/share/catalog/catalog.service';
 import {CatalogUrlService} from '@eg/share/catalog/catalog-url.service';
 import {CatalogSearchContext} from '@eg/share/catalog/search-context';
+import {BibRecordSummary} from '@eg/share/catalog/bib-record.service';
 
 /**
  * Shared bits needed by the staff version of the catalog.
@@ -122,6 +123,16 @@ export class StaffCatalogService {
         params.ridx = '' + this.routeIndex++; // see comments above
         this.router.navigate(['/staff/catalog/cnbrowse'], {queryParams: params});
     }
+
+    // Params to genreate a new author search based on a reset
+    // clone of the current page params.
+    getAuthorSearchParams(summary: BibRecordSummary): any {
+        const tmpContext = this.cloneContext(this.searchContext);
+        tmpContext.reset();
+        tmpContext.termSearch.fieldClass = ['author'];
+        tmpContext.termSearch.query = [summary.display.author];
+        return this.catUrl.toUrlParams(tmpContext);
+    }
 }
 
 
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/cnbrowse/results.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/cnbrowse/results.component.html
index 4aff584464..4a3da083f9 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/cnbrowse/results.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/cnbrowse/results.component.html
@@ -25,8 +25,9 @@
 <!-- header, pager, and list of records -->
 <div id="staff-catalog-browse-results-container" *ngIf="browseHasResults()">
 
-  <div class="row mb-2">
-    <div class="col-lg-3">
+  <div class="row mb-2 d-flex">
+    <div class="flex-1"></div>
+    <div>
       <button class="btn btn-primary" (click)="prevPage()">Back</button>
       <button class="btn btn-primary ml-3" (click)="nextPage()">Next</button>
     </div>
@@ -44,8 +45,18 @@
                 {{callNumber.suffix().label()}}
                 @ {{orgName(callNumber.owning_lib())}}
               </div>
-              <div>{{callNumber._bibSummary.display.title}}</div>
-              <div>{{callNumber._bibSummary.display.author}}</div>
+              <div>
+                <a queryParamsHandling="merge"
+                  routerLink="/staff/catalog/record/{{callNumber._bibSummary.id}}">
+                  {{callNumber._bibSummary.display.title}}
+                </a>
+              </div>
+              <div>
+                <a routerLink="/staff/catalog/search"
+                  [queryParams]="getAuthorSearchParams(callNumber._bibSummary)">
+                  {{callNumber._bibSummary.display.author}}
+                </a>
+              </div>
             </div>
             <div class="ml-2">
               <img src="/opac/extras/ac/jacket/small/r/{{callNumber._bibSummary.id}}"/>
@@ -56,14 +67,13 @@
     </div>
   </ng-container>
 
-
-  <div class="row mb-2">
-    <div class="col-lg-3">
+  <div class="row mb-2 d-flex">
+    <div class="flex-1"></div>
+    <div>
       <button class="btn btn-primary" (click)="prevPage()">Back</button>
       <button class="btn btn-primary ml-3" (click)="nextPage()">Next</button>
     </div>
   </div>
-
 </div>
 
 
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/cnbrowse/results.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/cnbrowse/results.component.ts
index bddf40efd1..464f443a1f 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/cnbrowse/results.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/cnbrowse/results.component.ts
@@ -2,6 +2,7 @@ import {Component, Input, OnInit, OnDestroy} from '@angular/core';
 import {ActivatedRoute, Router, ParamMap} from '@angular/router';
 import {Subscription} from 'rxjs';
 import {IdlObject} from '@eg/core/idl.service';
+import {PcrudService} from '@eg/core/pcrud.service';
 import {CatalogService} from '@eg/share/catalog/catalog.service';
 import {BibRecordService} from '@eg/share/catalog/bib-record.service';
 import {CatalogUrlService} from '@eg/share/catalog/catalog-url.service';
@@ -16,6 +17,9 @@ import {OrgService} from '@eg/core/org.service';
 })
 export class CnBrowseResultsComponent implements OnInit, OnDestroy {
 
+    // If set, this is a bib-focused browse
+    @Input() bibSummary: BibRecordSummary;
+
     @Input() rowCount = 5;
     rowIndexList: number[] = [];
 
@@ -23,13 +27,18 @@ export class CnBrowseResultsComponent implements OnInit, OnDestroy {
     colCount = 3;
 
     searchContext: CatalogSearchContext;
-    results: any[];
+    results: any[] = [];
     routeSub: Subscription;
 
+    // When browsing by a specific record, keep tabs on the initial
+    // browse call number.
+    browseCn: string;
+
     constructor(
         private router: Router,
         private route: ActivatedRoute,
         private org: OrgService,
+        private pcrud: PcrudService,
         private cat: CatalogService,
         private bib: BibRecordService,
         private catUrl: CatalogUrlService,
@@ -43,20 +52,53 @@ export class CnBrowseResultsComponent implements OnInit, OnDestroy {
             this.rowIndexList.push(idx);
         }
 
-        this.routeSub = this.route.queryParamMap.subscribe(
-            (params: ParamMap) => this.browseByUrl(params)
-        );
+        let promise = Promise.resolve();
+        if (this.bibSummary) {
+            promise = this.getBrowseCallnumber();
+        }
+
+        promise.then(_ => {
+            this.routeSub = this.route.queryParamMap.subscribe(
+                (params: ParamMap) => this.browseByUrl(params)
+            );
+        });
     }
 
     ngOnDestroy() {
         this.routeSub.unsubscribe();
     }
 
+    getBrowseCallnumber(): Promise<any> {
+        let org = this.searchContext.searchOrg.id();
+
+        if (this.searchContext.searchOrg.ou_type().can_have_vols() === 'f') {
+            // If the current search org unit cannot hold volumes, search
+            // across child org units.
+            org = this.org.descendants(this.searchContext.searchOrg, true);
+        }
+
+        return this.pcrud.search('acn',
+            {record: this.bibSummary.id, owning_lib: org},
+            {limit: 1}
+        ).toPromise().then(cn =>
+            this.browseCn = cn ? cn.label() : this.bibSummary.bibCallNumber
+        );
+    }
+
     browseByUrl(params: ParamMap): void {
         this.catUrl.applyUrlParams(this.searchContext, params);
+        this.getBrowseResults();
+    }
+
+    getBrowseResults() {
         const cbs = this.searchContext.cnBrowseSearch;
         cbs.limit = this.rowCount * this.colCount;
 
+        if (this.browseCn) {
+            // Override any call number browse URL parameters
+            cbs.value = this.browseCn;
+        }
+
         if (cbs.isSearchable()) {
             this.results = [];
             this.cat.cnBrowse(this.searchContext)
@@ -114,12 +156,23 @@ export class CnBrowseResultsComponent implements OnInit, OnDestroy {
 
     prevPage() {
         this.searchContext.cnBrowseSearch.offset--;
-        this.staffCat.cnBrowse();
+        if (this.bibSummary) {
+            // Browse without navigation
+            this.getBrowseResults();
+        } else {
+            this.staffCat.cnBrowse();
+        }
+
     }
 
     nextPage() {
         this.searchContext.cnBrowseSearch.offset++;
-        this.staffCat.cnBrowse();
+        if (this.bibSummary) {
+            // Browse without navigation
+            this.getBrowseResults();
+        } else {
+            this.staffCat.cnBrowse();
+        }
     }
 
     /**
@@ -145,6 +198,10 @@ export class CnBrowseResultsComponent implements OnInit, OnDestroy {
     orgName(orgId: number): string {
         return this.org.get(orgId).shortname();
     }
+
+    getAuthorSearchParams(summary: BibRecordSummary): any {
+        return this.staffCat.getAuthorSearchParams(summary);
+    }
 }
 
 
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html
index b82dd74003..a9330c3c2b 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html
@@ -83,6 +83,16 @@
           </eg-catalog-record-conjoined>
         </ng-template>
       </ngb-tab>
+      <ngb-tab title="Shelf Browse" i18n-title id="cnbrowse">
+        <ng-template ngbTabContent>
+          <ng-container *ngIf="summary">
+            <div class="mt-2">
+              <eg-catalog-cn-browse-results [bibSummary]="summary">
+              </eg-catalog-cn-browse-results>
+            </div>
+          </ng-container>
+        </ng-template>
+      </ngb-tab>
       <ngb-tab title="Patron View" i18n-title id="catalog">
         <ng-template ngbTabContent>
           <eg-opac-record-detail [recordId]="recordId">
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.ts
index b46e4ca0ba..8cb7f03c6c 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.ts
@@ -85,11 +85,7 @@ export class ResultRecordComponent implements OnInit, OnDestroy {
     // Params to genreate a new author search based on a reset
     // clone of the current page params.
     getAuthorSearchParams(summary: BibRecordSummary): any {
-        const tmpContext = this.staffCat.cloneContext(this.searchContext);
-        tmpContext.reset();
-        tmpContext.termSearch.fieldClass = ['author'];
-        tmpContext.termSearch.query = [summary.display.author];
-        return this.catUrl.toUrlParams(tmpContext);
+        return this.staffCat.getAuthorSearchParams(summary);
     }
 
     // Returns the URL parameters for the current page plus the

commit 4155a7a2abeffe73db0403a25f7a514514090ba3
Author: Bill Erickson <berickxx at gmail.com>
Date:   Tue Dec 31 13:02:57 2019 -0500

    LP1850546 Call number browse grid
    
    Return to grid-shaped call number browse with denser data display for
    main CN browse UI.
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Ruth Frasur <rfrasur at gmail.com>

diff --git a/Open-ILS/src/eg2/src/app/share/catalog/catalog-url.service.ts b/Open-ILS/src/eg2/src/app/share/catalog/catalog-url.service.ts
index 4c45a4e3c7..7b9698f2d3 100644
--- a/Open-ILS/src/eg2/src/app/share/catalog/catalog-url.service.ts
+++ b/Open-ILS/src/eg2/src/app/share/catalog/catalog-url.service.ts
@@ -126,6 +126,7 @@ export class CatalogUrlService {
         if (context.cnBrowseSearch.isSearchable()) {
             params.cnBrowseTerm = context.cnBrowseSearch.value;
             params.cnBrowsePage = context.cnBrowseSearch.offset;
+            params.cnBrowsePageSize = context.cnBrowseSearch.limit;
         }
 
         return params;
@@ -198,6 +199,7 @@ export class CatalogUrlService {
         if (params.has('cnBrowseTerm')) {
             context.cnBrowseSearch.value = params.get('cnBrowseTerm');
             context.cnBrowseSearch.offset = Number(params.get('cnBrowsePage'));
+            context.cnBrowseSearch.limit = Number(params.get('cnBrowsePageSize'));
         }
 
         const ts = context.termSearch;
diff --git a/Open-ILS/src/eg2/src/app/share/catalog/catalog.service.ts b/Open-ILS/src/eg2/src/app/share/catalog/catalog.service.ts
index 3b50f61e43..c80d0b2683 100644
--- a/Open-ILS/src/eg2/src/app/share/catalog/catalog.service.ts
+++ b/Open-ILS/src/eg2/src/app/share/catalog/catalog.service.ts
@@ -431,7 +431,7 @@ export class CatalogService {
         return this.net.request(
             'open-ils.supercat',
             'open-ils.supercat.call_number.browse',
-            cbs.value, ctx.searchOrg.shortname(), ctx.pager.limit, cbs.offset
+            cbs.value, ctx.searchOrg.shortname(), cbs.limit, cbs.offset
         ).pipe(tap(result => ctx.searchState = CatalogSearchState.COMPLETE));
     }
 }
diff --git a/Open-ILS/src/eg2/src/app/share/catalog/search-context.ts b/Open-ILS/src/eg2/src/app/share/catalog/search-context.ts
index f993b8ccbc..7010d9eff3 100644
--- a/Open-ILS/src/eg2/src/app/share/catalog/search-context.ts
+++ b/Open-ILS/src/eg2/src/app/share/catalog/search-context.ts
@@ -158,9 +158,14 @@ export class CatalogCnBrowseContext {
     // e.g. -2 means 2 pages back (alphabetically) from the original search.
     offset: number;
 
+    // Maintain a separate page size limit since it will generally
+    // differ from other search page sizes.
+    limit: number;
+
     reset() {
         this.value = '';
         this.offset = 0;
+        this.limit = 5; // UI will modify
     }
 
     isSearchable() {
@@ -171,6 +176,7 @@ export class CatalogCnBrowseContext {
         const ctx = new CatalogCnBrowseContext();
         ctx.value = this.value;
         ctx.offset = this.offset;
+        ctx.limit = this.limit;
         return ctx;
     }
 
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/cnbrowse/results.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/cnbrowse/results.component.html
index 09a1f4e870..4aff584464 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/cnbrowse/results.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/cnbrowse/results.component.html
@@ -32,13 +32,30 @@
     </div>
   </div>
 
-  <div class="row" *ngFor="let result of results; let idx = index">
-    <div class="col-lg-12" *ngIf="result._bibSummary">
-      <eg-catalog-result-record [summary]="result._bibSummary" 
-        [index]="idx" [callNumber]="result">
-      </eg-catalog-result-record>
+  <ng-container *ngIf="results && results.length">
+    <div class="row mb-3" *ngFor="let rowIdx of rowIndexList">
+      <ng-container *ngFor="let callNumber of resultSlice(rowIdx); let colIdx = index">
+        <ng-container *ngIf="callNumber._bibSummary">
+          <div class="col-lg-4 pt-2 d-flex border"
+            [ngClass]="{'border-primary': isCenter(rowIdx, colIdx)}">
+            <div class="flex-1">
+              <div class="font-weight-bold">
+                {{callNumber.prefix().label()}} {{callNumber.label()}}
+                {{callNumber.suffix().label()}}
+                @ {{orgName(callNumber.owning_lib())}}
+              </div>
+              <div>{{callNumber._bibSummary.display.title}}</div>
+              <div>{{callNumber._bibSummary.display.author}}</div>
+            </div>
+            <div class="ml-2">
+              <img src="/opac/extras/ac/jacket/small/r/{{callNumber._bibSummary.id}}"/>
+            </div>
+          </div>
+        </ng-container>
+      </ng-container>
     </div>
-  </div>
+  </ng-container>
+
 
   <div class="row mb-2">
     <div class="col-lg-3">
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/cnbrowse/results.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/cnbrowse/results.component.ts
index 037b9ea88f..bddf40efd1 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/cnbrowse/results.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/cnbrowse/results.component.ts
@@ -1,4 +1,4 @@
-import {Component, OnInit, OnDestroy} from '@angular/core';
+import {Component, Input, OnInit, OnDestroy} from '@angular/core';
 import {ActivatedRoute, Router, ParamMap} from '@angular/router';
 import {Subscription} from 'rxjs';
 import {IdlObject} from '@eg/core/idl.service';
@@ -8,6 +8,7 @@ import {CatalogUrlService} from '@eg/share/catalog/catalog-url.service';
 import {CatalogSearchContext, CatalogSearchState} from '@eg/share/catalog/search-context';
 import {StaffCatalogService} from '../catalog.service';
 import {BibRecordSummary} from '@eg/share/catalog/bib-record.service';
+import {OrgService} from '@eg/core/org.service';
 
 @Component({
   selector: 'eg-catalog-cn-browse-results',
@@ -15,6 +16,12 @@ import {BibRecordSummary} from '@eg/share/catalog/bib-record.service';
 })
 export class CnBrowseResultsComponent implements OnInit, OnDestroy {
 
+    @Input() rowCount = 5;
+    rowIndexList: number[] = [];
+
+    // hard-coded because it requires template changes.
+    colCount = 3;
+
     searchContext: CatalogSearchContext;
     results: any[];
     routeSub: Subscription;
@@ -22,6 +29,7 @@ export class CnBrowseResultsComponent implements OnInit, OnDestroy {
     constructor(
         private router: Router,
         private route: ActivatedRoute,
+        private org: OrgService,
         private cat: CatalogService,
         private bib: BibRecordService,
         private catUrl: CatalogUrlService,
@@ -30,6 +38,11 @@ export class CnBrowseResultsComponent implements OnInit, OnDestroy {
 
     ngOnInit() {
         this.searchContext = this.staffCat.searchContext;
+
+        for (let idx = 0; idx < this.rowCount; idx++) {
+            this.rowIndexList.push(idx);
+        }
+
         this.routeSub = this.route.queryParamMap.subscribe(
             (params: ParamMap) => this.browseByUrl(params)
         );
@@ -42,6 +55,7 @@ export class CnBrowseResultsComponent implements OnInit, OnDestroy {
     browseByUrl(params: ParamMap): void {
         this.catUrl.applyUrlParams(this.searchContext, params);
         const cbs = this.searchContext.cnBrowseSearch;
+        cbs.limit = this.rowCount * this.colCount;
 
         if (cbs.isSearchable()) {
             this.results = [];
@@ -117,6 +131,20 @@ export class CnBrowseResultsComponent implements OnInit, OnDestroy {
         this.router.navigate(
             ['/staff/catalog/record/' + summary.id], {queryParams: params});
     }
+
+    resultSlice(rowIdx: number): number[] {
+        const offset = rowIdx * this.colCount;
+        return this.results.slice(offset, offset + this.colCount);
+    }
+
+    isCenter(rowIdx: number, colIdx: number): boolean {
+        const total = this.rowCount * this.colCount;
+        return Math.floor(total / 2) === ((rowIdx * this.colCount) + colIdx);
+    }
+
+    orgName(orgId: number): string {
+        return this.org.get(orgId).shortname();
+    }
 }
 
 

commit 4325faeb1271cfa7c087c0ec890d2a0a765d5d04
Author: Bill Erickson <berickxx at gmail.com>
Date:   Fri Jan 10 16:02:31 2020 -0500

    LP1859241 Relase Notes (Angular Patron Search)
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Ruth Frasur <rfrasur at gmail.com>

diff --git a/docs/RELEASE_NOTES_NEXT/Circulation/ang-cat-holds-patron-search.adoc b/docs/RELEASE_NOTES_NEXT/Circulation/ang-cat-holds-patron-search.adoc
new file mode 100644
index 0000000000..6e21760ea2
--- /dev/null
+++ b/docs/RELEASE_NOTES_NEXT/Circulation/ang-cat-holds-patron-search.adoc
@@ -0,0 +1,5 @@
+Angular Staff Catalog Holds Patron Search Support
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+The Angular staff catalog now supports patron searching directly from 
+the holds placement interace.
+

commit 1e35be4827254524e229ead11f89c5de1340113d
Author: Bill Erickson <berickxx at gmail.com>
Date:   Thu Jan 9 17:20:42 2020 -0500

    LP1859241 Angular holds patron search dialog
    
    Implements a patron search dialog which may be instantiated directly
    from the staff catalog holds placement interface.
    
    Includes:
    
    1. New patron module (which absorbs the existing PatronService)
    2. New patron search component
    3. Patron search component dialog wrapper.
    4. Patron profile selector component which understands custom group
       display trees.
    4. Fixes an issue with the grid where the 'datatype' was not always
       propagated to IDL fields.
    5. Modifies the combobox to allow the caller to clear the value by
       passing a null value for the selectedId.
    
    To Test:
    
    [1] Navigate to the Angular staff catalog
    [2] Perform a bib search
    [3] Click 'Place Hold' next to a title.
    [4] Click the 'Patron Search' button.
    [5] Search for patrons and either double-click a search result row or
        single click then chose the 'Select' button.
    [6] Confirm the selected patron is now chosen for holds placement.
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Ruth Frasur <rfrasur at gmail.com>

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 02839579d3..41d2b30cec 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
@@ -75,16 +75,19 @@ export class ComboboxComponent implements ControlValueAccessor, OnInit {
     // 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 (id === undefined) { return; }
 
-            if (!this.selected) {
-                // It's possible the selected ID lives in a set of entries
-                // that are yet to be provided.
-                this.startId = id;
-            }
+        // clear on explicit null
+        if (id === null) { this.selected = null; }
+
+        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;
         }
     }
 
diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid.ts b/Open-ILS/src/eg2/src/app/share/grid/grid.ts
index e4f6715ef6..e885fb7787 100644
--- a/Open-ILS/src/eg2/src/app/share/grid/grid.ts
+++ b/Open-ILS/src/eg2/src/app/share/grid/grid.ts
@@ -230,9 +230,11 @@ export class GridColumnSet {
             if (idlInfo) {
                 col.idlFieldDef = idlInfo.idlField;
                 col.idlClass = idlInfo.idlClass.name;
+                if (!col.datatype) {
+                    col.datatype = col.idlFieldDef.datatype;
+                }
                 if (!col.label) {
                     col.label = col.idlFieldDef.label || col.idlFieldDef.name;
-                    col.datatype = col.idlFieldDef.datatype;
                 }
             }
         }
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 9b14137243..dbcfb03b8f 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
@@ -11,7 +11,7 @@ import {PickupComponent} from './pickup.component';
 import {PullListComponent} from './pull-list.component';
 import {ReturnComponent} from './return.component';
 import {NoTimezoneSetComponent} from './no-timezone-set.component';
-import {PatronService} from '@eg/staff/share/patron.service';
+import {PatronModule} from '@eg/staff/share/patron/patron.module';
 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';
@@ -23,9 +23,9 @@ import {OrgFamilySelectModule} from '@eg/share/org-family-select/org-family-sele
         BookingRoutingModule,
         ReactiveFormsModule,
         FmRecordEditorModule,
-        OrgFamilySelectModule
+        OrgFamilySelectModule,
+        PatronModule
     ],
-    providers: [PatronService],
     declarations: [
         CancelReservationDialogComponent,
         CreateReservationComponent,
diff --git a/Open-ILS/src/eg2/src/app/staff/booking/pickup.component.ts b/Open-ILS/src/eg2/src/app/staff/booking/pickup.component.ts
index 028f7cf89f..076c4132ca 100644
--- a/Open-ILS/src/eg2/src/app/staff/booking/pickup.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/booking/pickup.component.ts
@@ -2,7 +2,7 @@ import {Component, OnInit, ViewChild, OnDestroy} from '@angular/core';
 import {Router, ActivatedRoute, ParamMap} from '@angular/router';
 import {Subscription, of} from 'rxjs';
 import {single, filter, switchMap, debounceTime, tap} from 'rxjs/operators';
-import {PatronService} from '@eg/staff/share/patron.service';
+import {PatronService} from '@eg/staff/share/patron/patron.service';
 import {PcrudService} from '@eg/core/pcrud.service';
 import {IdlObject} from '@eg/core/idl.service';
 import {ReservationsGridComponent} from './reservations-grid.component';
diff --git a/Open-ILS/src/eg2/src/app/staff/booking/return.component.ts b/Open-ILS/src/eg2/src/app/staff/booking/return.component.ts
index d7a42f491f..f37e10e8a3 100644
--- a/Open-ILS/src/eg2/src/app/staff/booking/return.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/booking/return.component.ts
@@ -4,7 +4,7 @@ import {FormGroup, FormControl, Validators} from '@angular/forms';
 import {NgbTabChangeEvent, NgbTabset} from '@ng-bootstrap/ng-bootstrap';
 import {Observable, from, of, Subscription} from 'rxjs';
 import { single, switchMap, tap, debounceTime } from 'rxjs/operators';
-import {PatronService} from '@eg/staff/share/patron.service';
+import {PatronService} from '@eg/staff/share/patron/patron.service';
 import {PcrudService} from '@eg/core/pcrud.service';
 import {IdlObject} from '@eg/core/idl.service';
 import {ReservationsGridComponent} from './reservations-grid.component';
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 3ad00a9942..9b7d57acdc 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
@@ -5,6 +5,7 @@ import {CatalogRoutingModule} from './routing.module';
 import {HoldsModule} from '@eg/staff/share/holds/holds.module';
 import {HoldingsModule} from '@eg/staff/share/holdings/holdings.module';
 import {BookingModule} from '@eg/staff/share/booking/booking.module';
+import {PatronModule} from '@eg/staff/share/patron/patron.module';
 import {CatalogComponent} from './catalog.component';
 import {SearchFormComponent} from './search-form.component';
 import {ResultsComponent} from './result/results.component';
@@ -64,6 +65,7 @@ import {PreferencesComponent} from './prefs.component';
     HoldsModule,
     HoldingsModule,
     BookingModule,
+    PatronModule,
     MarcEditModule
   ],
   providers: [
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.html
index fa04d86ede..dca200dace 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.html
@@ -1,3 +1,7 @@
+
+<eg-patron-search-dialog #patronSearch>
+</eg-patron-search-dialog>
+
 <div class="row">
   <div class="col-lg-4">
     <h3 i18n>Place Hold 
@@ -7,7 +11,7 @@
     </h3>
   </div>
   <div class="col-lg-2 text-right">
-    <button class="btn btn-outline-dark btn-sm" [disabled]="true">
+    <button class="btn btn-outline-dark btn-sm" (click)="searchPatrons()">
       <span class="material-icons mat-icon-in-button align-middle" 
         i18n-title title="Search for Patron">search</span>
       <span class="align-middle" i18n>Search for Patron</span>
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.ts
index 539c434e4a..c1640b0b81 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.ts
@@ -13,6 +13,8 @@ import {StaffCatalogService} from '../catalog.service';
 import {HoldsService, HoldRequest,
     HoldRequestTarget} from '@eg/staff/share/holds/holds.service';
 import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
+import {PatronSearchDialogComponent
+  } from '@eg/staff/share/patron/search-dialog.component';
 
 class HoldContext {
     holdMeta: HoldRequestTarget;
@@ -63,6 +65,9 @@ export class HoldComponent implements OnInit {
     smsEnabled: boolean;
     placeHoldsClicked: boolean;
 
+    @ViewChild('patronSearch', {static: false})
+      patronSearch: PatronSearchDialogComponent;
+
     constructor(
         private router: Router,
         private route: ActivatedRoute,
@@ -398,6 +403,22 @@ export class HoldComponent implements OnInit {
             )
         );
     }
+
+    searchPatrons() {
+        this.patronSearch.open({size: 'xl'}).toPromise().then(
+            patrons => {
+                if (!patrons || patrons.length === 0) { return; }
+
+                const user = patrons[0];
+
+                this.user = user;
+                this.userBarcode =
+                    this.currentUserBarcode = user.card().barcode();
+                user.home_ou(this.org.get(user.home_ou()).id()); // de-flesh
+                this.applyUserSettings();
+            }
+        );
+    }
 }
 
 
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/result/results.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/result/results.component.ts
index 869eff2e79..50ed7914e4 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/result/results.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/result/results.component.ts
@@ -71,9 +71,11 @@ export class ResultsComponent implements OnInit, OnDestroy {
     }
 
     ngOnDestroy() {
-        this.routeSub.unsubscribe();
-        this.searchSub.unsubscribe();
-        this.basketSub.unsubscribe();
+        if (this.routeSub) {
+            this.routeSub.unsubscribe();
+            this.searchSub.unsubscribe();
+            this.basketSub.unsubscribe();
+        }
     }
 
     // Apply the select-all checkbox when all visible records
diff --git a/Open-ILS/src/eg2/src/app/staff/share/patron/patron.module.ts b/Open-ILS/src/eg2/src/app/staff/share/patron/patron.module.ts
new file mode 100644
index 0000000000..ac6e9b30f4
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/share/patron/patron.module.ts
@@ -0,0 +1,30 @@
+import {NgModule} from '@angular/core';
+import {StaffCommonModule} from '@eg/staff/common.module';
+import {GridModule} from '@eg/share/grid/grid.module';
+import {PatronService} from './patron.service';
+import {PatronSearchComponent} from './search.component';
+import {PatronSearchDialogComponent} from './search-dialog.component';
+import {ProfileSelectComponent} from './profile-select.component';
+
+ at NgModule({
+    declarations: [
+        PatronSearchComponent,
+        PatronSearchDialogComponent,
+        ProfileSelectComponent
+    ],
+    imports: [
+        StaffCommonModule,
+        GridModule
+    ],
+    exports: [
+        PatronSearchComponent,
+        PatronSearchDialogComponent,
+        ProfileSelectComponent
+    ],
+    providers: [
+        PatronService
+    ]
+})
+
+export class PatronModule {}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/patron.service.ts b/Open-ILS/src/eg2/src/app/staff/share/patron/patron.service.ts
similarity index 100%
rename from Open-ILS/src/eg2/src/app/staff/share/patron.service.ts
rename to Open-ILS/src/eg2/src/app/staff/share/patron/patron.service.ts
diff --git a/Open-ILS/src/eg2/src/app/staff/share/patron/profile-select.component.html b/Open-ILS/src/eg2/src/app/staff/share/patron/profile-select.component.html
new file mode 100644
index 0000000000..d5a36663a9
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/share/patron/profile-select.component.html
@@ -0,0 +1,6 @@
+
+<eg-combobox #combobox 
+  [startId]="initialValue" [entries]="cboxEntries"
+  (onChange)="propagateCboxChange($event)"
+  i18n-placeholder placeholder="Profile Group">
+</eg-combobox>
diff --git a/Open-ILS/src/eg2/src/app/staff/share/patron/profile-select.component.ts b/Open-ILS/src/eg2/src/app/staff/share/patron/profile-select.component.ts
new file mode 100644
index 0000000000..56ff560c60
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/share/patron/profile-select.component.ts
@@ -0,0 +1,178 @@
+import {Component, Input, Output, OnInit,
+    EventEmitter, ViewChild, forwardRef} from '@angular/core';
+import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms';
+import {Observable, of} from 'rxjs';
+import {map} from 'rxjs/operators';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {OrgService} from '@eg/core/org.service';
+import {AuthService} from '@eg/core/auth.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {ComboboxEntry, ComboboxComponent
+    } from '@eg/share/combobox/combobox.component';
+
+/* User permission group select comoboxbox.
+ *
+ * <eg-profile-select
+ *  [(ngModel)]="pgtObject" [useDisplayEntries]="true">
+ * </eg-profile-select>
+ */
+
+// Use a unicode char for spacing instead of ASCII=32 so the browser
+// won't collapse the nested display entries down to a single space.
+const PAD_SPACE = ' '; // U+2007
+
+ at Component({
+  selector: 'eg-profile-select',
+  templateUrl: './profile-select.component.html',
+  providers: [{
+    provide: NG_VALUE_ACCESSOR,
+    useExisting: forwardRef(() => ProfileSelectComponent),
+    multi: true
+  }]
+})
+export class ProfileSelectComponent implements ControlValueAccessor, OnInit {
+
+    // If true, attempt to build the selector from
+    // permission.grp_tree_display_entry's for the current org unit.
+    // If false OR if no permission.grp_tree_display_entry's exist
+    // build the selector from the full permission.grp_tree
+    @Input() useDisplayEntries: boolean;
+
+    // Emits the selected 'pgt' object or null if the selector is cleared.
+    @Output() profileChange: EventEmitter<IdlObject>;
+
+    @ViewChild('combobox', {static: false}) cbox: ComboboxComponent;
+
+    initialValue: number;
+    cboxEntries: ComboboxEntry[] = [];
+    profiles: {[id: number]: IdlObject} = {};
+
+    // Stub functions required by ControlValueAccessor
+    propagateChange = (_: any) => {};
+    propagateTouch = () => {};
+
+    constructor(
+        private org: OrgService,
+        private auth: AuthService,
+        private pcrud: PcrudService) {
+        this.profileChange = new EventEmitter<IdlObject>();
+    }
+
+    ngOnInit() {
+        this.collectGroups().then(grps => this.sortGroups(grps));
+    }
+
+    collectGroups(): Promise<IdlObject[]> {
+
+        if (!this.useDisplayEntries) {
+            return this.fetchPgt();
+        }
+
+        return this.pcrud.search('pgtde',
+            {org: this.org.ancestors(this.auth.user().ws_ou(), true)},
+            {flesh: 1, flesh_fields: {'pgtde': ['grp']}},
+            {atomic: true}
+
+        ).toPromise().then(groups => {
+
+            if (groups.length === 0) { return this.fetchPgt(); }
+
+            // In the query above, we fetch display entries for our org
+            // unit plus ancestors.  However, we only want to use one
+            // collection of display entries, those owned at our org
+            // unit or our closest ancestor.
+            let closestOrg = this.org.get(groups[0].org());
+            groups.forEach(g => {
+                const org = this.org.get(g.org());
+                if (closestOrg.ou_type().depth() < org.ou_type().depth()) {
+                    closestOrg = org;
+                }
+            });
+            groups = groups.filter(g => g.org() === closestOrg.id());
+
+            // Link the display entry to its pgt.
+            const pgtList = [];
+            groups.forEach(display => {
+                const pgt = display.grp();
+                pgt._display = display;
+                pgtList.push(pgt);
+            });
+
+            return pgtList;
+        });
+    }
+
+    fetchPgt(): Promise<IdlObject[]> {
+        return this.pcrud.retrieveAll('pgt', {}, {atomic: true}).toPromise();
+    }
+
+    grpLabel(groups: IdlObject[], grp: IdlObject): string {
+        let tmp = grp;
+        let depth = 0;
+
+        do {
+            const pid = tmp._display ? tmp._display.parent() : tmp.parent();
+            if (!pid) { break; } // top of the tree
+
+            // Should always produce a value unless a perm group
+            // display tree is poorly structured.
+            tmp = groups.filter(g => g.id() === pid)[0];
+
+            depth++;
+
+        } while (tmp);
+
+        return PAD_SPACE.repeat(depth) + grp.name();
+    }
+
+    sortGroups(groups: IdlObject[], grp?: IdlObject) {
+        if (!grp) {
+            grp = groups.filter(g => g.parent() === null)[0];
+        }
+
+        this.profiles[grp.id()] = grp;
+        this.cboxEntries.push(
+            {id: grp.id(), label: this.grpLabel(groups, grp)});
+
+        groups
+            .filter(g => g.parent() === grp.id())
+            .sort((a, b) => {
+                if (a._display) {
+                    return a._display.position() < b._display.position() ? -1 : 1;
+                } else {
+                    return a.name() < b.name() ? -1 : 1;
+                }
+            })
+            .forEach(child => this.sortGroups(groups, child));
+    }
+
+    writeValue(pgt: IdlObject) {
+        const id = pgt ? pgt.id() : null;
+        if (this.cbox) {
+            this.cbox.selectedId = id;
+        } else {
+            // Will propagate to cbox after its instantiated.
+            this.initialValue = id;
+        }
+    }
+
+    registerOnChange(fn) {
+        this.propagateChange = fn;
+    }
+
+    registerOnTouched(fn) {
+        this.propagateTouch = fn;
+    }
+
+    propagateCboxChange(entry: ComboboxEntry) {
+        if (entry) {
+            const grp = this.profiles[entry.id];
+            this.propagateChange(grp);
+            this.profileChange.emit(grp);
+        } else {
+            this.profileChange.emit(null);
+            this.propagateChange(null);
+        }
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/patron/search-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/share/patron/search-dialog.component.html
new file mode 100644
index 0000000000..1006a63c24
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/share/patron/search-dialog.component.html
@@ -0,0 +1,23 @@
+<ng-template #dialogContent>
+  <div class="modal-header bg-info">
+    <h4 class="modal-title"><span i18n>Patron Search</span></h4>
+    <button type="button" class="close" 
+      i18n-aria-label aria-label="Close" (click)="close()">
+      <span aria-hidden="true">×</span>
+    </button>
+  </div>
+  <div class="modal-body">
+    <eg-patron-search #searchForm (patronsSelected)="patronsSelected($event)">
+    </eg-patron-search>
+  </div>
+  <div class="modal-footer">
+    <ng-container>
+      <button type="button" class="btn btn-warning" 
+        (click)="close()" i18n>Cancel</button>
+      <button type="button" class="btn btn-success" 
+        [disabled]="searchForm ? searchForm.getSelected().length === 0 : true"
+        (click)="close(searchForm.getSelected())" i18n>Select</button>
+    </ng-container>
+  </div>
+</ng-template>
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/patron/search-dialog.component.ts b/Open-ILS/src/eg2/src/app/staff/share/patron/search-dialog.component.ts
new file mode 100644
index 0000000000..98e1c22d72
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/share/patron/search-dialog.component.ts
@@ -0,0 +1,36 @@
+import {Component, OnInit, Input, Output, ViewChild} from '@angular/core';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {NgbModal} from '@ng-bootstrap/ng-bootstrap';
+import {DialogComponent} from '@eg/share/dialog/dialog.component';
+import {PatronSearchComponent} from './search.component';
+
+/**
+ * Dialog container for patron search component
+ *
+ * <eg-patron-search-dialog (patronsSelected)="process($event)">
+ * </eg-patron-search-dialog>
+ */
+
+ at Component({
+  selector: 'eg-patron-search-dialog',
+  templateUrl: 'search-dialog.component.html'
+})
+
+export class PatronSearchDialogComponent
+    extends DialogComponent implements OnInit {
+
+    @ViewChild('searchForm', {static: false})
+        searchForm: PatronSearchComponent;
+
+    constructor(private modal: NgbModal) { super(modal); }
+
+    ngOnInit() {}
+
+    // Fired when a row in the search grid is dbl-clicked / activated
+    patronsSelected(patrons: IdlObject[]) {
+        this.close(patrons);
+    }
+}
+
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/patron/search.component.html b/Open-ILS/src/eg2/src/app/staff/share/patron/search.component.html
new file mode 100644
index 0000000000..f2e363217e
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/share/patron/search.component.html
@@ -0,0 +1,233 @@
+
+<div class="patron-search-form">
+  <div class="row mb-2">
+    <div class="col-lg-2">
+      <input class="form-control" type="text" id='focus-this-input'
+        i18n-aria-label aria-label="Last Name" (keyup.enter)="go()"
+        i18n-placeholder placeholder="Last Name"
+        [(ngModel)]="search.family_name"/>
+    </div>
+    <div class="col-lg-2">
+      <input class="form-control" type="text" (keyup.enter)="go()"
+        i18n-aria-label aria-label="First Name"
+        i18n-placeholder placeholder="First Name"
+        [(ngModel)]="search.first_given_name"/>
+    </div>
+    <div class="col-lg-2">
+      <input class="form-control" type="text" (keyup.enter)="go()"
+        i18n-aria-label aria-label="Middle Name"
+        i18n-placeholder placeholder="Middle Name"
+        [(ngModel)]="search.second_given_name"/>
+    </div>
+    <div class="col-lg-2">
+      <input class="form-control" type="text" (keyup.enter)="go()"
+        i18n-aria-label aria-label="Name Keywords"
+        i18n-placeholder placeholder="Name Keywords"
+        [(ngModel)]="search.name"/>
+    </div>
+    <div class="col-lg-2">
+      <button class="btn btn-success" (click)="go()" i18n>Search</button>
+      <button (click)="toggleExpandForm()"
+        class="btn btn-outline-dark ml-2 label-with-material-icon"
+        i18n-title title="Toggle Expanded Form Display">
+        <span *ngIf="!expandForm" class="material-icons">arrow_drop_down</span>
+        <span *ngIf="expandForm"  class="material-icons">arrow_drop_up</span>
+      </button>
+    </div>
+    <div class="col-lg-2">
+    </div>
+  </div>
+
+  <ng-container *ngIf="expandForm">
+    <div class="row mb-2">
+      <div class="col-lg-2">
+        <input class="form-control" type="text" (keyup.enter)="go()"
+          i18n-aria-label aria-label="Barcode"
+          i18n-placeholder placeholder="Barcode"
+          [(ngModel)]="search.barcode"/>
+      </div>
+      <div class="col-lg-2">
+        <input class="form-control" type="text" (keyup.enter)="go()"
+          i18n-aria-label aria-label="Alias"
+          i18n-placeholder placeholder="Alias"
+          [(ngModel)]="search.alias"/>
+      </div>
+      <div class="col-lg-2">
+        <input class="form-control" type="text" (keyup.enter)="go()"
+          i18n-aria-label aria-label="Username"
+          i18n-placeholder placeholder="Username"
+          [(ngModel)]="search.usrname"/>
+      </div>
+      <div class="col-lg-2">
+        <input class="form-control" type="text" (keyup.enter)="go()"
+          i18n-aria-label aria-label="Email"
+          i18n-placeholder placeholder="Email"
+          [(ngModel)]="search.email"/>
+      </div>
+      <div class="col-lg-2">
+        <button class="btn btn-warning" (click)="clear()" i18n>Clear Form</button>
+      </div>
+      <div class="col-lg-2">
+      </div>
+    </div>
+    <div class="row mb-2">
+      <div class="col-lg-2">
+        <input class="form-control" type="text" (keyup.enter)="go()"
+          i18n-aria-label aria-label="Identification"
+          i18n-placeholder placeholder="Identification"
+          [(ngModel)]="search.ident"/>
+      </div>
+      <div class="col-lg-2">
+        <input class="form-control" type="text" (keyup.enter)="go()"
+          i18n-aria-label aria-label="Phone"
+          i18n-placeholder placeholder="Phone"
+          [(ngModel)]="search.phone"/>
+      </div>
+      <div class="col-lg-2">
+        <input class="form-control" type="text" (keyup.enter)="go()"
+          i18n-aria-label aria-label="Street 1"
+          i18n-placeholder placeholder="Street 1"
+          [(ngModel)]="search.street1"/>
+      </div>
+      <div class="col-lg-2">
+        <input class="form-control" type="text" (keyup.enter)="go()"
+          i18n-aria-label aria-label="Street 2"
+          i18n-placeholder placeholder="Street 2"
+          [(ngModel)]="search.street2"/>
+      </div>
+      <div class="col-lg-2">
+        <input class="form-control" type="text" (keyup.enter)="go()"
+          i18n-aria-label aria-label="City"
+          i18n-placeholder placeholder="City"
+          [(ngModel)]="search.city"/>
+      </div>
+      <div class="col-lg-2"></div>
+    </div>
+    <div class="row mb-2">
+      <div class="col-lg-2">
+        <input class="form-control" type="text" (keyup.enter)="go()"
+          i18n-aria-label aria-label="State"
+          i18n-placeholder placeholder="State"
+          [(ngModel)]="search.state"/>
+      </div>
+      <div class="col-lg-2">
+        <input class="form-control" type="text" (keyup.enter)="go()"
+          i18n-aria-label aria-label="Post Code"
+          i18n-placeholder placeholder="Post Code"
+          [(ngModel)]="search.post_code"/>
+      </div>
+      <div class="col-lg-2">
+        <eg-profile-select [useDisplayEntries]="true" 
+          [(ngModel)]="search.profile">
+        </eg-profile-select>
+      </div>
+      <div class="col-lg-2">
+        <eg-org-select (onChange)="searchOrg = $event"
+          [applyOrgId]="searchOrg ? searchOrg.id() : null"
+          i18n-placeholder placeholder="Home Library">
+        </eg-org-select>
+        <!-- home org -->
+      </div>
+      <div class="col-lg-2">
+        <input class="form-control" type="text" (keyup.enter)="go()"
+          i18n-aria-label aria-label="Guardian"
+          i18n-placeholder placeholder="Guardian"
+          [(ngModel)]="search.guardian"/>
+      </div>
+      <div class="col-lg-2"></div>
+    </div>
+    <div class="row mb-2">
+      <div class="col-lg-2">
+        <input class="form-control" type="text" (keyup.enter)="go()"
+          i18n-aria-label aria-label="DOB Year"
+          i18n-placeholder placeholder="DOB Year"
+          [(ngModel)]="search.dob_year"/>
+      </div>
+      <div class="col-lg-2">
+        <input class="form-control" type="text" (keyup.enter)="go()"
+          i18n-aria-label aria-label="DOB Month"
+          i18n-placeholder placeholder="DOB Month"
+          [(ngModel)]="search.dob_month"/>
+      </div>
+      <div class="col-lg-2">
+        <input class="form-control" type="text" (keyup.enter)="go()"
+          i18n-aria-label aria-label="DOB Day"
+          i18n-placeholder placeholder="DOB Day"
+          [(ngModel)]="search.dob_day"/>
+      </div>
+      <div class="col-lg-2">
+        <input class="form-control" type="text" (keyup.enter)="go()"
+          i18n-aria-label aria-label="Database ID"
+          i18n-placeholder placeholder="Database ID"
+          [(ngModel)]="search.id"/>
+      </div>
+      <div class="col-lg-2">
+        <div class="form-check form-check-inline">
+          <input class="form-check-input" type="checkbox" 
+            (change)="toggleIncludeInactive()"
+            id="include-inactive" [(ngModel)]="search.inactive">
+          <label class="form-check-label" for="include-inactive" i18n>
+            Include Inactive
+          ?</label>
+        </div>
+      </div>
+      <div class="col-lg-2"></div>
+    </div>
+  </ng-container><!-- expand form -->
+</div>
+
+<div class="patron-search-grid">
+  <eg-grid #searchGrid idlClass="au" 
+    persistKey="circ.patron.search"
+    (onRowActivate)="rowsSelected($event)"
+    [dataSource]="dataSource" 
+    [showDeclaredFieldsOnly]="true"> 
+
+    <eg-grid-column path='id' 
+      i18n-label label="ID"></eg-grid-column>      
+    <eg-grid-column path='card.barcode' 
+      i18n-label label="Card"></eg-grid-column>
+    <eg-grid-column path='profile.name' 
+      i18n-label label="Profile"></eg-grid-column>
+    <eg-grid-column path='family_name' 
+      [sortable]="true" [multiSortable]="true"></eg-grid-column>
+    <eg-grid-column path='first_given_name' 
+      [sortable]="true" [multiSortable]="true"></eg-grid-column>
+    <eg-grid-column path='second_given_name' 
+      [sortable]="true" [multiSortable]="true"></eg-grid-column>
+    <eg-grid-column path='dob' 
+      [sortable]="true" [multiSortable]="true"></eg-grid-column>
+    <eg-grid-column path='home_ou.shortname' 
+      i18n-label label="Home Library"></eg-grid-column>
+    <eg-grid-column path='create_date' i18n-label label="Created On" 
+      [sortable]="true" [multiSortable]="true"></eg-grid-column>
+
+    <eg-grid-column i18n-label label="Mailing:Street 1"
+      path='mailing_address.street1' visible></eg-grid-column>
+    <eg-grid-column i18n-label label="Mailing:Street 2"
+      path='mailing_address.street2'></eg-grid-column>
+    <eg-grid-column i18n-label label="Mailing:City"
+      path='mailing_address.city'></eg-grid-column>
+    <eg-grid-column i18n-label label="Mailing:County"
+      path='mailing_address.county'></eg-grid-column>
+    <eg-grid-column i18n-label label="Mailing:State"
+      path='mailing_address.state'></eg-grid-column>
+    <eg-grid-column i18n-label label="Mailing:Zip"
+      path='mailing_address.post_code'></eg-grid-column>
+                                                                                 
+    <eg-grid-column i18n-label label="Billing:Street 1"
+      path='billing_address.street1'></eg-grid-column>
+    <eg-grid-column i18n-label label="Billing:Street 2"
+      path='billing_address.street2'></eg-grid-column>
+    <eg-grid-column i18n-label label="Billing:City"
+      path='billing_address.city'></eg-grid-column>
+    <eg-grid-column i18n-label label="Billing:County"
+      path='billing_address.county'></eg-grid-column>
+    <eg-grid-column i18n-label label="Billing:State"
+      path='billing_address.state'></eg-grid-column>
+    <eg-grid-column i18n-label label="Billing:Zip"
+      path='billing_address.post_code'></eg-grid-column>
+  </eg-grid>
+
+</div>
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/patron/search.component.ts b/Open-ILS/src/eg2/src/app/staff/share/patron/search.component.ts
new file mode 100644
index 0000000000..43f4fe18db
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/share/patron/search.component.ts
@@ -0,0 +1,239 @@
+import {Component, Input, Output, OnInit, AfterViewInit,
+    EventEmitter, ViewChild, Renderer2} from '@angular/core';
+import {Observable, of} from 'rxjs';
+import {map} from 'rxjs/operators';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {EventService} from '@eg/core/event.service';
+import {NetService} from '@eg/core/net.service';
+import {AuthService} from '@eg/core/auth.service';
+import {OrgService} from '@eg/core/org.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {ServerStoreService} from '@eg/core/server-store.service';
+import {ToastService} from '@eg/share/toast/toast.service';
+import {StringComponent} from '@eg/share/string/string.component';
+import {ComboboxEntry, ComboboxComponent} from '@eg/share/combobox/combobox.component';
+import {GridComponent} from '@eg/share/grid/grid.component';
+import {GridDataSource} from '@eg/share/grid/grid';
+import {Pager} from '@eg/share/util/pager';
+
+const DEFAULT_SORT = [
+   'family_name ASC',
+    'first_given_name ASC',
+    'second_given_name ASC',
+    'dob DESC'
+];
+
+const DEFAULT_FLESH = [
+    'card', 'settings', 'standing_penalties', 'addresses', 'billing_address',
+    'mailing_address', 'stat_cat_entries', 'waiver_entries', 'usr_activity',
+    'notes', 'profile'
+];
+
+const EXPAND_FORM = 'eg.circ.patron.search.show_extras';
+const INCLUDE_INACTIVE = 'eg.circ.patron.search.include_inactive';
+
+ at Component({
+  selector: 'eg-patron-search',
+  templateUrl: './search.component.html'
+})
+
+export class PatronSearchComponent implements OnInit, AfterViewInit {
+
+    @ViewChild('searchGrid', {static: false}) searchGrid: GridComponent;
+
+    // Fired on dbl-click of a search result row.
+    @Output() patronsSelected: EventEmitter<any>;
+
+    search: any = {};
+    searchOrg: IdlObject;
+    expandForm: boolean;
+    dataSource: GridDataSource;
+    profileGroups: IdlObject[] = [];
+
+    constructor(
+        private renderer: Renderer2,
+        private net: NetService,
+        private org: OrgService,
+        private auth: AuthService,
+        private store: ServerStoreService
+    ) {
+        this.patronsSelected = new EventEmitter<any>();
+        this.dataSource = new GridDataSource();
+        this.dataSource.getRows = (pager: Pager, sort: any[]) => {
+            return this.getRows(pager, sort);
+        };
+    }
+
+    ngOnInit() {
+        this.searchOrg = this.org.root();
+        this.store.getItemBatch([EXPAND_FORM, INCLUDE_INACTIVE])
+            .then(settings => {
+                this.expandForm = settings[EXPAND_FORM];
+                this.search.inactive = settings[INCLUDE_INACTIVE];
+            });
+    }
+
+    ngAfterViewInit() {
+        this.renderer.selectRootElement('#focus-this-input').focus();
+    }
+
+    toggleExpandForm() {
+        this.expandForm = !this.expandForm;
+        if (this.expandForm) {
+            this.store.setItem(EXPAND_FORM, true);
+        } else {
+            this.store.removeItem(EXPAND_FORM);
+        }
+    }
+
+    toggleIncludeInactive() {
+        if (this.search.inactive) { // value set by ngModel
+            this.store.setItem(INCLUDE_INACTIVE, true);
+        } else {
+            this.store.removeItem(INCLUDE_INACTIVE);
+        }
+    }
+
+    rowsSelected(rows: IdlObject | IdlObject[]) {
+        this.patronsSelected.emit([].concat(rows));
+    }
+
+    getSelected(): IdlObject[] {
+        return this.searchGrid ?
+            this.searchGrid.context.getSelectedRows() : [];
+    }
+
+    go() {
+        this.searchGrid.reload();
+    }
+
+    clear() {
+        this.search = {profile: null};
+        this.searchOrg = this.org.root();
+    }
+
+    getRows(pager: Pager, sort: any[]): Observable<any> {
+
+        let observable: Observable<IdlObject>;
+
+        if (this.search.id) {
+            observable = this.searchById();
+        } else {
+            observable = this.searchByForm(pager, sort);
+        }
+
+        return observable.pipe(map(user => this.localFleshUser(user)));
+    }
+
+    localFleshUser(user: IdlObject): IdlObject {
+        user.home_ou(this.org.get(user.home_ou()));
+        return user;
+    }
+
+    searchByForm(pager: Pager, sort: any[]): Observable<IdlObject> {
+
+        const search = this.compileSearch();
+        if (!search) { return of(); }
+
+        const sorter = this.compileSort(sort);
+
+        return this.net.request(
+            'open-ils.actor',
+            'open-ils.actor.patron.search.advanced.fleshed',
+            this.auth.token(),
+            this.compileSearch(),
+            pager.limit,
+            sorter,
+            null, // ?
+            this.searchOrg.id(),
+            DEFAULT_FLESH,
+            pager.offset
+        );
+    }
+
+    searchById(): Observable<IdlObject> {
+        return this.net.request(
+            'open-ils.actor',
+            'open-ils.actor.user.fleshed.retrieve',
+            this.auth.token(), this.search.id, DEFAULT_FLESH
+        );
+    }
+
+    compileSort(sort: any[]): string[] {
+        if (!sort || sort.length === 0) { return DEFAULT_SORT; }
+        return sort.map(def => `${def.name} ${def.dir}`);
+    }
+
+    compileSearch(): any {
+
+        let hasSearch = false;
+        const search: Object = {};
+
+        Object.keys(this.search).forEach(field => {
+            search[field] = this.mapSearchField(field);
+            if (search[field]) { hasSearch = true; }
+        });
+
+        return hasSearch ? search : null;
+    }
+
+    isValue(val: any): boolean {
+        return (val !== null && val !== undefined && val !== '');
+    }
+
+    mapSearchField(field: string): any {
+
+        const value = this.search[field];
+        if (!this.isValue(value)) { return null; }
+
+        const chunk = {value: value, group: 0};
+
+        switch (field) {
+
+            case 'name': // name keywords
+            case 'inactive':
+                delete chunk.group;
+                break;
+
+            case 'street1':
+            case 'street2':
+            case 'city':
+            case 'state':
+            case 'post_code':
+                chunk.group = 1;
+                break;
+
+            case 'phone':
+            case 'ident':
+                chunk.group = 2;
+                break;
+
+            case 'card':
+                chunk.group = 3;
+                break;
+
+            case 'profile':
+                chunk.group = 5;
+                chunk.value = chunk.value.id(); // pgt object
+                break;
+
+            case 'dob_day':
+            case 'dob_month':
+            case 'dob_year':
+                chunk.group = 4;
+                chunk.value = chunk.value.replace(/\D/g, '');
+
+                if (!field.match(/year/)) {
+                    // force day/month to be 2 digits
+                    chunk[field].value = ('0' + value).slice(-2);
+                }
+                break;
+        }
+
+        // Confirm the value wasn't scrubbed away above
+        if (!this.isValue(chunk.value)) { return null; }
+
+        return chunk;
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/styles.css b/Open-ILS/src/eg2/src/styles.css
index 169cf639b2..d1144fd9fb 100644
--- a/Open-ILS/src/eg2/src/styles.css
+++ b/Open-ILS/src/eg2/src/styles.css
@@ -89,6 +89,11 @@ h5 {font-size: .95rem}
     line-height: inherit;
 }
 
+.mat-icon-shrunk-in-button {
+    line-height: inherit;
+    font-size: 18px;
+}
+
 .input-group .mat-icon-in-button {
     font-size: .88rem !important; /* useful for buttons that cuddle up with inputs */
 }

commit 63c7000c3d4166a7b832ee5e28a399dc555401a1
Author: Bill Erickson <berickxx at gmail.com>
Date:   Fri Jan 17 11:22:09 2020 -0500

    LP1860044 Release Notes for Ang Cat Highlights
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Ruth Frasur <rfrasur at gmail.com>

diff --git a/docs/RELEASE_NOTES_NEXT/Client/staff-cat-highlighting.adoc b/docs/RELEASE_NOTES_NEXT/Client/staff-cat-highlighting.adoc
new file mode 100644
index 0000000000..2757876123
--- /dev/null
+++ b/docs/RELEASE_NOTES_NEXT/Client/staff-cat-highlighting.adoc
@@ -0,0 +1,6 @@
+Angular Staff Catalog gets Search Highlighting
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+Search text highlighting is now supported on the search results and
+record details pages in the Angular staff catalog for searches that
+support highlighting.
+

commit 0df5d1f8f7e0d907489feaa0156b966440310fb3
Author: Bill Erickson <berickxx at gmail.com>
Date:   Thu Jan 16 13:23:15 2020 -0500

    LP1860044 Angular catalog search result highlights
    
    Support search field highlighting in the Angular staff catalog
    search result and record detail pages.
    
    Adds a new <eg-bib-display-field /> component for rendering the
    highlighted content.
    
    Move the catalog-common module import into the staff common module so
    the bib-summary component has access to the new display-field component.
    
    Drop the default search result page size to 10 for consistency with
    other catalogs (and to speed up rendering).  Note users can still set
    the page size of their choice via user settings.
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Ruth Frasur <rfrasur at gmail.com>

diff --git a/Open-ILS/src/eg2/src/app/share/catalog/bib-display-field.component.css b/Open-ILS/src/eg2/src/app/share/catalog/bib-display-field.component.css
new file mode 100644
index 0000000000..f4dfc111e5
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/share/catalog/bib-display-field.component.css
@@ -0,0 +1,11 @@
+
+.oils_SH {
+  font-weight: bolder;
+  background-color: #99ff99;
+}
+
+.oils_SH.identifier {
+  font-weight: bolder;
+  background-color: #42b0f4;
+}
+
diff --git a/Open-ILS/src/eg2/src/app/share/catalog/bib-display-field.component.html b/Open-ILS/src/eg2/src/app/share/catalog/bib-display-field.component.html
new file mode 100644
index 0000000000..021e451ecc
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/share/catalog/bib-display-field.component.html
@@ -0,0 +1,7 @@
+
+<ng-container 
+  *ngFor="let val of getDisplayStrings(); let first = first">
+  <ng-container *ngIf="joiner && !first">{{joiner}} </ng-container>
+  <span [innerHTML]="val"></span>
+</ng-container>
+
diff --git a/Open-ILS/src/eg2/src/app/share/catalog/bib-display-field.component.ts b/Open-ILS/src/eg2/src/app/share/catalog/bib-display-field.component.ts
new file mode 100644
index 0000000000..abcbb4630a
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/share/catalog/bib-display-field.component.ts
@@ -0,0 +1,62 @@
+import {Component, OnInit, Input, ViewEncapsulation} from '@angular/core';
+import {NetService} from '@eg/core/net.service';
+import {OrgService} from '@eg/core/org.service';
+import {AuthService} from '@eg/core/auth.service';
+import {BibRecordService, BibRecordSummary
+    } from '@eg/share/catalog/bib-record.service';
+
+/* Display content from a bib summary display field.  If highlight
+ * data is avaialble, it will be used in lieu of the plan display string.
+ *
+ * <eg-bib-display-field field="title" [summary]="summary"
+ *  [usePlaceholder]="true"></eg-bib-display-field>
+ */
+
+// non-collapsing space
+const PAD_SPACE = ' '; // U+2007
+
+ at Component({
+  selector: 'eg-bib-display-field',
+  templateUrl: 'bib-display-field.component.html',
+  styleUrls: ['bib-display-field.component.css'],
+  encapsulation: ViewEncapsulation.None // required for search highlighting
+})
+export class BibDisplayFieldComponent implements OnInit {
+
+    @Input() summary: BibRecordSummary;
+    @Input() field: string; // display field name
+
+    // Used to join multi fields
+    @Input() joiner: string;
+
+    // If true, replace empty values with a non-collapsing space.
+    @Input() usePlaceholder: boolean;
+
+    constructor() {}
+
+    ngOnInit() {}
+
+    // Returns an array of display values which may either be
+    // plain string values or strings with embedded HTML markup
+    // for search results highlighting.
+    getDisplayStrings(): string[] {
+        const replacement = this.usePlaceholder ? PAD_SPACE : '';
+
+        if (!this.summary) { return [replacement]; }
+
+        const scrunch = (value) => {
+            if (Array.isArray(value)) {
+                return value;
+            } else {
+                return [value || replacement];
+            }
+        };
+
+        return scrunch(
+            this.summary.displayHighlights[this.field] ||
+            this.summary.display[this.field]
+        );
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/share/catalog/bib-record.service.ts b/Open-ILS/src/eg2/src/app/share/catalog/bib-record.service.ts
index b2058e7aed..83d66c0654 100644
--- a/Open-ILS/src/eg2/src/app/share/catalog/bib-record.service.ts
+++ b/Open-ILS/src/eg2/src/app/share/catalog/bib-record.service.ts
@@ -31,6 +31,7 @@ export class BibRecordSummary {
     holdCount: number;
     bibCallNumber: string;
     net: NetService;
+    displayHighlights: {[name: string]: string | string[]} = {};
 
     constructor(record: IdlObject, orgId: number, orgDepth: number) {
         this.id = Number(record.id());
diff --git a/Open-ILS/src/eg2/src/app/share/catalog/catalog-common.module.ts b/Open-ILS/src/eg2/src/app/share/catalog/catalog-common.module.ts
index f9e628ed0e..ba8c915884 100644
--- a/Open-ILS/src/eg2/src/app/share/catalog/catalog-common.module.ts
+++ b/Open-ILS/src/eg2/src/app/share/catalog/catalog-common.module.ts
@@ -6,17 +6,20 @@ import {CatalogUrlService} from './catalog-url.service';
 import {BibRecordService} from './bib-record.service';
 import {UnapiService} from './unapi.service';
 import {MarcHtmlComponent} from './marc-html.component';
+import {BibDisplayFieldComponent} from './bib-display-field.component';
 
 
 @NgModule({
     declarations: [
-        MarcHtmlComponent
+        MarcHtmlComponent,
+        BibDisplayFieldComponent
     ],
     imports: [
         EgCommonModule
     ],
     exports: [
-        MarcHtmlComponent
+        MarcHtmlComponent,
+        BibDisplayFieldComponent
     ],
     providers: [
         CatalogService,
diff --git a/Open-ILS/src/eg2/src/app/share/catalog/catalog.service.ts b/Open-ILS/src/eg2/src/app/share/catalog/catalog.service.ts
index 2aaaf1f3ae..3b50f61e43 100644
--- a/Open-ILS/src/eg2/src/app/share/catalog/catalog.service.ts
+++ b/Open-ILS/src/eg2/src/app/share/catalog/catalog.service.ts
@@ -135,20 +135,18 @@ export class CatalogService {
             method += '.staff';
         }
 
-        return new Promise((resolve, reject) => {
-            this.net.request(
-                'open-ils.search', method, {
-                    limit : ctx.pager.limit + 1,
-                    offset : ctx.pager.offset
-                }, fullQuery, true
-            ).subscribe(result => {
-                this.applyResultData(ctx, result);
-                ctx.searchState = CatalogSearchState.COMPLETE;
-                this.onSearchComplete.emit(ctx);
-                resolve();
-            });
+        return this.net.request(
+            'open-ils.search', method, {
+                limit : ctx.pager.limit + 1,
+                offset : ctx.pager.offset
+            }, fullQuery, true
+        ).toPromise()
+        .then(result => this.applyResultData(ctx, result))
+        .then(_ => this.fetchFieldHighlights(ctx))
+        .then(_ => {
+            ctx.searchState = CatalogSearchState.COMPLETE;
+            this.onSearchComplete.emit(ctx);
         });
-
     }
 
     // When showing titles linked to a browse entry, fetch
@@ -212,6 +210,67 @@ export class CatalogService {
                 // May be reset when quickly navigating results.
                 ctx.result.records[idx] = summary;
             }
+
+            if (ctx.highlightData[summary.id]) {
+                summary.displayHighlights = ctx.highlightData[summary.id];
+            }
+        })).toPromise();
+    }
+
+    fetchFieldHighlights(ctx: CatalogSearchContext): Promise<any> {
+
+        let hlMap;
+
+        // Extract the highlight map.  Not all searches have them.
+        if ((hlMap = ctx.result)            &&
+            (hlMap = hlMap.global_summary)  &&
+            (hlMap = hlMap.query_struct)    &&
+            (hlMap = hlMap.additional_data) &&
+            (hlMap = hlMap.highlight_map)   &&
+            (Object.keys(hlMap).length > 0)) {
+        } else { return Promise.resolve(); }
+
+        let ids;
+        if (ctx.getHighlightsFor) {
+            ids = [ctx.getHighlightsFor];
+        } else {
+            // ctx.currentResultIds() returns bib IDs or metabib IDs
+            // depending on the search type.  If we have metabib IDs, map
+            // them to bib IDs for highlighting.
+            ids = ctx.currentResultIds();
+            if (ctx.termSearch.groupByMetarecord) {
+                ids = ids.map(mrId =>
+                    ctx.result.records.filter(r => mrId === r.metabibId)[0].id
+                );
+            }
+        }
+
+        return this.net.requestWithParamList( // API is list-based
+            'open-ils.search',
+            'open-ils.search.fetch.metabib.display_field.highlight',
+            [hlMap].concat(ids)
+        ).pipe(map(fields => {
+
+            if (fields.length === 0) { return; }
+
+            // Each 'fields' collection is an array of display field
+            // values whose text is augmented with highlighting markup.
+            const highlights = ctx.highlightData[fields[0].source] = {};
+
+            fields.forEach(field => {
+                const dfMap = this.cmfMap[field.field].display_field_map();
+                if (!dfMap) { return; } // pretty sure this can't happen.
+
+                if (dfMap.multi() === 't') {
+                    if (!highlights[dfMap.name()]) {
+                        highlights[dfMap.name()] = [];
+                    }
+                    (highlights[dfMap.name()] as string[]).push(field.highlight);
+                } else {
+                    highlights[dfMap.name()] = field.highlight;
+                }
+            });
+
         })).toPromise();
     }
 
@@ -312,14 +371,15 @@ export class CatalogService {
     }
 
     fetchCmfs(): Promise<void> {
-        // At the moment, we only need facet CMFs.
         if (Object.keys(this.cmfMap).length) {
             return Promise.resolve();
         }
 
         return new Promise((resolve, reject) => {
             this.pcrud.search('cmf',
-                {facet_field : 't'}, {}, {atomic: true, anonymous: true}
+                {'-or': [{facet_field : 't'}, {display_field: 't'}]},
+                {flesh: 1, flesh_fields: {cmf: ['display_field_map']}},
+                {atomic: true, anonymous: true}
             ).subscribe(
                 cmfs => {
                     cmfs.forEach(c => this.cmfMap[c.id()] = c);
diff --git a/Open-ILS/src/eg2/src/app/share/catalog/search-context.ts b/Open-ILS/src/eg2/src/app/share/catalog/search-context.ts
index 041d710a4b..f993b8ccbc 100644
--- a/Open-ILS/src/eg2/src/app/share/catalog/search-context.ts
+++ b/Open-ILS/src/eg2/src/app/share/catalog/search-context.ts
@@ -347,6 +347,12 @@ export class CatalogSearchContext {
     // List of IDs in page/offset context.
     resultIds: number[];
 
+    // If a bib ID is provided, instruct the search code to
+    // only fetch field highlight data for a single record instead
+    // of all search results.
+    getHighlightsFor: number;
+    highlightData: {[id: number]: {[field: string]: string | string[]}} = {};
+
     // Utility stuff
     pager: Pager;
     org: OrgService;
@@ -403,6 +409,7 @@ export class CatalogSearchContext {
         this.showBasket = false;
         this.result = new CatalogSearchResults();
         this.resultIds = [];
+        this.highlightData = {};
         this.searchState = CatalogSearchState.PENDING;
         this.termSearch.reset();
         this.marcSearch.reset();
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 d9e2143ed8..3ad00a9942 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,7 +1,6 @@
 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';
 import {HoldsModule} from '@eg/staff/share/holds/holds.module';
 import {HoldingsModule} from '@eg/staff/share/holdings/holdings.module';
@@ -61,7 +60,6 @@ import {PreferencesComponent} from './prefs.component';
   imports: [
     StaffCommonModule,
     FmRecordEditorModule,
-    CatalogCommonModule,
     CatalogRoutingModule,
     HoldsModule,
     HoldingsModule,
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/catalog.service.ts b/Open-ILS/src/eg2/src/app/staff/catalog/catalog.service.ts
index e6da35879b..05f58814bf 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/catalog.service.ts
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/catalog.service.ts
@@ -64,7 +64,7 @@ export class StaffCatalogService {
         }
 
         if (!this.searchContext.pager.limit) {
-            this.searchContext.pager.limit = this.defaultSearchLimit || 20;
+            this.searchContext.pager.limit = this.defaultSearchLimit || 10;
         }
     }
 
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/pagination.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/record/pagination.component.ts
index 88214cd805..038679a5e4 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/record/pagination.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/pagination.component.ts
@@ -137,17 +137,23 @@ export class RecordPaginationComponent implements OnInit {
             return Promise.resolve();
         }
 
-        const origPager = this.searchContext.pager;
+        const ctx = this.searchContext;
+
+        const origPager = ctx.pager;
         const tmpPager = new Pager();
         tmpPager.limit = limit || 1000;
 
-        this.searchContext.pager = tmpPager;
+        ctx.pager = tmpPager;
+
+        // Avoid fetching highlight data for a potentially large
+        // list of record IDs
+        ctx.getHighlightsFor = this.id;
 
-        return this.cat.search(this.searchContext)
-        .then(
-            ok => this.searchContext.pager = origPager,
-            notOk => this.searchContext.pager = origPager
-        );
+        return this.cat.search(ctx)
+        .then(_ => {
+            ctx.pager = origPager;
+            ctx.getHighlightsFor = null;
+        });
     }
 
     returnToSearch(): void {
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html
index cf082f9668..b82dd74003 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html
@@ -10,8 +10,8 @@
 </eg-confirm-dialog>
 
 <div id="staff-catalog-record-container">
-  <div id='staff-catalog-bib-summary-container' class='mb-1'>
-    <eg-bib-summary [bibSummary]="summary">
+  <div id='staff-catalog-bib-summary-container' class='mt-1'>
+    <eg-bib-summary [bibSummary]="summaryForDisplay()">
     </eg-bib-summary>
   </div>
   <div class="row ml-0 mr-0">
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.ts
index dc8d8dfce8..b900ea8734 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.ts
@@ -151,6 +151,23 @@ export class RecordComponent implements OnInit {
         });
     }
 
+    // Lets us intercept the summary object and augment it with
+    // search highlight data if/when it becomes available from
+    // an externally executed search.
+    summaryForDisplay(): BibRecordSummary {
+        if (!this.summary) { return null; }
+        const sum = this.summary;
+        const ctx = this.searchContext;
+
+        if (Object.keys(sum.displayHighlights).length === 0) {
+            if (ctx.highlightData[sum.id]) {
+                sum.displayHighlights = ctx.highlightData[sum.id];
+            }
+        }
+
+        return this.summary;
+    }
+
     currentSearchOrg(): IdlObject {
         if (this.staffCat && this.staffCat.searchContext) {
             return this.staffCat.searchContext.searchOrg;
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.css b/Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.css
index 3d753f4e42..2930138605 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.css
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.css
@@ -1,7 +1,7 @@
 
 /**
- * Force the jacket image column to consume a consistent amount of 
- * horizontal space, while allowing some room for the browser to 
+ * Force the jacket image column to consume a consistent amount of
+ * horizontal space, while allowing some room for the browser to
  * render the correct aspect ratio.
  */
 .record-jacket-div {
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.html
index a27c1bdf34..65209a0b4f 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.html
@@ -1,8 +1,3 @@
-<!-- 
-  TODO
-  routerLink's
-  egDateFilter's
--->
 
 <div class="col-lg-12 card tight-card mb-2 bg-light">
   <div class="card-body">
@@ -18,8 +13,6 @@
           <input class="pl-1" type='checkbox' [(ngModel)]="isRecordSelected"
             (change)="toggleBasketEntry()"/>
         </label>
-        <!-- XXX hard-coded width so columns align vertically regardless
-             of the presence of a jacket image -->
         <div class="pl-2 record-jacket-div" >
           <ng-container *ngIf="hasMrConstituentRecords(summary)">
             <a routerLink="/staff/catalog/search"
@@ -46,27 +39,28 @@
         <div class="flex-1 pl-2">
           <div class="row">
             <div class="col-lg-12 font-weight-bold">
-              <!-- nbsp allows the column to take shape when no value exists -->
               <ng-container *ngIf="hasMrConstituentRecords(summary)">
                   <a routerLink="/staff/catalog/search"
                     [queryParams]="appendFromMrParam(summary)">
-                    {{summary.display.title || ' '}}
+                    <eg-bib-display-field [summary]="summary" field="title" 
+                      [usePlaceholder]="true"></eg-bib-display-field>
                   </a>
               </ng-container>
               <ng-container *ngIf="!hasMrConstituentRecords(summary)">
                 <a routerLink="/staff/catalog/record/{{summary.id}}"
                   [queryParams]="currentParams()">
-                  {{summary.display.title || ' '}}
+                  <eg-bib-display-field [summary]="summary" field="title" 
+                    [usePlaceholder]="true"></eg-bib-display-field>
                 </a>
               </ng-container>
             </div>
           </div>
           <div class="row pt-2">
             <div class="col-lg-12">
-              <!-- nbsp allows the column to take shape when no value exists -->
               <a routerLink="/staff/catalog/search"
-                  [queryParams]="getAuthorSearchParams(summary)">
-                {{summary.display.author || ' '}}
+                [queryParams]="getAuthorSearchParams(summary)">
+                <eg-bib-display-field [summary]="summary" field="author" 
+                  [usePlaceholder]="true"></eg-bib-display-field>
               </a>
             </div>
           </div>
@@ -88,32 +82,48 @@
               <ng-container *ngIf="summary.display.physical_description">
                 <!-- [].concat() to avoid modifying the summary arrays -->
                 <div class="pb-1" i18n>Phys. Desc.: 
-                  {{[].concat(summary.display.physical_description).join(', ')}}
+                  <eg-bib-display-field [summary]="summary" 
+                    field="physical_description" joiner=","></eg-bib-display-field>
                 </div>
               </ng-container>
               <ng-container *ngIf="summary.display.edition">
-                <div class="pb-1" i18n>Edition: {{summary.display.edition}}</div>
+                <div class="pb-1" i18n>Edition: 
+                  <eg-bib-display-field [summary]="summary" 
+                    field="edition" joiner=","></eg-bib-display-field>
+                </div>
               </ng-container>
               <ng-container *ngIf="summary.display.publisher || summary.display.pubdate">
                 <!-- note publisher typically includes pubdate -->
                 <ng-container *ngIf="summary.display.publisher; else pubDate">
-                  <div class="pb-1" i18n>Publisher: {{summary.display.publisher}}</div>
+                  <div class="pb-1" i18n>Publisher:
+                  <eg-bib-display-field [summary]="summary" field="publisher">
+                  </eg-bib-display-field>
+                  </div>
                 </ng-container>
                 <ng-template #pubDate>
-                  <div class="pb-1" i18n>Pub Date: {{summary.display.pubdate}}</div>
+                  <div class="pb-1" i18n>Pub Date: 
+                    <eg-bib-display-field [summary]="summary" field="pubdate">
+                    </eg-bib-display-field>
+                  </div>
                 </ng-template>
               </ng-container>
               <ng-container *ngIf="summary.display.isbn">
                 <div class="pb-1" i18n>ISBN: 
-                  {{[].concat(summary.display.isbn).join(', ')}}</div>
+                  <eg-bib-display-field [summary]="summary" 
+                    field="isbn" joiner=","></eg-bib-display-field>
+                </div>
               </ng-container>
               <ng-container *ngIf="summary.display.upc">
                 <div class="pb-1" i18n>UPC: 
-                  {{[].concat(summary.display.upc).join(', ')}}</div>
+                  <eg-bib-display-field [summary]="summary" 
+                    field="upc" joiner=","></eg-bib-display-field>
+                </div>
               </ng-container>
               <ng-container *ngIf="summary.display.issn">
                 <div i18n>ISSN: 
-                  {{[].concat(summary.display.issn).join(', ')}}</div>
+                  <eg-bib-display-field [summary]="summary" 
+                    field="issn" joiner=","></eg-bib-display-field>
+                </div>
               </ng-container>
             </div>
           </div>
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 c0fe41b621..1d641e435a 100644
--- a/Open-ILS/src/eg2/src/app/staff/common.module.ts
+++ b/Open-ILS/src/eg2/src/app/staff/common.module.ts
@@ -3,6 +3,7 @@ import {EgCommonModule} from '@eg/common.module';
 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 {CatalogCommonModule} from '@eg/share/catalog/catalog-common.module';
 import {StaffBannerComponent} from './share/staff-banner.component';
 import {AccessKeyDirective} from '@eg/share/accesskey/accesskey.directive';
 import {AccessKeyService} from '@eg/share/accesskey/accesskey.service';
@@ -39,12 +40,14 @@ import {PatronBarcodeValidatorDirective} from '@eg/share/validators/patron_barco
   imports: [
     EgCommonModule,
     CommonWidgetsModule,
-    GridModule
+    GridModule,
+    CatalogCommonModule
   ],
   exports: [
     EgCommonModule,
     CommonWidgetsModule,
     GridModule,
+    CatalogCommonModule,
     StaffBannerComponent,
     AccessKeyDirective,
     AccessKeyInfoComponent,
diff --git a/Open-ILS/src/eg2/src/app/staff/share/bib-summary/bib-summary.component.html b/Open-ILS/src/eg2/src/app/staff/share/bib-summary/bib-summary.component.html
index 45345d2501..3fd955888f 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/bib-summary/bib-summary.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/share/bib-summary/bib-summary.component.html
@@ -47,7 +47,10 @@
           <li class="list-group-item">
             <div class="d-flex">
               <div class="flex-1 font-weight-bold" i18n>Title:</div>
-              <div class="flex-3">{{summary.display.title}}</div>
+              <div class="flex-3">
+                <eg-bib-display-field [summary]="summary" field="title">
+                </eg-bib-display-field>
+              </div>
               <div class="flex-1 font-weight-bold pl-1" i18n>Edition:</div>
               <div class="flex-1">{{summary.display.edition}}</div>
               <div class="flex-1 font-weight-bold" i18n>TCN:</div>

commit 28ecd418244d2974498470d7cbe45a525df14e27
Author: Bill Erickson <berickxx at gmail.com>
Date:   Mon Sep 16 11:26:23 2019 -0400

    LP1841823 Marc flat editor repair slashes (AngJS)
    
    Replace all control field spaces with backslashes in MARC Flat text
    editor (AngJS edition).
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Elaine Hardy <ehardy at georgialibraries.org>

diff --git a/Open-ILS/web/js/ui/default/staff/marcrecord.js b/Open-ILS/web/js/ui/default/staff/marcrecord.js
index 9fe525b10a..2e3a422a70 100644
--- a/Open-ILS/web/js/ui/default/staff/marcrecord.js
+++ b/Open-ILS/web/js/ui/default/staff/marcrecord.js
@@ -297,7 +297,7 @@ var MARC21 = {
                             new MARC21.Field({
                                 record : me,
                                 tag    : line_tag(current_line),
-                                data   : cf_line_data(current_line).replace('\\',' ','g'),
+                                data   : cf_line_data(current_line).replace(/\\/g, ' ')
                             })
                         );
                     }
@@ -367,7 +367,7 @@ var MARC21 = {
 
             mtxt += this.fields.map( function (f) {
                 if (f.isControlfield()) {
-                    if (f.data) return '=' + f.tag + ' ' + f.data.replace(' ','\\','g');
+                    if (f.data) return '=' + f.tag + ' ' + f.data.replace(/ /g, '\\');
                     return '=' + f.tag;
                 } else {
                     return '=' + f.tag + ' ' +

commit c6b0494759c0755654fcdf9125cd5717361e0167
Author: Bill Erickson <berickxx at gmail.com>
Date:   Mon Sep 16 11:26:10 2019 -0400

    LP1841823 Marc flat editor repair slashes (Angular)
    
    Replace all control field spaces with backslashes in MARC Flat text
    editor, Anglular edition.
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Elaine Hardy <ehardy at georgialibraries.org>

diff --git a/Open-ILS/src/eg2/src/assets/js/marcrecord.js b/Open-ILS/src/eg2/src/assets/js/marcrecord.js
index 04d8c74cce..97340c0602 100644
--- a/Open-ILS/src/eg2/src/assets/js/marcrecord.js
+++ b/Open-ILS/src/eg2/src/assets/js/marcrecord.js
@@ -15,7 +15,7 @@
  */
 
 /*
- * Copy of file from Open-ILS/web/js/ui/default/staff/marcedit.js
+ * Copy of file from Open-ILS/web/js/ui/default/staff/marcrecord.js
  *
  * This copy of the the MARC21 library heavily modified by
  * Bill Erickson <berickxx at gmail.com> 2019 circa Evergreen 3.3.
@@ -311,7 +311,7 @@ var MARC21 = {
                             new MARC21.Field({
                                 record : me,
                                 tag    : line_tag(current_line),
-                                data   : cf_line_data(current_line).replace('\\',' ','g'),
+                                data   : cf_line_data(current_line).replace(/\\/g, ' ')
                             })
                         );
                     }
@@ -381,7 +381,7 @@ var MARC21 = {
 
             mtxt += this.fields.map( function (f) {
                 if (f.isControlfield()) {
-                    if (f.data) return '=' + f.tag + ' ' + f.data.replace(' ','\\','g');
+                    if (f.data) return '=' + f.tag + ' ' + f.data.replace(/ /g, '\\');
                     return '=' + f.tag;
                 } else {
                     return '=' + f.tag + ' ' +

commit e01c8ad84163468fe9c76584e56c90382fe46069
Author: Bill Erickson <berickxx at gmail.com>
Date:   Wed Jan 15 11:14:06 2020 -0500

    LP1859706 Map Angular cat "Patron View" to AngJS "OPAC View"
    
    The Angular catalog now treats the "Patron View" tab as the same as
    the "OPAC View" tab in the AngJS catalog for the purposes of saving a
    preferred default tab.
    
    Similarly, the Angular catalog-only "Item Table" tab maps to the AngJS
    "OPAC View" tab, since it's the closest analog.
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Ruth Frasur <rfrasur at gmail.com>

diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html
index 51d81b330c..cf082f9668 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html
@@ -36,7 +36,7 @@
     </div>
     <ngb-tabset #recordTabs [activeId]="recordTab" 
       (tabChange)="beforeTabChange($event)">
-      <ngb-tab title="Item Table" i18n-title id="catalog">
+      <ngb-tab title="Item Table" i18n-title id="item_table">
         <ng-template ngbTabContent>
           <eg-catalog-copies [recordId]="recordId"></eg-catalog-copies>
         </ng-template>
@@ -83,7 +83,7 @@
           </eg-catalog-record-conjoined>
         </ng-template>
       </ngb-tab>
-      <ngb-tab title="Patron View" i18n-title id="opac">
+      <ngb-tab title="Patron View" i18n-title id="catalog">
         <ng-template ngbTabContent>
           <eg-opac-record-detail [recordId]="recordId">
           </eg-opac-record-detail>
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.ts
index 9c0ce9d762..dc8d8dfce8 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.ts
@@ -44,7 +44,7 @@ export class RecordComponent implements OnInit {
 
         this.defaultTab =
             this.store.getLocalItem('eg.cat.default_record_tab')
-            || 'catalog';
+            || 'item_table';
 
         // Watch for URL record ID changes
         // This includes the initial route.
@@ -58,7 +58,7 @@ export class RecordComponent implements OnInit {
             this.searchContext = this.staffCat.searchContext;
 
             if (!this.recordTab) {
-                this.recordTab = this.defaultTab || 'catalog';
+                this.recordTab = this.defaultTab || 'item_table';
             }
 
             this.loadRecord();
diff --git a/Open-ILS/web/js/ui/default/staff/cat/catalog/app.js b/Open-ILS/web/js/ui/default/staff/cat/catalog/app.js
index f05c363295..491b6ca10b 100644
--- a/Open-ILS/web/js/ui/default/staff/cat/catalog/app.js
+++ b/Open-ILS/web/js/ui/default/staff/cat/catalog/app.js
@@ -630,6 +630,14 @@ function($scope , $routeParams , $location , $window , $q , egCore , egHolds , e
         });
     }
 
+    // Map the Angular catalog-only 'item_table' tab to the AngJS
+    // 'catalog' tab.
+    function get_default_record_tab() {
+        var tab = egCore.hatch.getLocalItem('eg.cat.default_record_tab');
+        if (!tab || tab === 'item_table') { return 'catalog'; }
+        return tab;
+    }
+
     // also set it when the iframe changes to a new record
     $scope.handle_page = function(url) {
 
@@ -669,8 +677,8 @@ function($scope , $routeParams , $location , $window , $q , egCore , egHolds , e
         // or we didn't change records on the OPAC load
         if (!$scope.in_opac_call && ($scope.record_id != prev_record_id)) {
             if ($scope.record_id) {
-                $scope.default_tab = egCore.hatch.getLocalItem( 'eg.cat.default_record_tab' );
-                tab = $routeParams.record_tab || $scope.default_tab || 'catalog';
+                $scope.default_tab = get_default_record_tab();
+                tab = $routeParams.record_tab || $scope.default_tab;
             } else {
                 tab = $routeParams.record_tab || 'catalog';
             }
@@ -2006,8 +2014,8 @@ function($scope , $routeParams , $location , $window , $q , egCore , egHolds , e
 
     var tab;
     if ($scope.record_id) {
-        $scope.default_tab = egCore.hatch.getLocalItem( 'eg.cat.default_record_tab' );
-        tab = $routeParams.record_tab || $scope.default_tab || 'catalog';
+        $scope.default_tab = get_default_record_tab();
+        tab = $routeParams.record_tab || $scope.default_tab;
 
     } else {
         tab = $routeParams.record_tab || 'catalog';

commit d1ea5bc27b6b0cc67fd3d99561da5dc69178cf18
Author: Bill Erickson <berickxx at gmail.com>
Date:   Thu Sep 19 16:14:06 2019 -0400

    LP1819236 Ang cat prevent keyword starts/exact searches
    
    Prevent users from attempting Keyword starts-with or matches-exactly
    searches since these are nonsensical.
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Ruth Frasur <rfrasur at gmail.com>

diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.html
index f5d2e50fb0..920b008074 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.html
@@ -35,6 +35,7 @@
             </div>
             <div class="col-lg-2 pl-0 pr-2">
               <select class="form-control" 
+                (change)="preventBogusCombos(idx)"
                 [(ngModel)]="context.termSearch.fieldClass[idx]">
                 <option i18n value='keyword'>Keyword</option>
                 <option i18n value='title'>Title</option>
@@ -50,8 +51,10 @@
                 <option i18n value='contains'>Contains</option>
                 <option i18n value='nocontains'>Does not contain</option>
                 <option i18n value='phrase'>Contains phrase</option>
-                <option i18n value='exact'>Matches exactly</option>
-                <option i18n value='starts'>Starts with</option>
+                <option [disabled]="context.termSearch.fieldClass[idx]=='keyword'"
+                  i18n value='exact'>Matches exactly</option>
+                <option [disabled]="context.termSearch.fieldClass[idx]=='keyword'"
+                  i18n value='starts'>Starts with</option>
               </select>
             </div>
             <div class="col-lg-4 pl-0 pr-2">
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.ts
index 357f7be493..043adb0ed1 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.ts
@@ -269,6 +269,16 @@ export class SearchFormComponent implements OnInit, AfterViewInit {
     searchIsActive(): boolean {
         return this.context.searchState === CatalogSearchState.SEARCHING;
     }
+
+    // It's possible to chose invalid combos depending on the order of selection
+    preventBogusCombos(idx: number) {
+        if (this.context.termSearch.fieldClass[idx] === 'keyword') {
+            const op = this.context.termSearch.matchOp[idx];
+            if (op === 'exact' || op === 'starts') {
+                this.context.termSearch.matchOp[idx] = 'contains';
+            }
+        }
+    }
 }
 
 

commit de3d6edee1cc948238c443772ff9b278d541a1c1
Author: Bill Erickson <berickxx at gmail.com>
Date:   Tue Jan 21 10:07:30 2020 -0500

    LP1860275 Staff catalog add mono part repair
    
    Fixes a bug in the New Monograph Part dialog which prevented passing the
    bib record ID during the creation process, which resulted in a database
    error and faulure to create the part.
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Tiffany Little <tlittle at georgialibraries.org>

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 504aa90a36..58f0de56eb 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
@@ -38,6 +38,10 @@ export class PartsComponent implements OnInit {
         }
     }
 
+    get recordId(): number {
+        return this.recId;
+    }
+
     constructor(
         private idl: IdlService,
         private org: OrgService,

commit 9194d9c4bc99f9c8f13632ef6f50e72793f392d1
Author: Bill Erickson <berickxx at gmail.com>
Date:   Fri Feb 21 11:33:43 2020 -0500

    LP1850938 Stamping DB upgrade (Ang Cat Prefs)
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>

diff --git a/Open-ILS/src/sql/Pg/002.schema.config.sql b/Open-ILS/src/sql/Pg/002.schema.config.sql
index 36b5d08863..6715c8c6b8 100644
--- a/Open-ILS/src/sql/Pg/002.schema.config.sql
+++ b/Open-ILS/src/sql/Pg/002.schema.config.sql
@@ -92,7 +92,7 @@ CREATE TRIGGER no_overlapping_deps
     BEFORE INSERT OR UPDATE ON config.db_patch_dependencies
     FOR EACH ROW EXECUTE PROCEDURE evergreen.array_overlap_check ('deprecates');
 
-INSERT INTO config.upgrade_log (version, applied_to) VALUES ('1197', :eg_version); -- berick/jboyer
+INSERT INTO config.upgrade_log (version, applied_to) VALUES ('1198', :eg_version); -- berick/tmccanna
 
 CREATE TABLE config.bib_source (
 	id		SERIAL	PRIMARY KEY,
diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.data.catalog-prefs.sql b/Open-ILS/src/sql/Pg/upgrade/1198.data.catalog-prefs.sql
similarity index 80%
rename from Open-ILS/src/sql/Pg/upgrade/XXXX.data.catalog-prefs.sql
rename to Open-ILS/src/sql/Pg/upgrade/1198.data.catalog-prefs.sql
index 1380afa6af..77b63aca94 100644
--- a/Open-ILS/src/sql/Pg/upgrade/XXXX.data.catalog-prefs.sql
+++ b/Open-ILS/src/sql/Pg/upgrade/1198.data.catalog-prefs.sql
@@ -1,6 +1,6 @@
 BEGIN;
 
--- SELECT evergreen.upgrade_deps_block_check('TODO', :eg_version);
+SELECT evergreen.upgrade_deps_block_check('1198', :eg_version);
 
 INSERT INTO config.workstation_setting_type (name, grp, datatype, label)
 VALUES (

commit 4717051494a67d70f1cd7e604c5df91b66b56f94
Author: Bill Erickson <berickxx at gmail.com>
Date:   Fri Nov 1 10:59:51 2019 -0400

    LP1850938 Angular Catalog Prefs Release Notes
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Terran McCanna <tmccanna at georgialibraries.org>

diff --git a/docs/RELEASE_NOTES_NEXT/Client/ang-cat-prefs-page.adoc b/docs/RELEASE_NOTES_NEXT/Client/ang-cat-prefs-page.adoc
new file mode 100644
index 0000000000..ff23e5baa2
--- /dev/null
+++ b/docs/RELEASE_NOTES_NEXT/Client/ang-cat-prefs-page.adoc
@@ -0,0 +1,12 @@
+Angular Staff Catalog Preferences Page
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Adds a new "Catalog Preferences" interface, accessible directly from the
+catalog.  The UI houses the search preferences (default search lib,
+preferred library, default search tab) and a new staff-specific
+hits-per-page setting.  Other preferences may be added later.
+
+Adds support for selecting a default search tab using the existing
+'eg.search.adv_pane' setting.
+
+

commit 7dc6adfcca7e56b53906adb7110a25545483d1e1
Author: Bill Erickson <berickxx at gmail.com>
Date:   Thu Oct 31 17:56:39 2019 -0400

    LP1850938 Angular Catalog Preferences Page
    
    Adds a new "Catalog Preferences" interface, accessible directly from the
    catalog.  The UI houses the search preferences (default search lib,
    preferred library, default search tab), a new staff-specific
    hits-per-page setting.  Other preferences may be added later.
    
    Adds support for selecting a default search tab using the existing
    'eg.search.adv_pane' setting.
    
    Reduce API call count by loading more of the catalog preference settings
    in the main batch invoked by the page resolver.
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Terran McCanna <tmccanna at georgialibraries.org>

diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/basket-actions.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/basket-actions.component.html
index 2f32c22fc5..752557c503 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/basket-actions.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/basket-actions.component.html
@@ -12,26 +12,34 @@
       </a>
     </div>
   </div>
-  <div class="">
+  <div class="pr-1">
     <div ngbDropdown placement="bottom-right">
       <button class="btn btn-light" id="basketActions"
         [disabled]="!basketCount()"
         ngbDropdownToggle i18n>Basket Actions</button>
       <div ngbDropdownMenu aria-labelledby="basketActions">
-      <button class="dropdown-item" 
-        (click)="applyAction('view')" i18n>View Basket</button>
-      <button class="dropdown-item" 
-        (click)="applyAction('hold')" i18n>Place Hold</button>
-      <button class="dropdown-item" 
-        (click)="applyAction('print')" i18n>Print Title Details</button>
-      <button class="dropdown-item" 
-        (click)="applyAction('email')" i18n>Email Title Details</button>
-      <button class="dropdown-item" 
-        (click)="applyAction('bucket')" i18n>Add Basket to Bucket</button>
-      <button class="dropdown-item" 
-        (click)="applyAction('export_marc')" i18n>Export Records</button>
-      <button class="dropdown-item" 
-        (click)="applyAction('clear')" i18n>Clear Basket</button>
+        <button class="dropdown-item"
+          (click)="applyAction('view')" i18n>View Basket</button>
+        <button class="dropdown-item"
+          (click)="applyAction('hold')" i18n>Place Hold</button>
+        <button class="dropdown-item"
+          (click)="applyAction('print')" i18n>Print Title Details</button>
+        <button class="dropdown-item"
+          (click)="applyAction('email')" i18n>Email Title Details</button>
+        <button class="dropdown-item"
+          (click)="applyAction('bucket')" i18n>Add Basket to Bucket</button>
+        <button class="dropdown-item"
+          (click)="applyAction('export_marc')" i18n>Export Records</button>
+        <button class="dropdown-item"
+          (click)="applyAction('clear')" i18n>Clear Basket</button>
+      </div>
     </div>
   </div>
+  <div>
+    <!-- Note this Prefernces links is not specific to Basket handling,
+        but it's here since it allowed for consistent formatting -->
+    <a routerLink="/staff/catalog/prefs" queryParamsHandling="merge">
+      <button class="btn btn-light" i18n>Catalog Preferences</button>
+    </a>
+  </div>
 </div>
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 810b9507fd..d9e2143ed8 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
@@ -30,6 +30,7 @@ import {CnBrowseComponent} from './cnbrowse.component';
 import {CnBrowseResultsComponent} from './cnbrowse/results.component';
 import {SearchTemplatesComponent} from './search-templates.component';
 import {MarcEditModule} from '@eg/staff/share/marc-edit/marc-edit.module';
+import {PreferencesComponent} from './prefs.component';
 
 @NgModule({
   declarations: [
@@ -54,6 +55,7 @@ import {MarcEditModule} from '@eg/staff/share/marc-edit/marc-edit.module';
     SearchTemplatesComponent,
     CnBrowseComponent,
     OpacViewComponent,
+    PreferencesComponent,
     CnBrowseResultsComponent
   ],
   imports: [
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/catalog.service.ts b/Open-ILS/src/eg2/src/app/staff/catalog/catalog.service.ts
index 86501fc10f..e6da35879b 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/catalog.service.ts
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/catalog.service.ts
@@ -23,6 +23,9 @@ export class StaffCatalogService {
     // TODO: does unapi support pref-lib for result-page copy counts?
     prefOrg: IdlObject;
 
+    // Default search tab
+    defaultTab: string;
+
     // Cache the currently selected detail record (i.g. catalog/record/123)
     // summary so the record detail component can avoid duplicate fetches
     // during record tab navigation.
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/prefs.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/prefs.component.html
new file mode 100644
index 0000000000..b7ed34ed85
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/prefs.component.html
@@ -0,0 +1,84 @@
+<eg-catalog-search-form #searchForm></eg-catalog-search-form>
+
+<eg-staff-banner bannerText="Catalog Preferences"></eg-staff-banner>
+
+<eg-string #successMsg i18n-text text="Setting Update Succeeded"></eg-string>
+<eg-string #failMsg i18n-text text="Setting Update Failed"></eg-string>
+
+<div class="row border-bottom border-secondary p-2 m-2">
+  <div class="col-lg-2 offset-lg-1">
+    <label for="default-lib-selector" class="font-weight-bold" i18n>
+      Default Search Library
+    </label>
+  </div>
+  <div class="col-lg-2">
+    <eg-org-select domId="default-lib-selector" 
+      (onChange)="orgChanged($event, 'eg.search.search_lib')"
+      [applyOrgId]="settings['eg.search.search_lib']">
+    </eg-org-select>
+  </div>
+  <div class="col-lg-6" i18n>
+    The default search library setting determines what library is
+    searched from the advanced search screen and portal page by
+    default. Manual selection of a search library will override it. One
+    recommendation is to set the search library to the highest point you
+    would normally want to search.
+  </div>
+</div>
+
+<div class="row border-bottom border-secondary p-2 m-2">
+  <div class="col-lg-2 offset-lg-1">
+    <label for="pref-lib-selector" class="font-weight-bold" i18n>
+      Preferred Library
+    </label>
+  </div>
+  <div class="col-lg-2">
+    <eg-org-select domId="pref-lib-selector" 
+      (onChange)="orgChanged($event, 'eg.search.pref_lib')"
+      [applyOrgId]="settings['eg.search.pref_lib']">
+    </eg-org-select>
+  </div>
+  <div class="col-lg-6" i18n>
+    The preferred library is used to show copies and URIs regardless
+    of the library searched. One recommendation is to set this to your
+    workstation library so that local copies show up first in search
+    results.
+  </div>
+</div>
+
+<div class="row border-bottom border-secondary p-2 m-2">
+  <div class="col-lg-2 offset-lg-1">
+    <label for="def-pane-selector" class="font-weight-bold" i18n>
+      Default Search Pane
+    </label>
+  </div>
+  <div class="col-lg-2">
+    <eg-combobox [selectedId]="settings['eg.search.adv_pane']"
+      (onChange)="paneChanged($event)">
+      <eg-combobox-entry entryId="advanced" entryLabel="Keyword Search"></eg-combobox-entry>
+      <eg-combobox-entry entryId="numeric" entryLabel="Numeric Search"></eg-combobox-entry>
+      <eg-combobox-entry entryId="expert" entryLabel="MARC Search"></eg-combobox-entry>
+      <eg-combobox-entry entryId="browse" entryLabel="Browse"></eg-combobox-entry>
+      <eg-combobox-entry entryId="cnbrowse" entryLabel="Shelf Browse"></eg-combobox-entry>
+    </eg-combobox>
+  </div>
+  <div class="col-lg-6" i18n>
+    Focus this search tab by default when opening new catalog pages.
+  </div>
+</div>
+
+<div class="row border-bottom border-secondary p-2 m-2">
+  <div class="col-lg-2 offset-lg-1">
+    <label for="pref-lib-selector" class="font-weight-bold" i18n>
+      Search Results Per Page
+    </label>
+  </div>
+  <div class="col-lg-2">
+    <input type="number" min="1" max="100" class="form-control"
+      [(ngModel)]="settings['eg.catalog.results.count']"
+      (change)="countChanged()"/>
+  </div>
+  <div class="col-lg-6" i18n>
+    The number of search results to display per page.
+  </div>
+</div>
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/prefs.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/prefs.component.ts
new file mode 100644
index 0000000000..7d81a07eb6
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/prefs.component.ts
@@ -0,0 +1,73 @@
+import {Component, OnInit, ViewChild} from '@angular/core';
+import {IdlObject} from '@eg/core/idl.service';
+import {StaffCatalogService} from './catalog.service';
+import {ServerStoreService} from '@eg/core/server-store.service';
+import {ToastService} from '@eg/share/toast/toast.service';
+import {StringComponent} from '@eg/share/string/string.component';
+import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
+
+/* Component for managing catalog preferences */
+
+const CATALOG_PREFS = [
+    'eg.search.search_lib',
+    'eg.search.pref_lib',
+    'eg.search.adv_pane',
+    'eg.catalog.results.count'
+];
+
+ at Component({
+  templateUrl: 'prefs.component.html'
+})
+export class PreferencesComponent implements OnInit {
+
+    settings: Object = {};
+
+    @ViewChild('successMsg', {static: false}) successMsg: StringComponent;
+    @ViewChild('failMsg', {static: false}) failMsg: StringComponent;
+
+    constructor(
+        private store: ServerStoreService,
+        private toast: ToastService,
+        private staffCat: StaffCatalogService,
+    ) {}
+
+    ngOnInit() {
+        this.staffCat.createContext();
+
+        // Pre-fetched by the resolver.
+        this.store.getItemBatch(CATALOG_PREFS)
+        .then(settings => this.settings = settings);
+    }
+
+    orgChanged(org: IdlObject, setting: string) {
+        const localVar = setting === 'eg.search.search_lib' ?
+            'defaultSearchOrg' : 'prefOrg';
+
+        this.updateValue(setting, org ? org.id() : null)
+        .then(val => this.staffCat[localVar] = val);
+    }
+
+    paneChanged(entry: ComboboxEntry) {
+        this.updateValue('eg.search.adv_pane', entry ? entry.id : null)
+        .then(value => this.staffCat.defaultTab = value);
+    }
+
+    countChanged() {
+        this.updateValue('eg.catalog.results.count',
+            this.settings['eg.catalog.results.count'] || null)
+        .then(value => {
+            this.staffCat.searchContext.pager.limit = value || 20;
+        });
+    }
+
+    updateValue(setting: string, value: any): Promise<any> {
+        const promise = (value === null) ?
+            this.store.removeItem(setting) :
+            this.store.setItem(setting, value);
+
+        return promise
+            .then(_ => this.toast.success(this.successMsg.text))
+            .then(_ => value);
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/resolver.service.ts b/Open-ILS/src/eg2/src/app/staff/catalog/resolver.service.ts
index 1b6dac1d4a..842893cf11 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/resolver.service.ts
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/resolver.service.ts
@@ -43,18 +43,27 @@ export class CatalogResolver implements Resolve<Promise<any[]>> {
         return this.store.getItemBatch([
             'eg.search.search_lib',
             'eg.search.pref_lib',
+            'eg.search.adv_pane',
+            'eg.catalog.results.count',
             'cat.holdings_show_empty_org',
             'cat.holdings_show_empty',
             'cat.marcedit.stack_subfields',
             'cat.marcedit.flateditor',
             'cat.holdings_show_copies',
             'cat.holdings_show_vols',
+            'opac.staff_saved_search.size',
+            'eg.catalog.search_templates',
             'opac.staff_saved_search.size'
         ]).then(settings => {
             this.staffCat.defaultSearchOrg =
                 this.org.get(settings['eg.search.search_lib']);
             this.staffCat.prefOrg =
                 this.org.get(settings['eg.search.pref_lib']);
+            this.staffCat.defaultTab = settings['eg.search.adv_pane'];
+            if (settings['eg.catalog.results.count']) {
+               this.staffCat.defaultSearchLimit =
+                  Number(settings['eg.catalog.results.count']);
+            }
         });
     }
 }
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/routing.module.ts b/Open-ILS/src/eg2/src/app/staff/catalog/routing.module.ts
index b70290583e..85e958bd84 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/routing.module.ts
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/routing.module.ts
@@ -8,6 +8,7 @@ import {HoldComponent} from './hold/hold.component';
 import {BrowseComponent} from './browse.component';
 import {CnBrowseComponent} from './cnbrowse.component';
 import {CanDeactivateGuard} from '@eg/share/util/can-deactivate.guard';
+import {PreferencesComponent} from './prefs.component';
 
 const routes: Routes = [{
   path: '',
@@ -35,6 +36,10 @@ const routes: Routes = [{
     path: 'cnbrowse',
     component: CnBrowseComponent,
     resolve: {catResolver : CatalogResolver}
+  }, {
+    path: 'prefs',
+    component: PreferencesComponent,
+    resolve: {catResolver : CatalogResolver}
   }
 ];
 
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.ts
index 42e70861f6..357f7be493 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.ts
@@ -7,6 +7,13 @@ import {CatalogSearchContext, CatalogSearchState} from '@eg/share/catalog/search
 import {StaffCatalogService} from './catalog.service';
 import {NgbTabset, NgbTabChangeEvent} from '@ng-bootstrap/ng-bootstrap';
 
+// Maps opac-style default tab names to local tab names.
+const LEGACY_TAB_NAME_MAP = {
+    expert: 'marc',
+    numeric: 'ident',
+    advanced: 'term'
+};
+
 @Component({
   selector: 'eg-catalog-search-form',
   styleUrls: ['search-form.component.css'],
@@ -87,9 +94,18 @@ export class SearchFormComponent implements OnInit, AfterViewInit {
                     this.searchTab = 'ident';
                 } else if (this.context.browseSearch.isSearchable()) {
                     this.searchTab = 'browse';
-                } else {
-                    // Default tab
+                } else if (this.context.termSearch.isSearchable()) {
                     this.searchTab = 'term';
+
+                } else {
+
+                    this.searchTab =
+                        LEGACY_TAB_NAME_MAP[this.staffCat.defaultTab]
+                        || this.staffCat.defaultTab || 'term';
+
+                }
+
+                if (this.searchTab === 'term') {
                     this.refreshCopyLocations();
                 }
             }
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/search-templates.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/search-templates.component.ts
index 8a1eb7d0ff..46974509dd 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/search-templates.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/search-templates.component.ts
@@ -62,10 +62,9 @@ export class SearchTemplatesComponent extends DialogComponent implements OnInit
 
     ngOnInit() {
         this.context = this.staffCat.searchContext;
-        console.log('ngOnInit() with selected = ', this.staffCat.selectedTemplate);
 
-        this.org.settings('opac.staff_saved_search.size').then(sets => {
-            const size = sets['opac.staff_saved_search.size'] || 0;
+        this.serverStore.getItem('opac.staff_saved_search.size')
+        .then(size => {
             if (!size) { return; }
 
             this.recentSearchesCount = Number(size);
diff --git a/Open-ILS/src/sql/Pg/950.data.seed-values.sql b/Open-ILS/src/sql/Pg/950.data.seed-values.sql
index a49cf02de8..257117b1d0 100644
--- a/Open-ILS/src/sql/Pg/950.data.seed-values.sql
+++ b/Open-ILS/src/sql/Pg/950.data.seed-values.sql
@@ -20308,3 +20308,13 @@ VALUES (
     )
 );
 
+INSERT INTO config.workstation_setting_type (name, grp, datatype, label)
+VALUES (
+    'eg.catalog.results.count', 'gui', 'integer',
+    oils_i18n_gettext(
+        'eg.catalog.results.count',
+        'Catalog Results Page Size',
+        'cwst', 'label'
+    )
+);
+
diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.data.catalog-prefs.sql b/Open-ILS/src/sql/Pg/upgrade/XXXX.data.catalog-prefs.sql
new file mode 100644
index 0000000000..1380afa6af
--- /dev/null
+++ b/Open-ILS/src/sql/Pg/upgrade/XXXX.data.catalog-prefs.sql
@@ -0,0 +1,15 @@
+BEGIN;
+
+-- SELECT evergreen.upgrade_deps_block_check('TODO', :eg_version);
+
+INSERT INTO config.workstation_setting_type (name, grp, datatype, label)
+VALUES (
+    'eg.catalog.results.count', 'gui', 'integer',
+    oils_i18n_gettext(
+        'eg.catalog.results.count',
+        'Catalog Results Page Size',
+        'cwst', 'label'
+    )
+);
+
+COMMIT;

commit 54032550a5db3690cea5a75b3c183d5e690b08b2
Author: Bill Erickson <berickxx at gmail.com>
Date:   Wed Jan 15 10:32:52 2020 -0500

    LP1852782 Improve MARC edit save/delete button placement
    
    Move the Save Record button to the left of the Delete Record, which is
    more consistent with other yes/no button combinations in the client.
    Also adds additional spacing and a faint border between the two buttons.
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    
    Signed-off-by: Jane Sandberg <sandbej at linnbenton.edu>

diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.html b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.html
index e3daf9093e..95baa0765e 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.html
@@ -56,15 +56,17 @@
     </div>
   </ng-container>
 
+  <div class="pr-3 mr-3 border-right">
+    <button class="btn btn-success" (click)="saveRecord()"
+      [disabled]="record && record.deleted" i18n>Save Changes</button>
+  </div>
+
   <ng-container *ngIf="record && record.id">
     <button *ngIf="!record.deleted" class="btn btn-warning" 
       [disabled]="inPlaceMode" (click)="deleteRecord()" i18n>Delete Record</button>
     <button *ngIf="record.deleted" class="btn btn-info" 
       [disabled]="inPlaceMode" (click)="undeleteRecord()" i18n>Undelete Record</button>
   </ng-container>
-
-  <button class="btn btn-success ml-2" (click)="saveRecord()" 
-    [disabled]="record && record.deleted" i18n>Save Changes</button>
 </div>
 
 <ng-container *ngIf="dataSaving">

commit 5eb9013776160b9cc24cc8653f63859bca418c82
Author: Bill Erickson <berickxx at gmail.com>
Date:   Wed Jan 15 10:10:03 2020 -0500

    LP1852782 Fix Firefox contenteditable tabbing
    
    Use tabindex="0" instead of tabindex="" to indicate focusable content.
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    
    Signed-off-by: Jane Sandberg <sandbej at linnbenton.edu>

diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.html b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.html
index cba8925f03..0fb728b2c4 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.html
@@ -16,7 +16,7 @@ Track their labels here.
     spellcheck="false"
     class="d-inline-block text-dark text-break {{moreClasses}}"
     [ngClass]="{'auth-invalid': isAuthInvalid()}"
-    [attr.tabindex]="fieldText ? -1 : ''"
+    [attr.tabindex]="fieldText ? -1 : '0'"
     [attr.aria-label]="ariaLabel"
     [egContextMenu]="contextMenuEntries()"
     (menuItemSelected)="contextMenuChange($event.value)"
@@ -36,7 +36,7 @@ Track their labels here.
     [size]="inputSize()"
     [maxlength]="maxLength || ''"
     [disabled]="fieldText"
-    [attr.tabindex]="fieldText ? -1 : ''"
+    [attr.tabindex]="fieldText ? -1 : '0'"
     [attr.aria-label]="ariaLabel"
     [egContextMenu]="contextMenuEntries()"
     (menuItemSelected)="contextMenuChange($event.value)"

commit abd661656a4d3308aca3aa5ed4a5f6a408322502
Author: Bill Erickson <berickxx at gmail.com>
Date:   Fri Jan 10 16:47:44 2020 -0500

    LP1852782 Angular MARC record update API repairs
    
    Use the correct API when updating authority records.
    
    Also use the correct bib record update API per LP 1859191.
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    
    Signed-off-by: Jane Sandberg <sandbej at linnbenton.edu>

diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.ts
index 157ff0abc4..0bc308a6cc 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.ts
@@ -220,7 +220,9 @@ export class MarcEditorComponent implements OnInit {
     }
 
     modifyRecord(marcXml: string, sourceName: string, sourceId: number): Promise<any> {
-        const method = 'open-ils.cat.biblio.record.marc.replace';
+        const method = this.recordType === 'biblio' ?
+            'open-ils.cat.biblio.record.xml.update' :
+            'open-ils.cat.authority.record.overlay';
 
         return this.net.request('open-ils.cat', method,
             this.auth.token(), this.record.id, marcXml, sourceName
@@ -235,7 +237,8 @@ export class MarcEditorComponent implements OnInit {
                 return null;
             }
 
-            return response.marc();
+            // authority.record.overlay resturns a '1' on success.
+            return typeof response === 'object' ? response.marc() : marcXml;
         });
     }
 

commit 37885d3a0ba1008747f674387530d0497b1a6992
Author: Bill Erickson <berickxx at gmail.com>
Date:   Wed Jan 8 16:49:35 2020 -0500

    LP1852782 Linker links to auth record editor
    
    Adds a new UI at /staff/cat/authority/edit/ for finding authority
    records by ID and editing authority records via the Angular MARC editor.
    
    Modifies the "Cataloging" => "Retrieve Authority Record By ID" nav menu
    entry to point to the Angular version of the interface.
    
    Augments the MARC edit authority linking dialog to turn authority ID's
    into links which open the authority record in its own MARC editor in a
    new tab.
    
    Misc. MARC editor repairs related to loading authority records by ID.
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    
    Signed-off-by: Jane Sandberg <sandbej at linnbenton.edu>

diff --git a/Open-ILS/src/eg2/src/app/common.module.ts b/Open-ILS/src/eg2/src/app/common.module.ts
index 56eddab1eb..5250670fde 100644
--- a/Open-ILS/src/eg2/src/app/common.module.ts
+++ b/Open-ILS/src/eg2/src/app/common.module.ts
@@ -13,9 +13,9 @@ Note core services are injected into 'root'.
 They do not have to be added to the providers list.
 */
 
-// consider moving these to core...
 import {HtmlToTxtService} from '@eg/share/util/htmltotxt.service';
 import {PrintService} from '@eg/share/print/print.service';
+import {AnonCacheService} from '@eg/share/util/anon-cache.service';
 
 // Globally available components
 import {PrintComponent} from '@eg/share/print/print.component';
@@ -79,6 +79,7 @@ export class EgCommonModule {
         return {
             ngModule: EgCommonModule,
             providers: [
+                AnonCacheService,
                 HtmlToTxtService,
                 PrintService,
                 ToastService
diff --git a/Open-ILS/src/eg2/src/app/share/catalog/catalog-common.module.ts b/Open-ILS/src/eg2/src/app/share/catalog/catalog-common.module.ts
index 5b45d00a60..f9e628ed0e 100644
--- a/Open-ILS/src/eg2/src/app/share/catalog/catalog-common.module.ts
+++ b/Open-ILS/src/eg2/src/app/share/catalog/catalog-common.module.ts
@@ -1,7 +1,6 @@
 import {NgModule} from '@angular/core';
 import {EgCommonModule} from '@eg/common.module';
 import {CatalogService} from './catalog.service';
-import {AnonCacheService} from '@eg/share/util/anon-cache.service';
 import {BasketService} from './basket.service';
 import {CatalogUrlService} from './catalog-url.service';
 import {BibRecordService} from './bib-record.service';
@@ -20,12 +19,11 @@ import {MarcHtmlComponent} from './marc-html.component';
         MarcHtmlComponent
     ],
     providers: [
-        AnonCacheService,
         CatalogService,
         CatalogUrlService,
         UnapiService,
         BibRecordService,
-        BasketService,
+        BasketService
     ]
 })
 
diff --git a/Open-ILS/src/eg2/src/app/staff/cat/authority/authority.module.ts b/Open-ILS/src/eg2/src/app/staff/cat/authority/authority.module.ts
new file mode 100644
index 0000000000..ded954a9c7
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/cat/authority/authority.module.ts
@@ -0,0 +1,23 @@
+import {NgModule} from '@angular/core';
+import {StaffCommonModule} from '@eg/staff/common.module';
+import {CommonWidgetsModule} from '@eg/share/common-widgets.module';
+import {AuthorityRoutingModule} from './routing.module';
+import {MarcEditModule} from '@eg/staff/share/marc-edit/marc-edit.module';
+import {AuthorityMarcEditComponent} from './marc-edit.component';
+
+ at NgModule({
+  declarations: [
+    AuthorityMarcEditComponent
+  ],
+  imports: [
+    StaffCommonModule,
+    CommonWidgetsModule,
+    MarcEditModule,
+    AuthorityRoutingModule
+  ],
+  providers: [
+  ]
+})
+
+export class AuthorityModule {
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/cat/authority/marc-edit.component.html b/Open-ILS/src/eg2/src/app/staff/cat/authority/marc-edit.component.html
new file mode 100644
index 0000000000..e563e3a9b7
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/cat/authority/marc-edit.component.html
@@ -0,0 +1,32 @@
+
+<ng-container *ngIf="!authorityId">
+  <!-- If we don't have an authority ID, prompt the user to enter one -->
+  <eg-staff-banner bannerText="Find Authority Record By ID" i18n-bannerText>
+  </eg-staff-banner>
+
+  <div class="row">
+    <div class="col-lg-6 form-inline">
+      <div class="input-group">
+        <div class="input-group-prepend">
+          <span class="input-group-text" i18n>Authorty Record Id</span>
+        </div>
+        <input type="text" class="form-control" 
+          id='auth-id-input'
+          i18n-placeholder placeholder="Authorty Record Id" 
+          i18n-aria-label aria-label="Authorty Record Id"
+          (keyup.enter)="goToAuthority()" [(ngModel)]="loadId"/>
+        <button class="btn btn-success" (click)="goToAuthority()" i18n>Submit</button>
+      </div>
+    </div>
+  </div>
+</ng-container>
+
+<ng-container *ngIf="authorityId">
+  <eg-staff-banner bannerText="Edit Authority Record #{{authorityId}}" i18n-bannerText>
+  </eg-staff-banner>
+
+  <eg-marc-editor #marcEditor recordType="authority" [recordId]="authorityId">
+  </eg-marc-editor>
+</ng-container>
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/cat/authority/marc-edit.component.ts b/Open-ILS/src/eg2/src/app/staff/cat/authority/marc-edit.component.ts
new file mode 100644
index 0000000000..88f2cd032d
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/cat/authority/marc-edit.component.ts
@@ -0,0 +1,35 @@
+import {Component, OnInit, AfterViewInit, ViewChild, Renderer2} from '@angular/core';
+import {Router, ActivatedRoute, ParamMap} from '@angular/router';
+import {MarcSavedEvent} from '@eg/staff/share/marc-edit/editor.component';
+
+ at Component({
+  templateUrl: 'marc-edit.component.html'
+})
+export class AuthorityMarcEditComponent implements AfterViewInit {
+
+    authorityId: number;
+
+    // Avoid setting authorityId during lookup because it can
+    // cause the marc editor to load prematurely.
+    loadId: number;
+
+    constructor(
+        private router: Router,
+        private route: ActivatedRoute,
+        private renderer: Renderer2) {
+        this.authorityId = +this.route.snapshot.paramMap.get('id');
+    }
+
+    ngAfterViewInit() {
+        if (!this.authorityId) {
+            this.renderer.selectRootElement('#auth-id-input').focus();
+        }
+    }
+
+    goToAuthority() {
+        if (this.loadId) {
+            this.router.navigate([`/staff/cat/authority/edit/${this.loadId}`]);
+        }
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/cat/authority/routing.module.ts b/Open-ILS/src/eg2/src/app/staff/cat/authority/routing.module.ts
new file mode 100644
index 0000000000..cd6b3a138f
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/cat/authority/routing.module.ts
@@ -0,0 +1,20 @@
+import {NgModule} from '@angular/core';
+import {RouterModule, Routes} from '@angular/router';
+import {AuthorityMarcEditComponent} from './marc-edit.component';
+
+const routes: Routes = [{
+    path: 'edit',
+    component: AuthorityMarcEditComponent
+  }, {
+    path: 'edit/:id',
+    component: AuthorityMarcEditComponent
+}];
+
+ at NgModule({
+  imports: [RouterModule.forChild(routes)],
+  exports: [RouterModule],
+  providers: []
+})
+
+export class AuthorityRoutingModule {}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/cat/routing.module.ts b/Open-ILS/src/eg2/src/app/staff/cat/routing.module.ts
index a923b46822..67fb59b56c 100644
--- a/Open-ILS/src/eg2/src/app/staff/cat/routing.module.ts
+++ b/Open-ILS/src/eg2/src/app/staff/cat/routing.module.ts
@@ -4,6 +4,9 @@ import {RouterModule, Routes} from '@angular/router';
 const routes: Routes = [
   { path: 'vandelay',
     loadChildren: '@eg/staff/cat/vandelay/vandelay.module#VandelayModule'
+  }, {
+    path: 'authority',
+    loadChildren: '@eg/staff/cat/authority/authority.module#AuthorityModule'
   }
 ];
 
diff --git a/Open-ILS/src/eg2/src/app/staff/nav.component.html b/Open-ILS/src/eg2/src/app/staff/nav.component.html
index 265368a62a..5310b5b340 100644
--- a/Open-ILS/src/eg2/src/app/staff/nav.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/nav.component.html
@@ -222,7 +222,7 @@
             <span class="material-icons">lock</span>
             <span i18n>Manage Authorities</span>
           </a>
-          <a href="/eg/staff/cat/catalog/retrieve_by_authority_id" class="dropdown-item">
+          <a routerLink="/staff/cat/authority/edit" class="dropdown-item">
             <span class="material-icons">collections</span>
             <span i18n>Retrieve Authority Record by ID</span>
           </a>
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/authority-linking-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/authority-linking-dialog.component.html
index 2c8c3fcc1d..03d1eb5270 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/authority-linking-dialog.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/authority-linking-dialog.component.html
@@ -90,26 +90,29 @@
       </div>
     </div>
     <ul *ngFor="let entry of browseData">
-      <li class="d-flex">
+      <li class="d-flex mt-1">
         <div class="flex-1">
           <ng-container
             *ngTemplateOutlet="headingField;context:{field:entry.main_heading, authId: entry.authority_id}">
           </ng-container>
         </div>
-        <div class="font-italic" i18n-title i18n
-          title="Authority Record ID {{entry.authority_id}}">
-          #{{entry.authority_id}}
+        <div class="font-italic">
+          <a target="_blank" 
+            i18n-title title="Authority Record ID {{entry.authority_id}}"
+            routerLink="/staff/cat/authority/edit/{{entry.authority_id}}">
+            #{{entry.authority_id}}
+          </a>
         </div>
       </li>
       <ul *ngFor="let from of entry.see_froms">
-        <li i18n>
+        <li class="mt-1">
          <ng-container
           *ngTemplateOutlet="headingField;context:{field:from, from:true}">
          </ng-container>
         </li>
       </ul>
       <ul *ngFor="let also of entry.see_alsos">
-        <li i18n>
+        <li class="mt-1">
          <ng-container
           *ngTemplateOutlet="headingField;context:{field:also, also:true}">
          </ng-container>
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.html b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.html
index 77d4f009f9..e3daf9093e 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.html
@@ -19,13 +19,15 @@
 
 <div class="row d-flex p-2 m-2">
 
-  <div class="form-check">
-    <input class="form-check-input" type="checkbox"
-      [(ngModel)]="showFastAdd" id="fast-add-item"/>
-    <label class="form-check-label" for="fast-add-item">
-      Add Item
-    </label>
-  </div>
+  <ng-container *ngIf="recordType === 'biblio'">
+    <div class="form-check">
+      <input class="form-check-input" type="checkbox"
+        [(ngModel)]="showFastAdd" id="fast-add-item"/>
+      <label class="form-check-label" for="fast-add-item">
+        Add Item
+      </label>
+    </div>
+  </ng-container>
 
   <ng-container *ngIf="showFastAdd">
     <div class="form-inline">
@@ -65,7 +67,6 @@
     [disabled]="record && record.deleted" i18n>Save Changes</button>
 </div>
 
-
 <ng-container *ngIf="dataSaving">
   <div class="row mt-5">
     <div class="offset-lg-3 col-lg-6">
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.ts
index 7c0a224f45..157ff0abc4 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.ts
@@ -16,7 +16,7 @@ import {MarcEditContext} from './editor-context';
 import {NgbTabset, NgbTabChangeEvent} from '@ng-bootstrap/ng-bootstrap';
 import {HoldingsService} from '@eg/staff/share/holdings/holdings.service';
 
-interface MarcSavedEvent {
+export interface MarcSavedEvent {
     marcXml: string;
     bibSource?: number;
     recordId?: number;
@@ -42,14 +42,24 @@ export class MarcEditorComponent implements OnInit {
 
     @Input() recordType: 'biblio' | 'authority' = 'biblio';
 
+    _pendingRecordId: number;
     @Input() set recordId(id: number) {
-        if (!id) { return; }
         if (this.record && this.record.id === id) { return; }
-        this.fromId(id);
+
+        // Avoid fetching the record by ID before OnInit since we may
+        // not yet know our recordType.
+        if (this.initCalled) {
+            this._pendingRecordId = null;
+            this.fromId(id);
+
+         } else {
+            // fetch later in OnInit
+            this._pendingRecordId = id;
+         }
     }
 
     get recordId(): number {
-        return this.record ? this.record.id : null;
+        return this.record ? this.record.id : this._pendingRecordId;
     }
 
     @Input() set recordXml(xml: string) {
@@ -86,6 +96,7 @@ export class MarcEditorComponent implements OnInit {
     fastItemLabel: string;
     fastItemBarcode: string;
     showFastAdd: boolean;
+    initCalled = false;
 
     constructor(
         private evt: EventService,
@@ -107,11 +118,17 @@ export class MarcEditorComponent implements OnInit {
 
     ngOnInit() {
 
+        this.initCalled = true;
+
         this.context.recordType = this.recordType;
 
         this.store.getItem('cat.marcedit.flateditor').then(
             useFlat => this.editorTab = useFlat ? 'flat' : 'rich');
 
+        if (!this.record && this.recordId) {
+            this.fromId(this.recordId);
+        }
+
         if (this.recordType !== 'biblio') { return; }
 
         this.pcrud.retrieveAll('cbs').subscribe(
@@ -247,13 +264,15 @@ export class MarcEditorComponent implements OnInit {
     }
 
     fromId(id: number): Promise<any> {
-        return this.pcrud.retrieve('bre', id)
-        .toPromise().then(bib => {
-            this.context.record = new MarcRecord(bib.marc());
+        const idlClass = this.recordType === 'authority' ? 'are' : 'bre';
+
+        return this.pcrud.retrieve(idlClass, id)
+        .toPromise().then(rec => {
+            this.context.record = new MarcRecord(rec.marc());
             this.record.id = id;
-            this.record.deleted = bib.deleted() === 't';
-            if (bib.source()) {
-                this.sourceSelector.applyEntryId(+bib.source());
+            this.record.deleted = rec.deleted() === 't';
+            if (idlClass === 'bre' && rec.source()) {
+                this.sourceSelector.applyEntryId(+rec.source());
             }
         });
     }
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.html b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.html
index 531822bf02..72381e9db0 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.html
@@ -40,7 +40,7 @@
 <ng-template #postSubfieldsChunk let-field="field">
 
   <ng-container *ngIf="isControlledBibTag(field.tag)">
-    <button class="btn btn-sm btn-info link-button"
+    <button class="btn btn-sm btn-outline-info link-button"
       i18n-title title="Create authority link"
       (click)="openLinkerDialog(field)">
       <span class="material-icons">link</span>
@@ -155,7 +155,7 @@
       </eg-marc-editable-content>
 
       <ng-container *ngIf="field.tag === '007'">
-         <button class="btn btn-sm btn-info link-button"
+         <button class="btn btn-sm btn-outline-info link-button"
           i18n-title title="Open physical characteristics wizard"
           (click)="openPhysCharDialog(field)">
           <span class="material-icons">launch</span>
diff --git a/Open-ILS/src/templates/staff/navbar.tt2 b/Open-ILS/src/templates/staff/navbar.tt2
index 1028f42bb7..220c3d1d1f 100644
--- a/Open-ILS/src/templates/staff/navbar.tt2
+++ b/Open-ILS/src/templates/staff/navbar.tt2
@@ -342,7 +342,7 @@
             </a>
           </li>
           <li>
-            <a href="./cat/catalog/retrieve_by_authority_id" target="_self">
+            <a href="/eg2/staff/cat/authority/edit" target="_self">
               <span class="glyphicon glyphicon-file"></span>
               [% l('Retrieve Authority Record by ID') %]
             </a>

commit fa921ae9d3cc1e418e85de379274588afb393eea
Author: Bill Erickson <berickxx at gmail.com>
Date:   Tue Dec 31 10:58:55 2019 -0500

    LP1852782 Main entry link sets subfield 0
    
    In the MARC editor, when applying a main entry heading, set the subfield
    0 of the modified bib field to link to the authority record in question.
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    
    Signed-off-by: Jane Sandberg <sandbej at linnbenton.edu>

diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/authority-linking-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/authority-linking-dialog.component.html
index df251677b3..2c8c3fcc1d 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/authority-linking-dialog.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/authority-linking-dialog.component.html
@@ -12,10 +12,11 @@
 </eg-marc-editor-dialog>
 
 <!-- display a single heading as MARC or as the human friendlier string -->
-<ng-template #headingField let-field="field" let-from="from" let-also="also">
+<ng-template #headingField 
+  let-field="field" let-from="from" let-also="also" let-authId="authId">
   <button class="btn btn-sm p-1 mr-1" 
     [ngClass]="{'btn-outline-primary': !(from || also), 'btn-outline-info': (from || also)}"
-    (click)="applyHeading(field)" i18n>Apply</button>
+    (click)="applyHeading(field, authId)" i18n>Apply</button>
   <ng-container *ngIf="showAs == 'heading'">
     <span *ngIf="from" i18n>See From: {{field.heading}}</span>
     <span *ngIf="also" i18n>See Also: {{field.heading}}</span>
@@ -92,7 +93,7 @@
       <li class="d-flex">
         <div class="flex-1">
           <ng-container
-            *ngTemplateOutlet="headingField;context:{field:entry.main_heading}">
+            *ngTemplateOutlet="headingField;context:{field:entry.main_heading, authId: entry.authority_id}">
           </ng-container>
         </div>
         <div class="font-italic" i18n-title i18n
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/authority-linking-dialog.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/authority-linking-dialog.component.ts
index 6504f7dec7..0de369fdb0 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/authority-linking-dialog.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/authority-linking-dialog.component.ts
@@ -135,12 +135,20 @@ export class AuthorityLinkingDialogComponent
         ).subscribe(entry => this.browseData.push(entry));
     }
 
-    applyHeading(authField: MarcField) {
+    applyHeading(authField: MarcField, authId?: number) {
         this.net.request(
             'open-ils.cat',
             'open-ils.cat.authority.bib_field.overlay_authority',
             this.fieldHash(), this.fieldHash(authField), this.controlSet
-        ).subscribe(field => this.close(field));
+        ).subscribe(field => {
+            if (authId) {
+                // If an authId is provided, it means we are using
+                // a main entry heading and we should set the bib
+                // field's subfield 0 to refer to the main entry record.
+                this.setSubfieldZero(authId, field);
+            }
+            this.close(field);
+        });
     }
 
     isControlledBibSf(sf: string): boolean {
@@ -148,17 +156,20 @@ export class AuthorityLinkingDialogComponent
             this.authMeta.sf_list().includes(sf) : false;
     }
 
-    setSubfieldZero(authId: number) {
-        const sfZero = this.bibField.subfields.filter(sf => sf[0] === '0')[0];
+    setSubfieldZero(authId: number, bibField?: MarcField) {
+
+        if (!bibField) { bibField = this.bibField; }
+
+        const sfZero = bibField.subfields.filter(sf => sf[0] === '0')[0];
         if (sfZero) {
-            this.context.deleteSubfield(this.bibField, sfZero);
+            this.context.deleteSubfield(bibField, sfZero);
         }
-        this.context.insertSubfield(this.bibField,
-            ['0', `(${this.cni})${authId}`, this.bibField.subfields.length]);
+        this.context.insertSubfield(bibField,
+            ['0', `(${this.cni})${authId}`, bibField.subfields.length]);
 
         // Reset the validation state.
-        this.bibField.authChecked = null;
-        this.bibField.authValid = null;
+        bibField.authChecked = null;
+        bibField.authValid = null;
     }
 
     createNewAuthority(editFirst?: boolean) {

commit 48ad50466df4ef921036216a15f8c8f22254d2a3
Author: Bill Erickson <berickxx at gmail.com>
Date:   Tue Dec 31 10:04:36 2019 -0500

    LP1852782 More title attributes for action buttons
    
    Adds title attributes to the Phys Char wizard and authority linking
    buttons, which contain no text within the button proper.
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    
    Signed-off-by: Jane Sandberg <sandbej at linnbenton.edu>

diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.html b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.html
index 8a5df6d60f..531822bf02 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.html
@@ -41,6 +41,7 @@
 
   <ng-container *ngIf="isControlledBibTag(field.tag)">
     <button class="btn btn-sm btn-info link-button"
+      i18n-title title="Create authority link"
       (click)="openLinkerDialog(field)">
       <span class="material-icons">link</span>
     </button>
@@ -155,6 +156,7 @@
 
       <ng-container *ngIf="field.tag === '007'">
          <button class="btn btn-sm btn-info link-button"
+          i18n-title title="Open physical characteristics wizard"
           (click)="openPhysCharDialog(field)">
           <span class="material-icons">launch</span>
         </button>

commit 3187adcc2004f3fef0756f106b6fb104dd668ca6
Author: Bill Erickson <berickxx at gmail.com>
Date:   Mon Dec 30 16:35:42 2019 -0500

    LP1852782 Vandelay MARC editor module repair
    
    With the addition of Fast Add item support, the MARC editor requires
    access to the HoldingsModule, which was not importe directly into
    Vandelay, unlike the catalog.  This patch tells the MARC editor to
    import the module itself.
    
    Fixes:
    
    QueuedRecordComponent.html:22
    NullInjectorError: StaticInjectorError(BaseModule)[MarcEditorComponent
    -> HoldingsService]:
      StaticInjectorError(Platform: core)[MarcEditorComponent ->
      HoldingsService]:
          NullInjectorError: No provider for HoldingsService!
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    
    Signed-off-by: Jane Sandberg <sandbej at linnbenton.edu>

diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marc-edit.module.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marc-edit.module.ts
index d9d2febe9f..4f2a39f341 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marc-edit.module.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marc-edit.module.ts
@@ -11,6 +11,7 @@ import {EditableContentComponent} from './editable-content.component';
 import {AuthorityLinkingDialogComponent} from './authority-linking-dialog.component';
 import {MarcEditorDialogComponent} from './editor-dialog.component';
 import {PhysCharDialogComponent} from './phys-char-dialog.component';
+import {HoldingsModule} from '@eg/staff/share/holdings/holdings.module';
 
 @NgModule({
     declarations: [
@@ -26,7 +27,8 @@ import {PhysCharDialogComponent} from './phys-char-dialog.component';
     ],
     imports: [
         StaffCommonModule,
-        CommonWidgetsModule
+        CommonWidgetsModule,
+        HoldingsModule
     ],
     exports: [
         MarcEditorComponent

commit 7ed43007e22b07c7debe881aa74b20a3471d84fe
Author: Bill Erickson <berickxx at gmail.com>
Date:   Thu Dec 26 11:21:22 2019 -0500

    LP1852782 Angular MARC editor Release Notes
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    
    Signed-off-by: Jane Sandberg <sandbej at linnbenton.edu>

diff --git a/docs/RELEASE_NOTES_NEXT/Cataloging/ang-marc-editor.adoc b/docs/RELEASE_NOTES_NEXT/Cataloging/ang-marc-editor.adoc
new file mode 100644
index 0000000000..bbccf51209
--- /dev/null
+++ b/docs/RELEASE_NOTES_NEXT/Cataloging/ang-marc-editor.adoc
@@ -0,0 +1,7 @@
+Enriched/Full MARC Editor Ported to Angular
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+The full MARC editor is now implemented in Angular.  This change impacts
+both the experimental Angular catalog and the MARC edit option within
+MARC Batch Import/Export (Vandelay) Queue manager.
+

commit fad0e712ccba16dde72eb26352c3c6013a58057b
Author: Bill Erickson <berickxx at gmail.com>
Date:   Thu Dec 26 10:28:59 2019 -0500

    LP1852782 Fast add item option
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    
    Signed-off-by: Jane Sandberg <sandbej at linnbenton.edu>

diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/holdings.service.ts b/Open-ILS/src/eg2/src/app/staff/share/holdings/holdings.service.ts
index 3c703e6373..5c91a68474 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/holdings/holdings.service.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/holdings/holdings.service.ts
@@ -8,8 +8,10 @@ import {AuthService} from '@eg/core/auth.service';
 import {EventService} from '@eg/core/event.service';
 
 interface NewCallNumData {
-    owner: number;
+    owner?: number;
     label?: string;
+    fast_add?: boolean;
+    barcode?: string;
 }
 
 @Injectable()
@@ -25,8 +27,8 @@ export class HoldingsService {
     // Open the holdings editor UI in a new browser window/tab.
     spawnAddHoldingsUi(
         recordId: number,               // Bib record ID
-        addToCallNums?: number[],           // Add copies to / modify existing CNs
-        callNumData?: NewCallNumData[],   // Creating new call numbers
+        addToCallNums?: number[],       // Add copies to / modify existing CNs
+        callNumData?: NewCallNumData[], // Creating new call numbers
         hideCopies?: boolean) {         // Hide the copy edit pane
 
         const raw: any[] = [];
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.html b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.html
index f574604470..77d4f009f9 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.html
@@ -18,6 +18,24 @@
 <eg-string #failMsg i18n-text text="Record failed to update"></eg-string>
 
 <div class="row d-flex p-2 m-2">
+
+  <div class="form-check">
+    <input class="form-check-input" type="checkbox"
+      [(ngModel)]="showFastAdd" id="fast-add-item"/>
+    <label class="form-check-label" for="fast-add-item">
+      Add Item
+    </label>
+  </div>
+
+  <ng-container *ngIf="showFastAdd">
+    <div class="form-inline">
+      <input type="text" class="form-control ml-2" 
+        [(ngModel)]="fastItemLabel" placeholder="Call Number" i18n-placeholder/>
+      <input type="text" class="form-control ml-2" 
+        [(ngModel)]="fastItemBarcode" placeholder="Barcode" i18n-placeholder/>
+    </div>
+  </ng-container>
+
   <div class="flex-1"></div>
 
   <h3 class="mr-2">
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.ts
index c03e0e76bb..7c0a224f45 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.ts
@@ -14,6 +14,7 @@ import {ComboboxEntry, ComboboxComponent
 import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component';
 import {MarcEditContext} from './editor-context';
 import {NgbTabset, NgbTabChangeEvent} from '@ng-bootstrap/ng-bootstrap';
+import {HoldingsService} from '@eg/staff/share/holdings/holdings.service';
 
 interface MarcSavedEvent {
     marcXml: string;
@@ -82,6 +83,10 @@ export class MarcEditorComponent implements OnInit {
     @ViewChild('successMsg', {static: false}) successMsg: StringComponent;
     @ViewChild('failMsg', {static: false}) failMsg: StringComponent;
 
+    fastItemLabel: string;
+    fastItemBarcode: string;
+    showFastAdd: boolean;
+
     constructor(
         private evt: EventService,
         private idl: IdlService,
@@ -90,6 +95,7 @@ export class MarcEditorComponent implements OnInit {
         private org: OrgService,
         private pcrud: PcrudService,
         private toast: ToastService,
+        private holdings: HoldingsService,
         private store: ServerStoreService
     ) {
         this.sources = [];
@@ -184,13 +190,14 @@ export class MarcEditorComponent implements OnInit {
         // NOTE we do not reinitialize our record with the MARC returned
         // from the server after a create/update, which means our record
         // may be out of sync, e.g. missing 901* values.  It's the
-        // callers onsibility to tear us down and rebuild us.
+        // callers responsibility to tear us down and rebuild us.
         return promise.then(marcXml => {
             if (!marcXml) { return null; }
             this.successMsg.current().then(msg => this.toast.success(msg));
             emission.marcXml = marcXml;
             emission.recordId = this.recordId;
             this.recordSaved.emit(emission);
+            this.fastAdd();
             return marcXml;
         });
     }
@@ -305,5 +312,20 @@ export class MarcEditorComponent implements OnInit {
             });
         });
     }
+
+    // Spawns the copy editor with the requested barcode and
+    // call number label.  Called after our record is saved.
+    fastAdd() {
+        if (this.showFastAdd && this.fastItemLabel && this.fastItemBarcode) {
+
+            const fastItem = {
+                label: this.fastItemLabel,
+                barcode: this.fastItemBarcode,
+                fast_add: true
+            };
+
+            this.holdings.spawnAddHoldingsUi(this.recordId, null, [fastItem]);
+        }
+    }
 }
 

commit cad0d77286bf4d7d3b2686f3a4030e2640b52078
Author: Bill Erickson <berickxx at gmail.com>
Date:   Mon Dec 23 17:33:18 2019 -0500

    LP1852782 MARC editor Physical Characteristics Wizard
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    
    Signed-off-by: Jane Sandberg <sandbej at linnbenton.edu>

diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts
index 1bcc19dd65..2b06e6031d 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts
@@ -128,7 +128,12 @@ export class EditableContentComponent
             this.editInput.select();
         }
 
-        if (!req) {
+        if (req) {
+            if (req.newText) {
+                this.setContent(req.newText);
+            }
+        } else {
+
             // Focus request may have come from keyboard navigation,
             // clicking, etc.  Model the event as a focus request
             // so it can be tracked the same.
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-context.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-context.ts
index 28335f81cb..49ed524e1f 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-context.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-context.ts
@@ -15,6 +15,11 @@ export interface FieldFocusRequest {
     target: MARC_EDITABLE_FIELD_TYPE;
     sfOffset?: number; // focus a specific subfield by its offset
     ffCode?: string; // fixed field code
+
+    // If set, an external source wants to modify the text content
+    // of an editable component (in a way that retains undo/redo
+    // functionality).
+    newText?: string;
 }
 
 export class UndoRedoAction {
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marc-edit.module.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marc-edit.module.ts
index be62ae9403..d9d2febe9f 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marc-edit.module.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marc-edit.module.ts
@@ -10,6 +10,7 @@ import {TagTableService} from './tagtable.service';
 import {EditableContentComponent} from './editable-content.component';
 import {AuthorityLinkingDialogComponent} from './authority-linking-dialog.component';
 import {MarcEditorDialogComponent} from './editor-dialog.component';
+import {PhysCharDialogComponent} from './phys-char-dialog.component';
 
 @NgModule({
     declarations: [
@@ -20,6 +21,7 @@ import {MarcEditorDialogComponent} from './editor-dialog.component';
         FixedFieldComponent,
         EditableContentComponent,
         MarcEditorDialogComponent,
+        PhysCharDialogComponent,
         AuthorityLinkingDialogComponent
     ],
     imports: [
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/phys-char-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/phys-char-dialog.component.html
new file mode 100644
index 0000000000..161388a32b
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/phys-char-dialog.component.html
@@ -0,0 +1,59 @@
+
+<ng-template #dialogContent>
+  <div class="modal-header bg-info">
+    <h4 class="modal-title" i18n>Physical Characteristics Wizard</h4>
+    <button type="button" class="close"
+      i18n-aria-label aria-label="Close" (click)="close()">
+      <span aria-hidden="true">×</span>
+    </button>
+  </div>
+  <div class="modal-body">
+
+    <div class="form-group row">
+      <label for="007-value" 
+        class="col-lg-4 col-form-label" i18n>007 Value</label>
+      <div class="col-lg-5 well-table">
+        <div class="well-value">
+          <!-- avoid newlines and spaces in the <pre> content -->
+          <pre class="pb-0 mb-0 pt-1">{{splitFieldData()[0]}}<span 
+            class="text-danger">{{splitFieldData()[1]}}</span>{{splitFieldData()[2]}}</pre>
+        </div>
+      </div>
+      <div class="col-lg-3 d-flex">
+        <button class="btn btn-outline-dark m-1 p-1 flex-1" (click)="reset()" i18n>Reset</button>
+        <button class="btn btn-outline-dark m-1 p-1 flex-1" (click)="reset(true)" i18n>Clear</button>
+      </div>
+    </div>
+
+    <div class="form-group row">
+      <label for="value-selector" class="col-lg-4 col-form-label">
+        <ng-container *ngIf="!selectorLabel" i18n>Category of Material</ng-container>
+        <ng-container *ngIf="selectorLabel">{{selectorLabel}}</ng-container>
+      </label>
+      <div class="col-lg-5">
+        <select id='value-selector' class="form-control" 
+          [(ngModel)]="selectorValue" (change)="selectorChanged()">
+          <option value=" " i18n><Unset></option>
+          <option *ngFor="let entry of selectorOptions" 
+            [ngValue]="entry.id" i18n>{{entry.id}}: {{entry.label}}</option>
+        </select>
+      </div>
+      <div class="col-lg-3 d-flex">
+        <button type="button" class="btn btn-outline-dark flex-1 m-1 p-1" 
+          (click)="prev()" [disabled]="step === 0" i18n>Previous</button>
+
+        <button type="button" class="btn btn-outline-dark flex-1 m-1 p-1"
+          (click)="next()" [disabled]="isLastStep()" i18n>Next</button>
+      </div>
+    </div>
+  </div>
+
+  <div class="modal-footer">
+    <button type="button" class="btn btn-success" 
+      (click)="close(fieldData)" i18n>Apply</button>
+
+    <button type="button" class="btn btn-warning" 
+      (click)="close()" i18n>Cancel</button>
+  </div>
+
+</ng-template>
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/phys-char-dialog.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/phys-char-dialog.component.ts
new file mode 100644
index 0000000000..6337002cb6
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/phys-char-dialog.component.ts
@@ -0,0 +1,220 @@
+import {Component, ViewChild, Input, Output, OnInit, EventEmitter} from '@angular/core';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {DialogComponent} from '@eg/share/dialog/dialog.component';
+import {NgbModal} from '@ng-bootstrap/ng-bootstrap';
+import {MarcEditorDialogComponent} from './editor-dialog.component';
+import {ComboboxComponent, ComboboxEntry} from '@eg/share/combobox/combobox.component';
+
+/**
+ * 007 Physical Characteristics Dialog
+ *
+ * Note the dialog does not many direct changes to the bib field.
+ * It simply emits the new value on close, or null of the
+ * dialog canceled.
+ */
+
+ at Component({
+  selector: 'eg-phys-char-dialog',
+  templateUrl: './phys-char-dialog.component.html'
+})
+
+export class PhysCharDialogComponent
+    extends DialogComponent implements OnInit {
+
+    // The 007 data
+    @Input() fieldData = '';
+
+    initialValue: string;
+
+    selectorLabel: string = null;
+    selectorValue: string;
+    selectorOptions: ComboboxEntry[] = [];
+
+    typeMap: ComboboxEntry[] = [];
+
+    sfMap: {[ptypeKey: string]: any[]} = {};
+    valueMap: {[ptypeKey: string]: ComboboxEntry[]} = {};
+
+    currentPtype: string;
+
+    // step is the 1-based position in the list of data slots for the
+    // currently selected type. step==0 means we are currently selecting
+    // the type.
+    step = 0;
+
+    // size and offset of the slot we're currently editing; this is
+    // maintained as a convenience for the highlighting of the currently
+    // active position
+    slotOffset = 0;
+    slotSize = 1;
+
+    constructor(
+        private modal: NgbModal,
+        private idl: IdlService,
+        private pcrud: PcrudService) {
+        super(modal);
+    }
+
+    ngOnInit() {
+        this.onOpen$.subscribe(_ => {
+            this.initialValue = this.fieldData;
+            this.reset();
+        });
+    }
+
+    // Chop the field data value into 3 parts, before, middle, and
+    // after, where 'middle' is the part we're currently editing.
+    splitFieldData(): string[] {
+        const data = this.fieldData;
+        return [
+            data.substring(0, this.slotOffset),
+            data.substring(this.slotOffset, this.slotOffset + this.slotSize),
+            data.substring(this.slotOffset + this.slotSize)
+        ];
+    }
+
+    setValuesForStep(): Promise<any> {
+        let promise;
+
+        if (this.step === 0) {
+            promise = this.getPhysCharTypeMap();
+        } else {
+            promise = this.currentSubfield().then(
+                subfield => this.getPhysCharValueMap(subfield.id()));
+        }
+
+        return promise.then(list => {
+            this.selectorOptions = list;
+            this.setSelectedOptionFromField();
+            this.setLabelForStep();
+        });
+    }
+
+    setLabelForStep() {
+        if (this.step === 0) {
+            this.selectorLabel = null;  // fall back to template value
+        } else {
+            this.currentSubfield().then(sf => this.selectorLabel = sf.label());
+        }
+    }
+
+    getStepSlot(): Promise<any[]> {
+        if (this.step === 0) { return Promise.resolve([0, 1]); }
+        return this.currentSubfield()
+            .then(sf => [sf.start_pos(), sf.length()]);
+    }
+
+    setSelectedOptionFromField() {
+        this.getStepSlot().then(slot => {
+            this.slotOffset = slot[0];
+            this.slotSize = slot[1];
+            this.selectorValue =
+                String.prototype.substr.apply(this.fieldData, slot) || ' ';
+        });
+    }
+
+    isLastStep(): boolean {
+        // This one is called w/ every digest, so avoid async
+        // calls.  Wait until we have loaded the current ptype
+        // subfields to determine if this is the last step.
+        return (
+            this.currentPtype &&
+            this.sfMap[this.currentPtype] &&
+            this.sfMap[this.currentPtype].length === this.step
+        );
+    }
+
+    selectorChanged() {
+
+        if (this.step === 0) {
+            this.currentPtype = this.selectorValue;
+            this.fieldData = this.selectorValue; // total reset
+
+        } else {
+            this.getStepSlot().then(slot => {
+
+                let value = this.fieldData;
+                const offset = slot[0];
+                const size = slot[1];
+                while (value.length < (offset + size)) { value += ' '; }
+
+                // Apply the value to the field in the required slot,
+                // then delete all data after "here", since those values
+                // no longer make sense.
+                const before = value.substr(0, offset);
+                this.fieldData = before + this.selectorValue.padEnd(size, ' ');
+                this.slotOffset = offset;
+                this.slotSize = size;
+            });
+        }
+    }
+
+    next() {
+        this.step++;
+        this.setValuesForStep();
+    }
+
+    prev() {
+        this.step--;
+        this.setValuesForStep();
+    }
+
+    currentSubfield(): Promise<any> {
+        return this.getPhysCharSubfieldMap(this.currentPtype)
+        .then(sfList => sfList[this.step - 1]);
+    }
+
+    reset(clear?: boolean) {
+        this.step = 0;
+        this.slotOffset = 0;
+        this.slotSize = 1;
+        this.fieldData = clear ? ' ' : this.initialValue;
+        this.currentPtype = this.fieldData.substr(0, 1);
+        this.setValuesForStep();
+    }
+
+    getPhysCharTypeMap(): Promise<ComboboxEntry[]> {
+        if (this.typeMap.length) {
+            return Promise.resolve(this.typeMap);
+        }
+
+        return this.pcrud.retrieveAll(
+            'cmpctm', {order_by: {cmpctm: 'label'}}, {atomic: true})
+        .toPromise().then(maps => {
+            return this.typeMap = maps.map(
+                map => ({id: map.ptype_key(), label: map.label()}));
+        });
+    }
+
+    getPhysCharSubfieldMap(ptypeKey: string): Promise<IdlObject[]> {
+
+        if (this.sfMap[ptypeKey]) {
+            return Promise.resolve(this.sfMap[ptypeKey]);
+        }
+
+        return this.pcrud.search('cmpcsm',
+            {ptype_key : ptypeKey},
+            {order_by : {cmpcsm : ['start_pos']}},
+            {atomic : true}
+        ).toPromise().then(maps => this.sfMap[ptypeKey] = maps);
+   }
+
+    getPhysCharValueMap(ptypeSubfield: string): Promise<ComboboxEntry[]> {
+
+        if (this.valueMap[ptypeSubfield]) {
+            return Promise.resolve(this.valueMap[ptypeSubfield]);
+        }
+
+        return this.pcrud.search('cmpcvm',
+            {ptype_subfield : ptypeSubfield},
+            {order_by : {cmpcsm : ['value']}},
+            {atomic : true}
+        ).toPromise().then(maps =>
+            this.valueMap[ptypeSubfield] = maps.map(
+                map => ({id: map.value(), label: map.label()}))
+        );
+   }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.html b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.html
index cf0f0aebb6..8a5df6d60f 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.html
@@ -10,6 +10,8 @@
 <eg-authority-linking-dialog #authLinker [context]="context">
 </eg-authority-linking-dialog>
 
+<eg-phys-char-dialog #physCharDialog></eg-phys-char-dialog>
+
 <ng-template #subfieldChunk let-field="field" let-subfield="subfield">
 
   <!-- move these around depending on whether we are stacking subfields -->
@@ -150,6 +152,13 @@
         [context]="context" [field]="field" fieldType="cfld"
         ariaLabel="Control Field Content" i18n-ariaLabel moreClasses="p-1">
       </eg-marc-editable-content>
+
+      <ng-container *ngIf="field.tag === '007'">
+         <button class="btn btn-sm btn-info link-button"
+          (click)="openPhysCharDialog(field)">
+          <span class="material-icons">launch</span>
+        </button>
+      </ng-container>
     </div>
 
     <!-- data fields -->
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.ts
index e4b3da641d..4555e2030d 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.ts
@@ -9,6 +9,7 @@ import {TagTableService} from './tagtable.service';
 import {MarcRecord, MarcField} from './marcrecord';
 import {MarcEditContext} from './editor-context';
 import {AuthorityLinkingDialogComponent} from './authority-linking-dialog.component';
+import {PhysCharDialogComponent} from './phys-char-dialog.component';
 
 
 /**
@@ -35,6 +36,9 @@ export class MarcRichEditorComponent implements OnInit {
     @ViewChild('authLinker', {static: false})
         authLinker: AuthorityLinkingDialogComponent;
 
+    @ViewChild('physCharDialog', {static: false})
+        physCharDialog: PhysCharDialogComponent;
+
     constructor(
         private idl: IdlService,
         private net: NetService,
@@ -149,6 +153,11 @@ export class MarcRichEditorComponent implements OnInit {
     openLinkerDialog(field: MarcField) {
         this.authLinker.bibField = field;
         this.authLinker.open({size: 'xl'}).subscribe(newField => {
+
+            // The presence of newField here means the linker wants to
+            // replace the field with a new field from the authority
+            // record.  Otherwise, the original field may have been
+            // directly modified or the dialog canceled.
             if (!newField) { return; }
 
             // Performs an insert followed by a delete, so the two
@@ -161,6 +170,23 @@ export class MarcRichEditorComponent implements OnInit {
             this.context.setUndoGroupSize(2);
         });
     }
+
+    // 007 Physical characteristics wizard.
+    openPhysCharDialog(field: MarcField) {
+        this.physCharDialog.fieldData = field.data;
+
+        this.physCharDialog.open({size: 'lg'}).subscribe(
+            newData => {
+                if (newData) {
+                    this.context.requestFieldFocus({
+                        fieldId: field.fieldId,
+                        target: 'cfld',
+                        newText: newData
+                    });
+                }
+            }
+        );
+    }
 }
 
 

commit 9f3c2229737fc8170a0bfc721b7d39bf04e5ecd7
Author: Bill Erickson <berickxx at gmail.com>
Date:   Mon Dec 23 17:33:05 2019 -0500

    LP1852782 Reset authority validation after linking
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    
    Signed-off-by: Jane Sandberg <sandbej at linnbenton.edu>

diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/authority-linking-dialog.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/authority-linking-dialog.component.ts
index 1c7d3a5d1c..6504f7dec7 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/authority-linking-dialog.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/authority-linking-dialog.component.ts
@@ -155,6 +155,10 @@ export class AuthorityLinkingDialogComponent
         }
         this.context.insertSubfield(this.bibField,
             ['0', `(${this.cni})${authId}`, this.bibField.subfields.length]);
+
+        // Reset the validation state.
+        this.bibField.authChecked = null;
+        this.bibField.authValid = null;
     }
 
     createNewAuthority(editFirst?: boolean) {

commit 7f83d0319fe961edb9478d5f26a93f6683153ea1
Author: Bill Erickson <berickxx at gmail.com>
Date:   Thu Dec 19 18:00:27 2019 -0500

    LP1852782 MARC edit inline authority record creation.
    
    Implement support for creating a new authority record from a bib field
    in "immediate" (non-editing)  mode.
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    
    Signed-off-by: Jane Sandberg <sandbej at linnbenton.edu>

diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/authority-linking-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/authority-linking-dialog.component.html
index c0e9558433..df251677b3 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/authority-linking-dialog.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/authority-linking-dialog.component.html
@@ -7,9 +7,14 @@
   </span>
 </ng-template>
 
+<!-- MARC edit-ception! -->
+<eg-marc-editor-dialog #marcEditDialog recordType="authority">
+</eg-marc-editor-dialog>
+
 <!-- display a single heading as MARC or as the human friendlier string -->
 <ng-template #headingField let-field="field" let-from="from" let-also="also">
-  <button class="btn btn-sm btn-outline-info p-1 mr-1" 
+  <button class="btn btn-sm p-1 mr-1" 
+    [ngClass]="{'btn-outline-primary': !(from || also), 'btn-outline-info': (from || also)}"
     (click)="applyHeading(field)" i18n>Apply</button>
   <ng-container *ngIf="showAs == 'heading'">
     <span *ngIf="from" i18n>See From: {{field.heading}}</span>
@@ -54,12 +59,10 @@
       <div class="ml-2 p-1">
         <div class="mb-1" i18n>Create new authority from this field</div>
         <div>
-          <button class="btn btn-outline-info" [disabled]="true">
-            Immediately
-          </button>
-          <button class="btn btn-outline-info ml-2" [disabled]="true">
-            Create and Edit
-          </button>
+          <button class="btn btn-outline-info" 
+            (click)="createNewAuthority()">Immediately</button>
+          <button class="btn btn-outline-info ml-2" 
+            (click)="createNewAuthority(true)">Create and Edit</button>
         </div>
       </div>
     </div>
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/authority-linking-dialog.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/authority-linking-dialog.component.ts
index 2015ec187c..1c7d3a5d1c 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/authority-linking-dialog.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/authority-linking-dialog.component.ts
@@ -1,10 +1,14 @@
-import {Component, Input, Output, OnInit, EventEmitter} from '@angular/core';
+import {Component, ViewChild, Input, Output, OnInit, EventEmitter} from '@angular/core';
 import {NetService} from '@eg/core/net.service';
+import {OrgService} from '@eg/core/org.service';
+import {AuthService} from '@eg/core/auth.service';
 import {PcrudService} from '@eg/core/pcrud.service';
 import {DialogComponent} from '@eg/share/dialog/dialog.component';
 import {NgbModal} from '@ng-bootstrap/ng-bootstrap';
 import {MarcField} from './marcrecord';
+import {MarcEditContext} from './editor-context';
 import {Pager} from '@eg/share/util/pager';
+import {MarcEditorDialogComponent} from './editor-dialog.component';
 
 /**
  * MARC Authority Linking Dialog
@@ -22,6 +26,7 @@ export class AuthorityLinkingDialogComponent
     @Input() thesauri: string = null;
     @Input() controlSet: number = null;
     @Input() pager: Pager;
+    @Input() context: MarcEditContext;
 
     browseData: any[] = [];
 
@@ -32,8 +37,15 @@ export class AuthorityLinkingDialogComponent
 
     selectedSubfields: string[] = [];
 
+    cni: string; // Control Number Identifier
+
+    @ViewChild('marcEditDialog', {static: false})
+        marcEditDialog: MarcEditorDialogComponent;
+
     constructor(
         private modal: NgbModal,
+        private auth: AuthService,
+        private org: OrgService,
         private pcrud: PcrudService,
         private net: NetService) {
         super(modal);
@@ -61,9 +73,14 @@ export class AuthorityLinkingDialogComponent
 
     initData() {
 
-       this.pager.offset = 0;
+        this.pager.offset = 0;
+
+        this.org.settings(['cat.marc_control_number_identifier']).then(s => {
+            this.cni = s['cat.marc_control_number_identifier'] ||
+                'Set cat.marc_control_number_identifier in Library Settings';
+        });
 
-       this.pcrud.search('acsbf',
+        this.pcrud.search('acsbf',
             {tag: this.bibField.tag},
             {flesh: 1, flesh_fields: {acsbf: ['authority_field']}},
             {atomic:  true, anonymous: true}
@@ -130,5 +147,41 @@ export class AuthorityLinkingDialogComponent
         return this.authMeta ?
             this.authMeta.sf_list().includes(sf) : false;
     }
+
+    setSubfieldZero(authId: number) {
+        const sfZero = this.bibField.subfields.filter(sf => sf[0] === '0')[0];
+        if (sfZero) {
+            this.context.deleteSubfield(this.bibField, sfZero);
+        }
+        this.context.insertSubfield(this.bibField,
+            ['0', `(${this.cni})${authId}`, this.bibField.subfields.length]);
+    }
+
+    createNewAuthority(editFirst?: boolean) {
+
+        const method = editFirst ?
+            'open-ils.cat.authority.record.create_from_bib.readonly' :
+            'open-ils.cat.authority.record.create_from_bib';
+
+        this.net.request(
+            'open-ils.cat', method,
+            this.fieldHash(), this.cni, this.auth.token()
+        ).subscribe(record => {
+            if (editFirst) {
+                this.marcEditDialog.recordXml = record;
+                this.marcEditDialog.open({size: 'xl'})
+                .subscribe(saveEvent => {
+                    if (saveEvent && saveEvent.recordId) {
+                        this.setSubfieldZero(saveEvent.recordId);
+                    }
+                    this.close();
+                });
+            } else {
+                this.setSubfieldZero(record.id());
+                this.close();
+            }
+        });
+    }
 }
 
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts
index 88be84c592..1bcc19dd65 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts
@@ -6,8 +6,8 @@ import {MarcRecord, MarcField, MarcSubfield} from './marcrecord';
 import {MarcEditContext, FieldFocusRequest, MARC_EDITABLE_FIELD_TYPE,
     TextUndoRedoAction} from './editor-context';
 import {ContextMenuEntry} from '@eg/share/context-menu/context-menu.service';
-import {TagTableService} from './tagtable.service';
 import {StringComponent} from '@eg/share/string/string.component';
+import {TagTable} from './tagtable.service';
 
 /**
  * MARC Editable Content Component
@@ -73,9 +73,11 @@ export class EditableContentComponent
     @ViewChild('insertAfter', {static: false}) insertAfterStr: StringComponent;
     @ViewChild('deleteField', {static: false}) deleteFieldStr: StringComponent;
 
-    constructor(
-        private renderer: Renderer2,
-        private tagTable: TagTableService) {}
+    constructor(private renderer: Renderer2) {}
+
+    tt(): TagTable { // for brevity
+        return this.context.tagTable;
+    }
 
     ngOnInit() {
         this.setupFieldType();
@@ -197,8 +199,7 @@ export class EditableContentComponent
     }
 
     applyFFOptions() {
-        return this.tagTable.getFfFieldMeta(
-            this.fixedFieldCode, this.record.recordType())
+        return this.tt().getFfFieldMeta(this.fixedFieldCode)
         .then(fieldMeta => {
             if (fieldMeta) {
                 this.maxLength = fieldMeta.length || 1;
@@ -216,20 +217,19 @@ export class EditableContentComponent
                 return this.tagContextMenuEntries();
 
             case 'sfc':
-                return this.tagTable.getSubfieldCodes(this.field.tag);
+                return this.tt().getSubfieldCodes(this.field.tag);
 
             case 'sfv':
-                return this.tagTable.getSubfieldValues(
+                return this.tt().getSubfieldValues(
                     this.field.tag, this.subfield[0]);
 
             case 'ind1':
             case 'ind2':
-                return this.tagTable.getIndicatorValues(
+                return this.tt().getIndicatorValues(
                     this.field.tag, this.fieldType);
 
             case 'ffld':
-                return this.tagTable.getFfValues(
-                    this.fixedFieldCode, this.record.recordType());
+                return this.tt().getFfValues(this.fixedFieldCode);
         }
 
         return null;
@@ -261,7 +261,7 @@ export class EditableContentComponent
             {divider: true}
         );
 
-        this.tagTable.getFieldTags().forEach(e => this.tagMenuEntries.push(e));
+        this.tt().getFieldTags().forEach(e => this.tagMenuEntries.push(e));
 
         return this.tagMenuEntries;
     }
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-context.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-context.ts
index 16a8caeecc..28335f81cb 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-context.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-context.ts
@@ -1,6 +1,7 @@
 import {EventEmitter} from '@angular/core';
 import {MarcRecord, MarcField, MarcSubfield} from './marcrecord';
 import {NgbPopover} from '@ng-bootstrap/ng-bootstrap';
+import {TagTable} from './tagtable.service';
 
 /* Per-instance MARC editor context. */
 
@@ -65,6 +66,8 @@ export class MarcEditContext {
     undoStack: UndoRedoAction[] = [];
     redoStack: UndoRedoAction[] = [];
 
+    tagTable: TagTable;
+
     // True if any changes have been made.
     // For the 'rich' editor, this is any un-do-able actions.
     // For the text edtior it's any text change.
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-dialog.component.html
new file mode 100644
index 0000000000..1fc6efa694
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-dialog.component.html
@@ -0,0 +1,14 @@
+
+<ng-template #dialogContent>
+  <div class="modal-header bg-info">
+    <h4 class="modal-title" i18n>MARC Editor</h4>
+    <button type="button" class="close"
+      i18n-aria-label aria-label="Close" (click)="close()">
+      <span aria-hidden="true">×</span>
+    </button>
+  </div>
+  <div class="modal-body">
+    <eg-marc-editor #marcEditor (recordSaved)="handleRecordSaved($event)" 
+      [recordType]="recordType" [recordXml]="recordXml"></eg-marc-editor>
+  </div>
+</ng-template>
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-dialog.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-dialog.component.ts
new file mode 100644
index 0000000000..67e836bb0c
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-dialog.component.ts
@@ -0,0 +1,44 @@
+import {Component, Input, Output, OnInit, EventEmitter} from '@angular/core';
+import {Observable} from 'rxjs';
+import {NetService} from '@eg/core/net.service';
+import {OrgService} from '@eg/core/org.service';
+import {AuthService} from '@eg/core/auth.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {DialogComponent} from '@eg/share/dialog/dialog.component';
+import {NgbModal, NgbModalRef, NgbModalOptions} from '@ng-bootstrap/ng-bootstrap';
+import {MarcEditContext} from './editor-context';
+
+
+/**
+ * Spawn a MARC editor within a dialog.
+ */
+
+ at Component({
+  selector: 'eg-marc-editor-dialog',
+  templateUrl: './editor-dialog.component.html'
+})
+
+export class MarcEditorDialogComponent
+    extends DialogComponent implements OnInit {
+
+    @Input() context: MarcEditContext;
+    @Input() recordXml: string;
+    @Input() recordType: 'biblio' | 'authority' = 'biblio';
+
+    constructor(
+        private modal: NgbModal,
+        private auth: AuthService,
+        private org: OrgService,
+        private pcrud: PcrudService,
+        private net: NetService) {
+        super(modal);
+    }
+
+    ngOnInit() {}
+
+    handleRecordSaved(saved) {
+        this.close(saved);
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.html b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.html
index 7daa2c89b2..f574604470 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.html
@@ -26,13 +26,15 @@
     </span>
   </h3>
     
-  <div class="mr-2">
-    <eg-combobox #sourceSelector
-      [entries]="sources"
-      placeholder="Select a Source..."
-      i18n-placeholder>
-    </eg-combobox>
-  </div>
+  <ng-container *ngIf="recordType === 'biblio'">
+    <div class="mr-2">
+      <eg-combobox #sourceSelector
+        [entries]="sources"
+        placeholder="Select a Source..."
+        i18n-placeholder>
+      </eg-combobox>
+    </div>
+  </ng-container>
 
   <ng-container *ngIf="record && record.id">
     <button *ngIf="!record.deleted" class="btn btn-warning" 
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.ts
index 841ca075fe..c03e0e76bb 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.ts
@@ -18,6 +18,7 @@ import {NgbTabset, NgbTabChangeEvent} from '@ng-bootstrap/ng-bootstrap';
 interface MarcSavedEvent {
     marcXml: string;
     bibSource?: number;
+    recordId?: number;
 }
 
 /**
@@ -46,6 +47,10 @@ export class MarcEditorComponent implements OnInit {
         this.fromId(id);
     }
 
+    get recordId(): number {
+        return this.record ? this.record.id : null;
+    }
+
     @Input() set recordXml(xml: string) {
         if (xml) {
             this.fromXml(xml);
@@ -101,6 +106,8 @@ export class MarcEditorComponent implements OnInit {
         this.store.getItem('cat.marcedit.flateditor').then(
             useFlat => this.editorTab = useFlat ? 'flat' : 'rich');
 
+        if (this.recordType !== 'biblio') { return; }
+
         this.pcrud.retrieveAll('cbs').subscribe(
             src => this.sources.push({id: +src.id(), label: src.source()}),
             _ => {},
@@ -149,41 +156,87 @@ export class MarcEditorComponent implements OnInit {
         let sourceName: string = null;
         let sourceId: number = null;
 
-        if (this.sourceSelector.selected) {
+        if (this.sourceSelector && this.sourceSelector.selected) {
             sourceName = this.sourceSelector.selected.label;
             sourceId = this.sourceSelector.selected.id;
         }
 
+        const emission = {
+            marcXml: xml, bibSource: sourceId, recordId: this.recordId};
+
         if (this.inPlaceMode) {
             // Let the caller have the modified XML and move on.
-            this.recordSaved.emit({marcXml: xml, bibSource: sourceId});
+            this.recordSaved.emit(emission);
             return Promise.resolve();
         }
 
+        let promise;
+
         if (this.record.id) { // Editing an existing record
 
-            const method = 'open-ils.cat.biblio.record.xml.update';
+            promise = this.modifyRecord(xml, sourceName, sourceId);
 
-            return this.net.request('open-ils.cat', method,
-                this.auth.token(), this.record.id, xml, sourceName
-            ).toPromise().then(response => {
+        } else {
 
-                const evt = this.evt.parse(response);
-                if (evt) {
-                    console.error(evt);
-                    this.failMsg.current().then(msg => this.toast.warning(msg));
-                    this.dataSaving = false;
-                    return;
-                }
+            promise = this.createRecord(xml, sourceName);
+        }
 
-                this.successMsg.current().then(msg => this.toast.success(msg));
-                this.recordSaved.emit({marcXml: xml, bibSource: sourceId});
-                return response;
-            });
+        // NOTE we do not reinitialize our record with the MARC returned
+        // from the server after a create/update, which means our record
+        // may be out of sync, e.g. missing 901* values.  It's the
+        // callers onsibility to tear us down and rebuild us.
+        return promise.then(marcXml => {
+            if (!marcXml) { return null; }
+            this.successMsg.current().then(msg => this.toast.success(msg));
+            emission.marcXml = marcXml;
+            emission.recordId = this.recordId;
+            this.recordSaved.emit(emission);
+            return marcXml;
+        });
+    }
 
-        } else {
-            // TODO: create a new record
-        }
+    modifyRecord(marcXml: string, sourceName: string, sourceId: number): Promise<any> {
+        const method = 'open-ils.cat.biblio.record.marc.replace';
+
+        return this.net.request('open-ils.cat', method,
+            this.auth.token(), this.record.id, marcXml, sourceName
+
+        ).toPromise().then(response => {
+
+            const evt = this.evt.parse(response);
+            if (evt) {
+                console.error(evt);
+                this.failMsg.current().then(msg => this.toast.warning(msg));
+                this.dataSaving = false;
+                return null;
+            }
+
+            return response.marc();
+        });
+    }
+
+    createRecord(marcXml: string, sourceName?: string): Promise<any> {
+
+        const method = this.recordType === 'biblio' ?
+            'open-ils.cat.biblio.record.xml.create' :
+            'open-ils.cat.authority.record.import';
+
+        return this.net.request('open-ils.cat', method,
+            this.auth.token(), marcXml, sourceName
+        ).toPromise().then(response => {
+
+            const evt = this.evt.parse(response);
+
+            if (evt) {
+                console.error(evt);
+                this.failMsg.current().then(msg => this.toast.warning(msg));
+                this.dataSaving = false;
+                return null;
+            }
+
+            this.record.id = response.id();
+            return response.marc();
+        });
     }
 
     fromId(id: number): Promise<any> {
@@ -226,7 +279,7 @@ export class MarcEditorComponent implements OnInit {
                 }
                 return this.fromId(this.record.id)
                 .then(_ => this.recordSaved.emit(
-                    {marcXml: this.record.toXml()}));
+                    {marcXml: this.record.toXml(), recordId: this.recordId}));
             });
         });
     }
@@ -248,7 +301,7 @@ export class MarcEditorComponent implements OnInit {
 
                 return this.fromId(this.record.id)
                 .then(_ => this.recordSaved.emit(
-                    {marcXml: this.record.toXml()}));
+                    {marcXml: this.record.toXml(), recordId: this.recordId}));
             });
         });
     }
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-field.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-field.component.ts
index a81255dc6d..6905ec6b55 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-field.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-field.component.ts
@@ -25,7 +25,7 @@ export class FixedFieldComponent implements OnInit {
     fieldMeta: IdlObject;
     randId = Math.floor(Math.random() * 10000000);
 
-    constructor(private tagTable: TagTableService) {}
+    constructor() {}
 
     ngOnInit() {
         this.init().then(_ =>
@@ -37,8 +37,7 @@ export class FixedFieldComponent implements OnInit {
 
         // If no field metadata is found for this fixed field code and
         // record type combo, the field will be hidden in the UI.
-        return this.tagTable.getFfFieldMeta(
-            this.fieldCode, this.record.recordType())
+        return this.context.tagTable.getFfFieldMeta(this.fieldCode)
         .then(fieldMeta => this.fieldMeta = fieldMeta);
     }
 }
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marc-edit.module.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marc-edit.module.ts
index e34928dd48..be62ae9403 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marc-edit.module.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marc-edit.module.ts
@@ -9,6 +9,7 @@ import {FixedFieldComponent} from './fixed-field.component';
 import {TagTableService} from './tagtable.service';
 import {EditableContentComponent} from './editable-content.component';
 import {AuthorityLinkingDialogComponent} from './authority-linking-dialog.component';
+import {MarcEditorDialogComponent} from './editor-dialog.component';
 
 @NgModule({
     declarations: [
@@ -18,6 +19,7 @@ import {AuthorityLinkingDialogComponent} from './authority-linking-dialog.compon
         FixedFieldsEditorComponent,
         FixedFieldComponent,
         EditableContentComponent,
+        MarcEditorDialogComponent,
         AuthorityLinkingDialogComponent
     ],
     imports: [
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.html b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.html
index 68e0f68073..cf0f0aebb6 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.html
@@ -7,7 +7,8 @@
   </div>
 </ng-container>
 
-<eg-authority-linking-dialog #authLinker></eg-authority-linking-dialog>
+<eg-authority-linking-dialog #authLinker [context]="context">
+</eg-authority-linking-dialog>
 
 <ng-template #subfieldChunk let-field="field" let-subfield="subfield">
 
@@ -69,8 +70,10 @@
       <div class="col-lg-3">
         <div><button class="btn btn-outline-dark"
           (click)="showHelp = !showHelp" i18n>Help</button></div>
-        <div class="mt-2"><button class="btn btn-outline-dark"
-          (click)="validate()" i18n>Validate</button></div>
+        <ng-container *ngIf="context.recordType === 'biblio'">
+          <div class="mt-2"><button class="btn btn-outline-dark"
+            (click)="validate()" i18n>Validate</button></div>
+        </ng-container>
         <div class="mt-2">
           <button type="button" class="btn btn-outline-info"
             [disabled]="undoCount() < 1" (click)="undo()">
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.ts
index 480c7ea5b2..e4b3da641d 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.ts
@@ -62,11 +62,13 @@ export class MarcRichEditorComponent implements OnInit {
         if (!this.record) { return Promise.resolve(); }
 
         return Promise.all([
-            this.tagTable.loadTagTable({marcRecordType: this.context.recordType}),
-            this.tagTable.getFfPosTable(this.record.recordType()),
-            this.tagTable.getFfValueTable(this.record.recordType()),
+            this.tagTable.loadTags({
+                marcRecordType: this.context.recordType,
+                ffType: this.record.recordType()
+            }).then(table => this.context.tagTable = table),
             this.tagTable.getControlledBibTags().then(
-                tags => this.controlledBibTags = tags)
+                tags => this.controlledBibTags = tags),
+            this.fetchSettings()
         ]).then(_ =>
             // setTimeout forces all of our sub-components to rerender
             // themselves each time init() is called.  Without this,
@@ -77,6 +79,11 @@ export class MarcRichEditorComponent implements OnInit {
         );
     }
 
+    fetchSettings(): Promise<any> {
+        // Fetch at rich editor load time to cache.
+        return this.org.settings(['cat.marc_control_number_identifier']);
+    }
+
     stackSubfieldsChange() {
         if (this.stackSubfields) {
             this.store.setItem('cat.marcedit.stack_subfields', true);
@@ -111,8 +118,8 @@ export class MarcRichEditorComponent implements OnInit {
 
     validate() {
         const fields = [];
-        this.record.fields.filter(f => this.isControlledBibTag(f.tag))
 
+        this.record.fields.filter(f => this.isControlledBibTag(f.tag))
         .forEach(f => {
             f.authValid = false;
             fields.push({
@@ -120,7 +127,7 @@ export class MarcRichEditorComponent implements OnInit {
                 tag: f.tag,
                 ind1: f.ind1,
                 ind2: f.ind2,
-                subfields: f.subfields.map(sf => ({code: sf[0], value: sf[1]}))
+                subfields: f.subfields.map(sf => [sf[0], sf[1]])
             });
         });
 
@@ -141,7 +148,7 @@ export class MarcRichEditorComponent implements OnInit {
 
     openLinkerDialog(field: MarcField) {
         this.authLinker.bibField = field;
-        this.authLinker.open({size: 'lg'}).subscribe(newField => {
+        this.authLinker.open({size: 'xl'}).subscribe(newField => {
             if (!newField) { return; }
 
             // Performs an insert followed by a delete, so the two
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/tagtable.service.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/tagtable.service.ts
index 3d2fc7370f..ce6ddb7431 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/tagtable.service.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/tagtable.service.ts
@@ -6,52 +6,69 @@ import {IdlObject} from '@eg/core/idl.service';
 import {AuthService} from '@eg/core/auth.service';
 import {NetService} from '@eg/core/net.service';
 import {PcrudService} from '@eg/core/pcrud.service';
-import {EventService} from '@eg/core/event.service';
 import {ContextMenuEntry} from '@eg/share/context-menu/context-menu.service';
 
+const DEFAULT_MARC_FORMAT = 'marc21';
+
 interface TagTableSelector {
     marcFormat?: string;
-    marcRecordType?: string;
+    marcRecordType: 'biblio' | 'authority' | 'serial';
+
+    // MARC record fixed field "Type" value.
+    ffType: string;
 }
 
-const defaultTagTableSelector: TagTableSelector = {
-    marcFormat     : 'marc21',
-    marcRecordType : 'biblio'
-};
+export class TagTable {
 
- at Injectable()
-export class TagTableService {
+    store: StoreService;
+    auth: AuthService;
+    net: NetService;
+    pcrud: PcrudService;
 
-    // Current set of tags in list and map form.
-    tagMap: {[tag: string]: any} = {};
-    ffPosMap: {[rtype: string]: any[]} = {};
-    ffValueMap: {[rtype: string]: any} = {};
-    controlledBibTags: string[];
+    selector: TagTableSelector;
 
-    extractedValuesCache:
-        {[valueType: string]: {[which: string]: any}} = {};
+    // Current set of tags in list and map form.
+    tagMap: {[tag: string]: any};
+    ffPosTable: any;
+    ffValueTable: any;
+    fieldTags: ContextMenuEntry[];
+
+    // Cache of compiled, sorted, munged data.  Stuff the UI requests
+    // frequently for selectors, etc.
+    cache: {[valueType: string]: {[which: string]: any}} = {
+        indicators: {},
+        sfcodes: {},
+        sfvalues: {},
+        ffvalues: {}
+    };
 
     constructor(
-        private store: StoreService,
-        private auth: AuthService,
-        private net: NetService,
-        private pcrud: PcrudService,
-        private evt: EventService
+        store: StoreService,
+        auth: AuthService,
+        net: NetService,
+        pcrud: PcrudService,
+        selector: TagTableSelector
     ) {
+        this.store = store;
+        this.auth = auth;
+        this.net = net;
+        this.pcrud = pcrud;
+        this.selector = selector;
+    }
+
 
-        this.extractedValuesCache = {
-            fieldtags: {},
-            indicators: {},
-            sfcodes: {},
-            sfvalues: {},
-            ffvalues: {}
-        };
+    load(): Promise<any> {
+        return Promise.all([
+            this.loadTagTable(),
+            this.getFfPosTable(),
+            this.getFfValueTable(),
+        ]);
     }
 
     // Various data needs munging for display.  Cached the modified
     // values since they are refernced repeatedly by the UI code.
     fromCache(dataType: string, which?: string, which2?: string): ContextMenuEntry[] {
-        const part1 = this.extractedValuesCache[dataType][which];
+        const part1 = this.cache[dataType][which];
         if (which2) {
             if (part1) {
                 return part1[which2];
@@ -63,7 +80,7 @@ export class TagTableService {
 
     toCache(dataType: string, which: string,
         which2: string, values: ContextMenuEntry[]): ContextMenuEntry[] {
-        const base = this.extractedValuesCache[dataType];
+        const base = this.cache[dataType];
         const part1 = base[which];
 
         if (which2) {
@@ -76,69 +93,62 @@ export class TagTableService {
         return values;
     }
 
-    getFfPosTable(rtype: string): Promise<any> {
-        const storeKey = 'FFPosTable_' + rtype;
+    getFfPosTable(): Promise<any> {
+        const storeKey = 'FFPosTable_' + this.selector.ffType;
 
-        if (this.ffPosMap[rtype]) {
-            return Promise.resolve(this.ffPosMap[rtype]);
+        if (this.ffPosTable) {
+            return Promise.resolve(this.ffPosTable);
         }
 
-        this.ffPosMap[rtype] = this.store.getLocalItem(storeKey);
+        this.ffPosTable = this.store.getLocalItem(storeKey);
 
-        if (this.ffPosMap[rtype]) {
-            return Promise.resolve(this.ffPosMap[rtype]);
+        if (this.ffPosTable) {
+            return Promise.resolve(this.ffPosTable);
         }
 
         return this.net.request(
             'open-ils.fielder', 'open-ils.fielder.cmfpm.atomic',
-            {query: {tag: {'!=' : '006'}, rec_type: rtype}}
+            {query: {tag: {'!=' : '006'}, rec_type: this.selector.ffType}}
 
         ).toPromise().then(table => {
             this.store.setLocalItem(storeKey, table);
-            return this.ffPosMap[rtype] = table;
+            return this.ffPosTable = table;
         });
     }
 
-    getFfValueTable(rtype: string): Promise<any> {
+    // ffType is the fixed field Type value. BKS, AUT, etc.
+    // See config.marc21_rec_type_map
+    getFfValueTable(): Promise<any> {
 
-        const storeKey = 'FFValueTable_' + rtype;
+        const storeKey = 'FFValueTable_' + this.selector.ffType;
 
-        if (this.ffValueMap[rtype]) {
-            return Promise.resolve(this.ffValueMap[rtype]);
+        if (this.ffValueTable) {
+            return Promise.resolve(this.ffValueTable);
         }
 
-        this.ffValueMap[rtype] = this.store.getLocalItem(storeKey);
+        this.ffValueTable = this.store.getLocalItem(storeKey);
 
-        if (this.ffValueMap[rtype]) {
-            return Promise.resolve(this.ffValueMap[rtype]);
+        if (this.ffValueTable) {
+            return Promise.resolve(this.ffValueTable);
         }
 
         return this.net.request(
             'open-ils.cat',
-            'open-ils.cat.biblio.fixed_field_values.by_rec_type', rtype
+            'open-ils.cat.biblio.fixed_field_values.by_rec_type',
+            this.selector.ffType
 
         ).toPromise().then(table => {
             this.store.setLocalItem(storeKey, table);
-            return this.ffValueMap[rtype] = table;
+            return this.ffValueTable = table;
         });
     }
 
-    loadTagTable(selector?: TagTableSelector): Promise<any> {
+    loadTagTable(): Promise<any> {
 
-        if (selector) {
-            if (!selector.marcFormat) {
-                selector.marcFormat = defaultTagTableSelector.marcFormat;
-            }
-            if (!selector.marcRecordType) {
-                selector.marcRecordType =
-                    defaultTagTableSelector.marcRecordType;
-            }
-        } else {
-            selector = defaultTagTableSelector;
-        }
+        const sel = this.selector;
 
         const cacheKey =
-            `current_tag_table_${selector.marcFormat}_${selector.marcRecordType}`;
+            `current_tag_table_${sel.marcFormat}_${sel.marcRecordType}`;
 
         this.tagMap = this.store.getLocalItem(cacheKey);
 
@@ -146,18 +156,19 @@ export class TagTableService {
             return Promise.resolve(this.tagMap);
         }
 
-        return this.fetchTagTable(selector).then(_ => {
+        return this.fetchTagTable().then(_ => {
             this.store.setLocalItem(cacheKey, this.tagMap);
             return this.tagMap;
         });
     }
 
-    fetchTagTable(selector?: TagTableSelector): Promise<any> {
+    fetchTagTable(): Promise<any> {
         this.tagMap = [];
         return this.net.request(
             'open-ils.cat',
             'open-ils.cat.tag_table.all.retrieve.local',
-            this.auth.token(), selector.marcFormat, selector.marcRecordType
+            this.auth.token(), this.selector.marcFormat,
+            this.selector.marcRecordType
         ).pipe(tap(tagData => {
             this.tagMap[tagData.tag] = tagData;
         })).toPromise();
@@ -179,16 +190,17 @@ export class TagTableService {
 
     getFieldTags(): ContextMenuEntry[] {
 
-        const cached = this.fromCache('fieldtags');
-        if (cached) { return cached; }
+        if (!this.fieldTags) {
+            this.fieldTags = Object.keys(this.tagMap)
+            .filter(tag => Boolean(this.tagMap[tag]))
+            .map(tag => ({
+                value: tag,
+                label: `${tag}: ${this.tagMap[tag].name}`
+            }))
+            .sort((a, b) => a.label < b.label ? -1 : 1);
+        }
 
-        return Object.keys(this.tagMap)
-        .filter(tag => Boolean(this.tagMap[tag]))
-        .map(tag => ({
-            value: tag,
-            label: `${tag}: ${this.tagMap[tag].name}`
-        }))
-        .sort((a, b) => a.label < b.label ? -1 : 1);
+        return this.fieldTags;
     }
 
     getSubfieldValues(tag: string, sfCode: string): ContextMenuEntry[] {
@@ -235,29 +247,29 @@ export class TagTableService {
     }
 
 
-    getFfFieldMeta(fieldCode: string, recordType: string): Promise<IdlObject> {
-        return this.getFfPosTable(recordType).then(table => {
+    getFfFieldMeta(fieldCode: string): Promise<IdlObject> {
+        return this.getFfPosTable().then(table => {
 
-            // Note the AngJS MARC editor stores the full POS table
-            // for all record types in every copy of the table, hence
-            // the seemingly extraneous check in recordType.
+            // Best I can tell, the AngJS MARC editor stores the
+            // full POS table for all record types in every copy of
+            // the table, hence the seemingly extraneous check in ffType.
             return table.filter(
                 field =>
                     field.fixed_field === fieldCode
-                 && field.rec_type === recordType
+                 && field.rec_type === this.selector.ffType
             )[0];
         });
     }
 
 
     // Assumes getFfPosTable and getFfValueTable have already been
-    // invoked for the request record type.
-    getFfValues(fieldCode: string, recordType: string): ContextMenuEntry[] {
+    // invoked for the requested record type.
+    getFfValues(fieldCode: string): ContextMenuEntry[] {
 
-        const cached = this.fromCache('ffvalues', recordType, fieldCode);
+        const cached = this.fromCache('ffvalues', fieldCode);
         if (cached) { return cached; }
 
-        let values = this.ffValueMap[recordType];
+        let values = this.ffValueTable;
 
         if (!values || !values[fieldCode]) { return null; }
 
@@ -269,7 +281,40 @@ export class TagTableService {
             .map(val => ({value: val[0], label: `${val[0]}: ${val[1]}`}))
             .sort((a, b) => a.label < b.label ? -1 : 1);
 
-        return this.toCache('ffvalues', recordType, fieldCode, values);
+        return this.toCache('ffvalues', fieldCode, null, values);
+    }
+
+}
+
+ at Injectable()
+export class TagTableService {
+
+    tagTables: {[marcRecordType: string]: TagTable} = {};
+    controlledBibTags: string[];
+
+    constructor(
+        private store: StoreService,
+        private auth: AuthService,
+        private net: NetService,
+        private pcrud: PcrudService,
+    ) {}
+
+    loadTags(selector: TagTableSelector): Promise<TagTable> {
+        if (!selector.marcFormat) {
+            selector.marcFormat = DEFAULT_MARC_FORMAT;
+        }
+
+        // Tag tables of a given marc record type are identical.
+        if (this.tagTables[selector.marcRecordType]) {
+            return Promise.resolve(this.tagTables[selector.marcRecordType]);
+        }
+
+        const tt = new TagTable(
+            this.store, this.auth, this.net, this.pcrud, selector);
+
+        this.tagTables[selector.marcRecordType] = tt;
+
+        return tt.load().then(_ => tt);
     }
 
     getControlledBibTags(): Promise<string[]> {
diff --git a/Open-ILS/src/eg2/src/styles.css b/Open-ILS/src/eg2/src/styles.css
index 9c120cce7d..169cf639b2 100644
--- a/Open-ILS/src/eg2/src/styles.css
+++ b/Open-ILS/src/eg2/src/styles.css
@@ -220,3 +220,8 @@ body>.dropdown-menu {z-index: 2100;}
   color: black;
 }
 
+/* Allow for larger XL dialogs */
+ at media (min-width: 1300px) { .modal-xl { max-width: 1200px; } }
+ at media (min-width: 1600px) { .modal-xl { max-width: 1500px; } }
+ at media (min-width: 1700px) { .modal-xl { max-width: 1600px; } }
+
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Cat/Authority.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Cat/Authority.pm
index f601c0e585..82d04f56e9 100644
--- a/Open-ILS/src/perlmods/lib/OpenILS/Application/Cat/Authority.pm
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Cat/Authority.pm
@@ -374,7 +374,7 @@ __PACKAGE__->register_method(
 );
 
 # Returns the first found field.
-sub get_auth_field {
+sub get_auth_field_by_tag {
     my ($atag, $cset_id) = @_;
 
     my $e = new_editor();
@@ -400,7 +400,7 @@ sub bib_field_overlay_authority_field {
     # not consistent with the control set, it may produce unexpected
     # results.
     my $sf_list = '';
-    my $acsaf = get_auth_field($atag, $cset_id);
+    my $acsaf = get_auth_field_by_tag($atag, $cset_id);
 
     if ($acsaf) {
         $sf_list = $acsaf->sf_list;
@@ -409,7 +409,7 @@ sub bib_field_overlay_authority_field {
 
         # Handle 4XX and 5XX
         (my $alt_atag = $atag) =~ s/^./1/;
-        $acsaf = get_auth_field($alt_atag, $cset_id) if $alt_atag ne $atag;
+        $acsaf = get_auth_field_by_tag($alt_atag, $cset_id) if $alt_atag ne $atag;
 
         $sf_list = $acsaf->sf_list if $acsaf;
     }
@@ -515,7 +515,11 @@ sub validate_bib_fields {
                 acsaf => ['id', 'tag', 'sf_list', 'control_set']
             },
             from => {acsbf => {acsaf => {}}},
-            where => $where
+            where => $where,
+            order_by => [
+                {class => 'acsaf', field => 'main_entry', direction => 'desc'},
+                {class => 'acsaf', field => 'tag'}
+            ]
         });
 
         my @seen_subfields;
@@ -553,8 +557,8 @@ sub validate_bib_fields {
             $record->append_fields($field);
 
             my $match = $U->simplereq(
-                'open-ils.cat', 
-                'open-ils.cat.authority.simple_heading.from_xml',
+                'open-ils.search', 
+                'open-ils.search.authority.simple_heading.from_xml',
                 $record->as_xml_record, $control_set);
 
             if ($match) {
@@ -631,14 +635,24 @@ sub bib_field_authority_linking_browse {
 
     return [] unless $bib_field;
 
-    my $term = join(' ', map {$_->[0]} @{$bib_field->{subfields}});
+    my $term = join(' ', map {$_->[1]} @{$bib_field->{subfields}});
 
     return [] unless $term;
 
     my $axis = $e->json_query({
         select => {abaafm => ['axis']},
         from => {acsbf => {acsaf => {join => 'abaafm'}}},
-        where => {'+acsbf' => {tag => $bib_field->{tag}}}
+        where => {'+acsbf' => {tag => $bib_field->{tag}}},
+        order_by => [
+            {class => 'acsaf', field => 'main_entry', direction => 'desc'},
+            {class => 'acsaf', field => 'tag'},
+
+            # This lets us favor the 'subject' axis to the 'topic' axis.
+            # Topic is a subset of subject.  It's not clear if a field
+            # can link only to the 'topic' axes.  In stock EG, the one
+            # 'topic' field also links to 'subject'.
+            {class => 'abaafm', field => 'axis'}
+        ]
     })->[0];
 
     return [] unless $axis && ($axis = $axis->{axis});

commit 90d93ea18314597f7a31a0450f8f7f652d26864e
Author: Bill Erickson <berickxx at gmail.com>
Date:   Mon Dec 16 10:40:01 2019 -0500

    LP1852782 MARC editor authority linking support
    
    Adds authority browse UI for controlled bib tags, with support for
    applying headings for found authorities.
    
    Adds 3 new open-ils.cat APIs for managing the authority browse and
    linking logic, lifted from the AngJS MARC editor.
    
    open-ils.cat.authority.validate.bib_field
    open-ils.cat.authority.bib_field.linking_browse
    open-ils.cat.authority.bib_field.overlay_authority
    
    Adds new "Show As Heading" and "Show As MARC" options allowing staff to
    see the main headings, see from, and see alsos as human-friendly text or
    as the raw MARC data.
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    
    Signed-off-by: Jane Sandberg <sandbej at linnbenton.edu>

diff --git a/Open-ILS/src/eg2/src/app/staff/share/buckets/bucket-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/share/buckets/bucket-dialog.component.html
index 2c595487f6..de4d29cee8 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/buckets/bucket-dialog.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/share/buckets/bucket-dialog.component.html
@@ -9,7 +9,7 @@
       </ng-container>
       <span *ngIf="fromBibQueue" i18n>Add Records from queue #{{fromBibQueue}} to Bucket</span>
     </h4>
-    <button type="button" class="close" 
+    <button type="button" class="close"
       i18n-aria-label aria-label="Close" (click)="close()">
       <span aria-hidden="true">×</span>
     </button>
@@ -18,13 +18,13 @@
     <div class="row">
       <div class="col-lg-3 font-weight-bold" i18n>Name of existing bucket</div>
       <div class="col-lg-5">
-        <eg-combobox [entries]="formatBucketEntries()" 
+        <eg-combobox [entries]="formatBucketEntries()"
           (onChange)="bucketChanged($event)"
           placeholder="Existing Bucket..." i18n-placeholder>
         </eg-combobox>
       </div>
       <div class="col-lg-4">
-        <button class="btn btn-info" (click)="addToSelected()" i18n 
+        <button class="btn btn-info" (click)="addToSelected()" i18n
           [disabled]="!selectedBucket">
           Add To Selected Bucket
         </button>
@@ -33,13 +33,13 @@
     <div class="row mt-3">
       <div class="col-lg-3 font-weight-bold" i18n>Name of new bucket</div>
       <div class="col-lg-5">
-        <input type="text" class="form-control" 
+        <input type="text" class="form-control"
           placeholder="New Bucket Name..."
           i18n-placeholder
           [(ngModel)]="newBucketName"/>
       </div>
       <div class="col-lg-4">
-        <button class="btn btn-info" (click)="addToNew()" i18n 
+        <button class="btn btn-info" (click)="addToNew()" i18n
           [disabled]="!newBucketName">
           Add To New Bucket
         </button>
@@ -48,7 +48,7 @@
     <div class="row mt-3">
       <div class="col-lg-3 font-weight-bold" i18n>New bucket description</div>
       <div class="col-lg-5">
-        <textarea size="3" type="text" class="form-control" 
+        <textarea size="3" type="text" class="form-control"
           placeholder="Optional New Bucket Description..."
           i18n-placeholder
           [(ngModel)]="newBucketDesc">
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/authority-linking-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/authority-linking-dialog.component.html
new file mode 100644
index 0000000000..c0e9558433
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/authority-linking-dialog.component.html
@@ -0,0 +1,116 @@
+
+<!-- display a single heading as MARC -->
+<ng-template #fieldAsMarc let-field="field">
+  <span>{{field.tag}} {{field.ind1}} {{field.ind2}}</span>
+  <span *ngFor="let sf of field.subfields">
+    <span class="text-danger" i18n>‡</span>{{sf[0]}} {{sf[1]}}
+  </span>
+</ng-template>
+
+<!-- display a single heading as MARC or as the human friendlier string -->
+<ng-template #headingField let-field="field" let-from="from" let-also="also">
+  <button class="btn btn-sm btn-outline-info p-1 mr-1" 
+    (click)="applyHeading(field)" i18n>Apply</button>
+  <ng-container *ngIf="showAs == 'heading'">
+    <span *ngIf="from" i18n>See From: {{field.heading}}</span>
+    <span *ngIf="also" i18n>See Also: {{field.heading}}</span>
+    <span *ngIf="!from && !also" i18n>{{field.heading}}</span>
+  </ng-container>
+  <ng-container *ngIf="showAs == 'marc'">
+    <ng-container
+      *ngTemplateOutlet="fieldAsMarc;context:{field:field}">
+    </ng-container>
+  </ng-container>
+</ng-template>
+
+<ng-template #dialogContent>
+  <div class="modal-header bg-info">
+    <h4 class="modal-title" i18n>Manage Authority Links</h4>
+    <button type="button" class="close"
+      i18n-aria-label aria-label="Close" (click)="close()">
+      <span aria-hidden="true">×</span>
+    </button>
+  </div>
+  <div class="modal-body">
+    <div class="row border-bottom border-secondary p-2 d-flex">
+      <div class="flex-1 font-weight-bold p-1 pl-2 pt-2 ml-2">
+        <div>{{bibField.tag}} {{bibField.ind1}} {{bibField.ind2}}</div>
+
+        <div *ngFor="let sf of bibField.subfields">
+          <div class="form-check form-check-inline">
+            <input class="form-check-input" id="search-subfield-{{sf[0]}}" 
+              type="checkbox" [disabled]="!isControlledBibSf(sf[0])"
+              [(ngModel)]="selectedSubfields[sf[0]]" 
+              (change)="getPage(pager.offset)"/>
+
+            <span class="text-danger" i18n>‡</span>
+
+            <label class="form-check-label" for="search-subfield-{{sf[0]}}" i18n>
+              {{sf[0]}} {{sf[1]}}
+            </label>
+          </div>
+        </div>
+      </div>
+      <div class="ml-2 p-1">
+        <div class="mb-1" i18n>Create new authority from this field</div>
+        <div>
+          <button class="btn btn-outline-info" [disabled]="true">
+            Immediately
+          </button>
+          <button class="btn btn-outline-info ml-2" [disabled]="true">
+            Create and Edit
+          </button>
+        </div>
+      </div>
+    </div>
+    <div class="row border-bottom border-secondary p-2 d-flex">
+      <div class="flex-1">
+        <button class="btn btn-outline-dark" [disabled]="pager.offset == 0"
+          (click)="getPage(0)" i18n>Start</button>
+        <button class="btn btn-outline-dark ml-2"
+          (click)="getPage(-1)" i18n>Previous</button>
+        <button class="btn btn-outline-dark ml-2"
+          (click)="getPage(1)" i18n>Next</button>
+      </div>
+      <div class="pt-2 mb-2">
+        <div class="form-check form-check-inline">
+          <input class="form-check-input" type="radio" value="heading"
+            [(ngModel)]="showAs" name='show-as-heading' id="show-as-heading">
+          <label class="form-check-label" for="show-as-heading" i18n>Show As Heading</label>
+        </div>
+        <div class="form-check form-check-inline">
+          <input class="form-check-input" type="radio" value="marc"
+            [(ngModel)]="showAs" name='show-as-heading' id="show-as-marc">
+          <label class="form-check-label" for="show-as-marc" i18n>Show As MARC</label>
+        </div>
+      </div>
+    </div>
+    <ul *ngFor="let entry of browseData">
+      <li class="d-flex">
+        <div class="flex-1">
+          <ng-container
+            *ngTemplateOutlet="headingField;context:{field:entry.main_heading}">
+          </ng-container>
+        </div>
+        <div class="font-italic" i18n-title i18n
+          title="Authority Record ID {{entry.authority_id}}">
+          #{{entry.authority_id}}
+        </div>
+      </li>
+      <ul *ngFor="let from of entry.see_froms">
+        <li i18n>
+         <ng-container
+          *ngTemplateOutlet="headingField;context:{field:from, from:true}">
+         </ng-container>
+        </li>
+      </ul>
+      <ul *ngFor="let also of entry.see_alsos">
+        <li i18n>
+         <ng-container
+          *ngTemplateOutlet="headingField;context:{field:also, also:true}">
+         </ng-container>
+        </li>
+      </ul>
+    </ul>
+  </div>
+</ng-template>
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/authority-linking-dialog.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/authority-linking-dialog.component.ts
new file mode 100644
index 0000000000..2015ec187c
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/authority-linking-dialog.component.ts
@@ -0,0 +1,134 @@
+import {Component, Input, Output, OnInit, EventEmitter} from '@angular/core';
+import {NetService} from '@eg/core/net.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {DialogComponent} from '@eg/share/dialog/dialog.component';
+import {NgbModal} from '@ng-bootstrap/ng-bootstrap';
+import {MarcField} from './marcrecord';
+import {Pager} from '@eg/share/util/pager';
+
+/**
+ * MARC Authority Linking Dialog
+ */
+
+ at Component({
+  selector: 'eg-authority-linking-dialog',
+  templateUrl: './authority-linking-dialog.component.html'
+})
+
+export class AuthorityLinkingDialogComponent
+    extends DialogComponent implements OnInit {
+
+    @Input() bibField: MarcField;
+    @Input() thesauri: string = null;
+    @Input() controlSet: number = null;
+    @Input() pager: Pager;
+
+    browseData: any[] = [];
+
+    // If false, show the raw MARC field data.
+    showAs: 'heading' | 'marc' = 'heading';
+
+    authMeta: any;
+
+    selectedSubfields: string[] = [];
+
+    constructor(
+        private modal: NgbModal,
+        private pcrud: PcrudService,
+        private net: NetService) {
+        super(modal);
+    }
+
+    ngOnInit() {
+        if (!this.pager) {
+            this.pager = new Pager();
+            this.pager.limit = 5;
+        }
+
+        this.onOpen$.subscribe(_ => this.initData());
+    }
+
+    fieldHash(field?: MarcField): any {
+        if (!field) { field = this.bibField; }
+
+        return {
+            tag: field.tag,
+            ind1: field.ind1,
+            ind2: field.ind2,
+            subfields: field.subfields.map(sf => [sf[0], sf[1]])
+        };
+    }
+
+    initData() {
+
+       this.pager.offset = 0;
+
+       this.pcrud.search('acsbf',
+            {tag: this.bibField.tag},
+            {flesh: 1, flesh_fields: {acsbf: ['authority_field']}},
+            {atomic:  true, anonymous: true}
+
+        ).subscribe(bibMetas => {
+            if (bibMetas.length === 0) { return; }
+
+            let bibMeta;
+            if (this.controlSet) {
+                bibMeta = bibMetas.filter(b =>
+                    this.controlSet === +b.authority_field().control_set());
+            } else {
+                bibMeta = bibMetas[0];
+            }
+
+            if (bibMeta) {
+                this.authMeta = bibMeta.authority_field();
+                this.bibField.subfields.forEach(sf =>
+                    this.selectedSubfields[sf[0]] =
+                        this.isControlledBibSf(sf[0])
+                );
+            }
+
+            this.getPage(0);
+        });
+    }
+
+    getPage(direction: number) {
+        this.browseData = [];
+
+        if (direction > 0) {
+            this.pager.offset++;
+        } else if (direction < 0) {
+            this.pager.offset--;
+        } else {
+            this.pager.offset = 0;
+        }
+
+        const hash = this.fieldHash();
+
+        // Only search the selected subfields
+        hash.subfields =
+            hash.subfields.filter(sf => this.selectedSubfields[sf[0]]);
+
+        if (hash.subfields.length === 0) { return; }
+
+        this.net.request(
+            'open-ils.cat',
+            'open-ils.cat.authority.bib_field.linking_browse',
+            hash, this.pager.limit,
+            this.pager.offset, this.thesauri
+        ).subscribe(entry => this.browseData.push(entry));
+    }
+
+    applyHeading(authField: MarcField) {
+        this.net.request(
+            'open-ils.cat',
+            'open-ils.cat.authority.bib_field.overlay_authority',
+            this.fieldHash(), this.fieldHash(authField), this.controlSet
+        ).subscribe(field => this.close(field));
+    }
+
+    isControlledBibSf(sf: string): boolean {
+        return this.authMeta ?
+            this.authMeta.sf_list().includes(sf) : false;
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.css b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.css
index e21bb843d8..4778fbe9f2 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.css
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.css
@@ -8,15 +8,19 @@ div[contenteditable] {
    min-height: calc(1.5em + .75rem + 2px);
 }
 
-.sf-delimiter { 
+.sf-delimiter {
   /* match angjs color */
-  color: rgb(0, 0, 255)!important; 
+  color: rgb(0, 0, 255)!important;
   /* snuggle up to my subfield code */
-  margin-right: -0.5rem; 
+  margin-right: -0.5rem;
 }
 
-.sf-code { 
+.sf-code {
   /* match angjs color */
-  color: rgb(0, 0, 255)!important; 
+  color: rgb(0, 0, 255)!important;
+}
+
+.auth-invalid {
+  color: rgb(255, 0, 0)!important;
 }
 
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.html b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.html
index 07b67767a6..cba8925f03 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.html
@@ -1,6 +1,6 @@
 
-<!-- 
-Some context menus have additional static options.  
+<!--
+Some context menus have additional static options.
 Track their labels here.
 -->
 <eg-string #add006 text="Add 006" i18n-text></eg-string>
@@ -12,9 +12,10 @@ Track their labels here.
 
 <ng-container *ngIf="bigText">
   <div contenteditable
-    id='{{randId}}' 
+    id='{{randId}}'
     spellcheck="false"
     class="d-inline-block text-dark text-break {{moreClasses}}"
+    [ngClass]="{'auth-invalid': isAuthInvalid()}"
     [attr.tabindex]="fieldText ? -1 : ''"
     [attr.aria-label]="ariaLabel"
     [egContextMenu]="contextMenuEntries()"
@@ -27,13 +28,14 @@ Track their labels here.
 </ng-container>
 
 <ng-container *ngIf="!bigText">
-  <input 
-    id='{{randId}}' 
+  <input
+    id='{{randId}}'
     spellcheck="false"
     class="text-dark rounded-0 form-control {{moreClasses}}"
-    [size]="inputSize()" 
+    [ngClass]="{'auth-invalid': isAuthInvalid()}"
+    [size]="inputSize()"
     [maxlength]="maxLength || ''"
-    [disabled]="fieldText" 
+    [disabled]="fieldText"
     [attr.tabindex]="fieldText ? -1 : ''"
     [attr.aria-label]="ariaLabel"
     [egContextMenu]="contextMenuEntries()"
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts
index 2eeadf16e4..88be84c592 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts
@@ -597,6 +597,36 @@ export class EditableContentComponent
         // Context menus can steal focus.
         this.context.requestFieldFocus(this.context.lastFocused);
     }
+
+    isAuthInvalid(): boolean {
+        return (
+            this.fieldType === 'sfv' &&
+            this.field.authChecked &&
+            !this.field.authValid
+        );
+    }
+
+    isAuthValid(): boolean {
+        return (
+            this.fieldType === 'sfv' &&
+            this.field.authChecked &&
+            this.field.authValid
+        );
+    }
+
+    isLastSubfieldValue(): boolean {
+        if (this.fieldType === 'sfv') {
+            const myIdx = this.subfield[2];
+            for (let idx = 0; idx < this.field.subfields.length; idx++) {
+                if (idx > myIdx) {
+                    return false;
+                }
+            }
+            return true;
+        }
+
+        return false;
+    }
 }
 
 
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-context.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-context.ts
index ae7af497c4..16a8caeecc 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-context.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-context.ts
@@ -22,6 +22,10 @@ export class UndoRedoAction {
 
     // Which stack do we toss this on once it's been applied?
     isRedo: boolean;
+
+    // Grouped actions are tracked as multiple undo / redo actions, but
+    // are done and un-done as a unit.
+    groupSize?: number;
 }
 
 export class TextUndoRedoAction extends UndoRedoAction {
@@ -88,7 +92,9 @@ export class MarcEditContext {
     requestFieldFocus(req: FieldFocusRequest) {
         // timeout allows for new components to be built before the
         // focus request is emitted.
-        setTimeout(() => this.fieldFocusRequest.emit(req));
+        if (req) {
+            setTimeout(() => this.fieldFocusRequest.emit(req));
+        }
     }
 
     resetUndos() {
@@ -97,18 +103,75 @@ export class MarcEditContext {
     }
 
     requestUndo() {
-        const undo = this.undoStack.shift();
-        if (undo) {
-            undo.isRedo = false;
-            this.distributeUndoRedo(undo);
-        }
+        let remaining = null;
+
+        do {
+            const action = this.undoStack.shift();
+            if (!action) { return; }
+
+            if (remaining === null) {
+                remaining = action.groupSize || 1;
+            }
+            remaining--;
+
+            action.isRedo = false;
+            this.distributeUndoRedo(action);
+
+        } while (remaining > 0);
     }
 
     requestRedo() {
-        const redo = this.redoStack.shift();
-        if (redo) {
-            redo.isRedo = true;
-            this.distributeUndoRedo(redo);
+        let remaining = null;
+
+        do {
+            const action = this.redoStack.shift();
+            if (!action) { return; }
+
+            if (remaining === null) {
+                remaining = action.groupSize || 1;
+            }
+            remaining--;
+
+            action.isRedo = true;
+            this.distributeUndoRedo(action);
+
+        } while (remaining > 0);
+    }
+
+    // Calculate stack action count taking groupSize (atomic action
+    // sets) into consideration.
+    stackCount(stack: UndoRedoAction[]): number {
+        let size = 0;
+        let skip = 0;
+
+        stack.forEach(action => {
+            if (action.groupSize > 1) {
+                if (skip) { return; }
+                skip = 1;
+            } else {
+                skip = 0;
+            }
+            size++;
+        });
+
+        return size;
+    }
+
+    undoCount(): number {
+        return this.stackCount(this.undoStack);
+    }
+
+    redoCount(): number {
+        return this.stackCount(this.redoStack);
+    }
+
+    // Stamp the most recent 'size' entries in the undo stack
+    // as being an atomic undo/redo set.
+    setUndoGroupSize(size: number) {
+        for (let idx = 0; idx < size; idx++) {
+            if (this.undoStack[idx]) {
+                this.undoStack[idx].groupSize = size;
+            }
         }
     }
 
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marc-edit.module.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marc-edit.module.ts
index c7bbaba481..e34928dd48 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marc-edit.module.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marc-edit.module.ts
@@ -8,6 +8,7 @@ import {FixedFieldsEditorComponent} from './fixed-fields-editor.component';
 import {FixedFieldComponent} from './fixed-field.component';
 import {TagTableService} from './tagtable.service';
 import {EditableContentComponent} from './editable-content.component';
+import {AuthorityLinkingDialogComponent} from './authority-linking-dialog.component';
 
 @NgModule({
     declarations: [
@@ -16,7 +17,8 @@ import {EditableContentComponent} from './editable-content.component';
         MarcFlatEditorComponent,
         FixedFieldsEditorComponent,
         FixedFieldComponent,
-        EditableContentComponent
+        EditableContentComponent,
+        AuthorityLinkingDialogComponent
     ],
     imports: [
         StaffCommonModule,
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marcrecord.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marcrecord.ts
index cdc99aac4b..d49f4ef568 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marcrecord.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marcrecord.ts
@@ -19,6 +19,10 @@ export interface MarcField {
     ind2?: string;
     subfields?: MarcSubfield[];
 
+    // For authority validation
+    authValid: boolean;
+    authChecked: boolean;
+
     // Fields are immutable when it comes to controlfield vs.
     // data field.  Stamp the value when stamping field IDs.
     isCtrlField: boolean;
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.css b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.css
index 5c90f28b9b..e3c973c1df 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.css
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.css
@@ -6,3 +6,10 @@
   border-bottom: 1px solid gray;
   */
 }
+
+.link-button .material-icons {
+  font-size: 17px;
+  display: inline-flex;
+  vertical-align: middle;
+  align-items: center;
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.html b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.html
index a7ca33f082..68e0f68073 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.html
@@ -7,6 +7,7 @@
   </div>
 </ng-container>
 
+<eg-authority-linking-dialog #authLinker></eg-authority-linking-dialog>
 
 <ng-template #subfieldChunk let-field="field" let-subfield="subfield">
 
@@ -33,6 +34,31 @@
 
 </ng-template>
 
+<ng-template #postSubfieldsChunk let-field="field">
+
+  <ng-container *ngIf="isControlledBibTag(field.tag)">
+    <button class="btn btn-sm btn-info link-button"
+      (click)="openLinkerDialog(field)">
+      <span class="material-icons">link</span>
+    </button>
+  </ng-container>
+
+  <ng-container *ngIf="field.authChecked">
+    <span class="pl-2 pt-2">
+      <span *ngIf="field.authValid"
+        title="Authority Validation Succeeded" i18n-title
+        class="material-icons label-with-material-icon text-success">
+        check_circle_outline
+      </span>
+      <span *ngIf="!field.authValid"
+        title="Authority Validation Failed" i18n-title
+        class="material-icons label-with-material-icon text-danger">
+        error_outline
+      </span>
+    </span>
+  </ng-container>
+</ng-template>
+
 <ng-container *ngIf="dataLoaded">
   <div class="mt-3 text-monospace"
     (contextmenu)="$event.preventDefault()">
@@ -44,21 +70,20 @@
         <div><button class="btn btn-outline-dark"
           (click)="showHelp = !showHelp" i18n>Help</button></div>
         <div class="mt-2"><button class="btn btn-outline-dark"
-          [disabled]="true"
           (click)="validate()" i18n>Validate</button></div>
         <div class="mt-2">
-          <button type="button" class="btn btn-outline-info" 
+          <button type="button" class="btn btn-outline-info"
             [disabled]="undoCount() < 1" (click)="undo()">
             Undo <span class="badge badge-info">{{undoCount()}}</span>
           </button>
-          <button type="button" class="btn btn-outline-info ml-2" 
+          <button type="button" class="btn btn-outline-info ml-2"
             [disabled]="redoCount() < 1" (click)="redo()">
             Redo <span class="badge badge-info">{{redoCount()}}</span>
           </button>
         </div>
         <div class="mt-2">
           <div class="form-check">
-            <input class="form-check-input" type="checkbox" 
+            <input class="form-check-input" type="checkbox"
               (change)="stackSubfieldsChange()"
               [(ngModel)]="stackSubfields" id="stack-subfields-{{randId}}">
             <label class="form-check-label" for="stack-subfields-{{randId}}">
@@ -149,21 +174,27 @@
 
         <!-- when not stacking subfields, render them inline -->
         <ng-container *ngIf="!stackSubfields">
-          <ng-container *ngFor="let subfield of field.subfields">
-            <ng-container 
+          <ng-container *ngFor="let subfield of field.subfields; let last = last">
+            <ng-container
               *ngTemplateOutlet="subfieldChunk;context:{field:field,subfield:subfield}">
             </ng-container>
+            <ng-container *ngIf="last">
+              <ng-container
+                *ngTemplateOutlet="postSubfieldsChunk;context:{field:field}">
+              </ng-container>
+            </ng-container>
           </ng-container>
         </ng-container>
+
       </div>
 
-      <!-- when stacking subfields, each subfield gets its own row 
+      <!-- when stacking subfields, each subfield gets its own row
         preceeded by a placeholder for the tag as a way to 'tab' right -->
       <ng-container *ngIf="stackSubfields">
         <div class="form-inline" *ngFor="let subfield of field.subfields">
           <eg-marc-editable-content fieldText="   " moreClasses="p-1 invisible">
           </eg-marc-editable-content>
-          <ng-container 
+          <ng-container
             *ngTemplateOutlet="subfieldChunk;context:{field:field,subfield:subfield}">
           </ng-container>
         </div>
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.ts
index a1a7d552eb..480c7ea5b2 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.ts
@@ -1,12 +1,14 @@
 import {Component, Input, Output, OnInit, AfterViewInit, EventEmitter,
-    OnDestroy} from '@angular/core';
+    ViewChild, OnDestroy} from '@angular/core';
 import {filter} from 'rxjs/operators';
 import {IdlService} from '@eg/core/idl.service';
+import {NetService} from '@eg/core/net.service';
 import {OrgService} from '@eg/core/org.service';
 import {ServerStoreService} from '@eg/core/server-store.service';
 import {TagTableService} from './tagtable.service';
 import {MarcRecord, MarcField} from './marcrecord';
 import {MarcEditContext} from './editor-context';
+import {AuthorityLinkingDialogComponent} from './authority-linking-dialog.component';
 
 
 /**
@@ -28,9 +30,14 @@ export class MarcRichEditorComponent implements OnInit {
     showHelp: boolean;
     randId = Math.floor(Math.random() * 100000);
     stackSubfields: boolean;
+    controlledBibTags: string[] = [];
+
+    @ViewChild('authLinker', {static: false})
+        authLinker: AuthorityLinkingDialogComponent;
 
     constructor(
         private idl: IdlService,
+        private net: NetService,
         private org: OrgService,
         private store: ServerStoreService,
         private tagTable: TagTableService
@@ -57,7 +64,9 @@ export class MarcRichEditorComponent implements OnInit {
         return Promise.all([
             this.tagTable.loadTagTable({marcRecordType: this.context.recordType}),
             this.tagTable.getFfPosTable(this.record.recordType()),
-            this.tagTable.getFfValueTable(this.record.recordType())
+            this.tagTable.getFfValueTable(this.record.recordType()),
+            this.tagTable.getControlledBibTags().then(
+                tags => this.controlledBibTags = tags)
         ]).then(_ =>
             // setTimeout forces all of our sub-components to rerender
             // themselves each time init() is called.  Without this,
@@ -77,11 +86,11 @@ export class MarcRichEditorComponent implements OnInit {
     }
 
     undoCount(): number {
-        return this.context.undoStack.length;
+        return this.context.undoCount();
     }
 
     redoCount(): number {
-        return this.context.redoStack.length;
+        return this.context.redoCount();
     }
 
     undo() {
@@ -99,6 +108,52 @@ export class MarcRichEditorComponent implements OnInit {
     dataFields(): MarcField[] {
         return this.record.fields.filter(f => !f.isCtrlField);
     }
+
+    validate() {
+        const fields = [];
+        this.record.fields.filter(f => this.isControlledBibTag(f.tag))
+
+        .forEach(f => {
+            f.authValid = false;
+            fields.push({
+                id: f.fieldId, // ignored and echoed by server
+                tag: f.tag,
+                ind1: f.ind1,
+                ind2: f.ind2,
+                subfields: f.subfields.map(sf => ({code: sf[0], value: sf[1]}))
+            });
+        });
+
+        this.net.request('open-ils.cat',
+            'open-ils.cat.authority.validate.bib_field', fields)
+        .subscribe(checkedField => {
+            const bibField = this.record.fields
+                .filter(f => f.fieldId === +checkedField.id)[0];
+
+            bibField.authChecked = true;
+            bibField.authValid = checkedField.valid;
+        });
+    }
+
+    isControlledBibTag(tag: string): boolean {
+        return this.controlledBibTags && this.controlledBibTags.includes(tag);
+    }
+
+    openLinkerDialog(field: MarcField) {
+        this.authLinker.bibField = field;
+        this.authLinker.open({size: 'lg'}).subscribe(newField => {
+            if (!newField) { return; }
+
+            // Performs an insert followed by a delete, so the two
+            // fields can be tracked separately for undo/redo actions.
+            const marcField = this.record.newField(newField);
+            this.context.insertField(field, marcField);
+            this.context.deleteField(field);
+
+            // Mark the insert and delete as an atomic undo/redo action.
+            this.context.setUndoGroupSize(2);
+        });
+    }
 }
 
 
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/tagtable.service.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/tagtable.service.ts
index e3571b1322..3d2fc7370f 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/tagtable.service.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/tagtable.service.ts
@@ -1,5 +1,6 @@
 import {Injectable, EventEmitter} from '@angular/core';
-import {map, tap} from 'rxjs/operators';
+import {Observable} from 'rxjs';
+import {map, tap, distinct} from 'rxjs/operators';
 import {StoreService} from '@eg/core/store.service';
 import {IdlObject} from '@eg/core/idl.service';
 import {AuthService} from '@eg/core/auth.service';
@@ -25,6 +26,7 @@ export class TagTableService {
     tagMap: {[tag: string]: any} = {};
     ffPosMap: {[rtype: string]: any[]} = {};
     ffValueMap: {[rtype: string]: any} = {};
+    controlledBibTags: string[];
 
     extractedValuesCache:
         {[valueType: string]: {[which: string]: any}} = {};
@@ -269,6 +271,20 @@ export class TagTableService {
 
         return this.toCache('ffvalues', recordType, fieldCode, values);
     }
+
+    getControlledBibTags(): Promise<string[]> {
+        if (this.controlledBibTags) {
+            return Promise.resolve(this.controlledBibTags);
+        }
+
+        this.controlledBibTags = [];
+        return this.pcrud.retrieveAll('acsbf', {select: ['tag']})
+        .pipe(
+            map(field => field.tag()),
+            distinct(),
+            map(tag => this.controlledBibTags.push(tag))
+        ).toPromise().then(_ => this.controlledBibTags);
+    }
 }
 
 
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Cat/Authority.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Cat/Authority.pm
index 66c6c4a9eb..f601c0e585 100644
--- a/Open-ILS/src/perlmods/lib/OpenILS/Application/Cat/Authority.pm
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Cat/Authority.pm
@@ -1,5 +1,7 @@
 package OpenILS::Application::Cat::Authority;
 use strict; use warnings;
+use MARC::Record;
+use MARC::File::XML (BinaryEncoding => 'utf8', RecordFormat => 'USMARC');
 use base qw/OpenILS::Application/;
 use OpenILS::Utils::CStoreEditor q/:funcs/;
 use OpenILS::Application::Cat::AuthCommon;
@@ -347,4 +349,335 @@ sub retrieve_acsaf {
     return undef;
 }
 
+__PACKAGE__->register_method(
+    method => "bib_field_overlay_authority_field",
+    api_name => "open-ils.cat.authority.bib_field.overlay_authority",
+    api_level => 1,
+    stream => 1,
+    argc => 2,
+    signature => {
+        desc => q/Given a bib field hash and an authority field hash,
+            merge the authority data for controlled fields into the 
+            bib field./,
+        params => [
+            {name => 'Bib Field', 
+                desc => '{tag:., ind1:., ind2:.,subfields:[[code, value],...]}'},
+            {name => 'Authority Field', 
+                desc => '{tag:., ind1:., ind2:.,subfields:[[code, value],...]}'},
+            {name => 'Control Set ID',
+                desc => q/Optional control set limiter.  If no control set
+                    is provided, the first matching authority field
+                    definition will be used./}
+        ],
+        return => q/The modified bib field/
+    }
+);
+
+# Returns the first found field.
+sub get_auth_field {
+    my ($atag, $cset_id) = @_;
+
+    my $e = new_editor();
+
+    my $where = {tag => $atag};
+
+    $where->{control_set} = $cset_id if $cset_id;
+
+    return $e->search_authority_control_set_authority_field($where)->[0];
+}
+
+sub bib_field_overlay_authority_field {
+    my ($self, $client, $bib_field, $auth_field, $cset_id) = @_;
+
+    return $bib_field unless $bib_field && $auth_field;
+
+    my $btag = $bib_field->{'tag'};
+    my $atag = $auth_field->{'tag'};
+
+    # Find the controlled subfields.  Here we assume the authority
+    # field provided should be used as the source of which subfields
+    # are controlled.  If passed a set of bib and auth data that are
+    # not consistent with the control set, it may produce unexpected
+    # results.
+    my $sf_list = '';
+    my $acsaf = get_auth_field($atag, $cset_id);
+
+    if ($acsaf) {
+        $sf_list = $acsaf->sf_list;
+
+    } else {
+
+        # Handle 4XX and 5XX
+        (my $alt_atag = $atag) =~ s/^./1/;
+        $acsaf = get_auth_field($alt_atag, $cset_id) if $alt_atag ne $atag;
+
+        $sf_list = $acsaf->sf_list if $acsaf;
+    }
+
+    my $subfields = [];
+    my $auth_sf_zero;
+
+    # Add the controlled authority subfields
+    for my $sf (@{$auth_field->{subfields}}) {
+        my $c = $sf->[0]; # subfield code
+        my $v = $sf->[1]; # subfield value
+
+        if ($c eq '0') {
+            $auth_sf_zero = $v;
+
+        } elsif (index($sf_list, $c) > -1) {
+            push(@$subfields, [$c, $v]);
+        }
+    }
+
+    # Add the uncontrolled bib subfields
+    for my $sf (@{$bib_field->{subfields}}) {
+        my $c = $sf->[0]; # subfield code
+        my $v = $sf->[1]; # subfield value
+
+        # Discard the bib '0' since the link is no longer valid, 
+        # given we're replacing the contents of the field.
+        if (index($sf_list, $c) < 0 && $c ne '0') {
+            push(@$subfields, [$c, $v]);
+        }
+    }
+
+    # The data on this authority field may link to yet 
+    # another authority record.  Track that in our bib field
+    # as the last subfield;
+    push(@$subfields, ['0', $auth_sf_zero]) if $auth_sf_zero;
+
+    my $new_bib_field = {
+        tag => $bib_field->{tag},
+        ind1 => $auth_field->{'ind1'},
+        ind2 => $auth_field->{'ind2'},
+        subfields => $subfields
+    };
+
+    $new_bib_field->{ind1} = $auth_field->{'ind2'} 
+        if $atag eq '130' && $btag eq '130';
+
+    return $new_bib_field;
+}
+
+__PACKAGE__->register_method(
+    method    => "validate_bib_fields",
+    api_name  => "open-ils.cat.authority.validate.bib_field",
+    stream => 1,
+    signature => {
+        desc => q/Returns a stream of bib field objects with a 'valid'
+        attribute added, set to 1 or 0, indicating whether the field
+        has a matching authority entry.  If no control set ID is provided
+        all configured control sets will be tested.  Testing will stop
+        with the first positive validation./,
+        params => [
+            {type => 'object', name => 'Bib Fields',
+                description => q/
+                    List of objects like this 
+                    {
+                        tag: tag, 
+                        ind1: ind1, 
+                        ind2: ind2, 
+                        subfields: [[code, value], ...]
+                    }
+
+                    For example:
+srfsh# request open-ils.cat open-ils.cat.authority.validate.bib_field
+  [{"tag":"600","ind1":"", "ind2":"", "subfields":[["a","shakespeare william"], ...]}]
+                /
+            },
+            {type => 'number', name => 'Optional Control Set ID'},
+        ]
+    }
+);
+
+# for stub records sent to 
+# open-ils.cat.authority.simple_heading
+my $auth_leader = '00000czm a2200205Ka 4500';
+
+sub validate_bib_fields {
+    my ($self, $client, $bib_fields, $control_set) = @_;
+
+    $bib_fields = [$bib_fields] unless ref $bib_fields eq 'ARRAY';
+
+    my $e = new_editor();
+
+    for my $bib_field (@$bib_fields) {
+
+        $bib_field->{valid} = 0;
+
+        my $where = {'+acsbf' => {tag => $bib_field->{tag}}};
+        $where->{'+acsaf'} = {control_set => $control_set} if $control_set;
+
+        my $auth_field_list = $e->json_query({
+            select => {
+                acsbf => ['authority_field'],
+                acsaf => ['id', 'tag', 'sf_list', 'control_set']
+            },
+            from => {acsbf => {acsaf => {}}},
+            where => $where
+        });
+
+        my @seen_subfields;
+        for my $auth_field (@$auth_field_list) {
+
+            my $sf_list = $auth_field->{sf_list};
+
+            # Some auth fields have the same sf_list values.  Track the
+            # ones we've already tested.
+            next if grep {$_ eq $sf_list} @seen_subfields;
+
+            push(@seen_subfields, $sf_list);
+
+            my @sf_values;
+            for my $subfield (@{$bib_field->{subfields}}) {
+                my $code = $subfield->[0];
+                my $value = $subfield->[1];
+
+                next unless defined $value && $value ne '';
+
+                # is this a controlled subfield?
+                next unless index($sf_list, $code) > -1;
+
+                push(@sf_values, $code, $value);
+            }
+
+            next unless @sf_values;
+
+            my $record = MARC::Record->new;
+            $record->leader($auth_leader);
+
+            my $field = MARC::Field->new($auth_field->{tag},
+                $bib_field->{ind1}, $bib_field->{ind2}, @sf_values);
+
+            $record->append_fields($field);
+
+            my $match = $U->simplereq(
+                'open-ils.cat', 
+                'open-ils.cat.authority.simple_heading.from_xml',
+                $record->as_xml_record, $control_set);
+
+            if ($match) {
+                $bib_field->{valid} = 1;
+                $bib_field->{authority_record} = $match;
+                $bib_field->{authority_field} = $auth_field->{id};
+                $bib_field->{control_set} = $auth_field->{control_set};
+                last;
+            }
+        }
+
+        # Present our findings.
+        $client->respond($bib_field);
+    }
+
+    return undef;
+}
+
+
+__PACKAGE__->register_method(
+    method    => "bib_field_authority_linking_browse",
+    api_name  => "open-ils.cat.authority.bib_field.linking_browse",
+    stream => 1,
+    signature => {
+        desc => q/Returns a stream of authority record blobs including
+            information on its main heading and its see froms and see 
+            alsos, based on an axis-based browse search.  This was
+            initially created to move some MARC editor authority linking 
+            logic to the server.  The browse axis is derived from the
+            bib field data provided.
+        ...
+        /,
+        params => [
+            {type => 'object', name => 'MARC Field hash {tag:.,ind1:.,ind2:,subfields:[[code,value],.]}'},
+            {type => 'number', name => 'Page size / limit'},
+            {type => 'number', name => 'Page offset'},
+            {type => 'string', name => 'Optional thesauri, comma separated'}
+        ]
+    }
+);
+
+sub get_heading_string {
+    my $field = shift;
+
+    my $heading = '';
+    for my $subfield ($field->subfields) {
+        $heading .= ' --' if index('xyz', $subfield->[0]) > -1;
+        $heading .= ' ' if $heading;
+        $heading .= $subfield->[1];
+    }
+
+    return $heading;
+}
+
+# Turns a MARC::Field into a hash and adds the field's heading string.
+sub hashify_field {
+    my $field = shift;
+    return {
+        heading => get_heading_string($field),
+        tag => $field->tag,
+        ind1 => $field->indicator(1),
+        ind2 => $field->indicator(2),
+        subfields => [$field->subfields]
+    };
+}
+
+sub bib_field_authority_linking_browse {
+    my ($self, $client, $bib_field, $limit, $offset, $thesauri) = @_;
+
+    $offset ||= 0;
+    $limit ||= 5;
+    $thesauri ||= '';
+    my $e = new_editor();
+
+    return [] unless $bib_field;
+
+    my $term = join(' ', map {$_->[0]} @{$bib_field->{subfields}});
+
+    return [] unless $term;
+
+    my $axis = $e->json_query({
+        select => {abaafm => ['axis']},
+        from => {acsbf => {acsaf => {join => 'abaafm'}}},
+        where => {'+acsbf' => {tag => $bib_field->{tag}}}
+    })->[0];
+
+    return [] unless $axis && ($axis = $axis->{axis});
+
+    # See https://bugs.launchpad.net/evergreen/+bug/1403098
+    my $are_ids = $U->simplereq(
+        'open-ils.supercat',
+        'open-ils.supercat.authority.browse_center.by_axis.refs',
+        $axis, $term, $offset, $limit, $thesauri);
+
+    for my $are_id (@$are_ids) {
+
+        my $are = $e->retrieve_authority_record_entry($are_id);
+        my $rec = MARC::Record->new_from_xml($are->marc, 'UTF-8');
+
+        my $main_field = $rec->field('1..');
+        my $auth_org_field = $rec->field('003');
+        my $auth_org = $auth_org_field ? $auth_org_field->data : undef;
+
+        my $resp = {
+            authority_id => $are_id,
+            main_heading => hashify_field($main_field),
+            auth_org => $auth_org,
+            see_alsos => [],
+            see_froms => []
+        };
+
+        for my $also_field ($rec->field('5..')) {
+            push(@{$resp->{see_alsos}}, hashify_field($also_field));
+        }
+
+        for my $from_field ($rec->field('4..')) {
+            push(@{$resp->{see_froms}}, hashify_field($from_field));
+        }
+
+        $client->respond($resp);
+    }
+
+    return undef;
+}
+
 1;
diff --git a/Open-ILS/src/templates/staff/cat/share/t_authority_browser.tt2 b/Open-ILS/src/templates/staff/cat/share/t_authority_browser.tt2
index b52972233b..9ca06859d1 100644
--- a/Open-ILS/src/templates/staff/cat/share/t_authority_browser.tt2
+++ b/Open-ILS/src/templates/staff/cat/share/t_authority_browser.tt2
@@ -18,7 +18,7 @@
             {{main.heading}}
             (<span style="font-family: 'Lucida Console', Monaco, monospace;">
                 <span ng-repeat="sf in main.headingField.subfields">
-                    <span class="marcsfcodedelimiter">‡{{sf.0}}</span> {{sf.1}}
+                    <span class="marcsfcodedelimiter">‡{{sf[0]}}</span> {{sf[1]}}
                 </span>
             </span>)
         </div>
@@ -29,7 +29,7 @@
                     [% l('See from: [_1]', '{{seefrom.heading}}') %]
                     (<span style="font-family: 'Lucida Console', Monaco, monospace;">
                         <span ng-repeat="sf in seefrom.headingField.subfields">
-                            <span class="marcsfcodedelimiter">‡{{sf.0}}</span> {{sf.1}}
+                            <span class="marcsfcodedelimiter">‡{{sf[0]}}</span> {{sf[1]}}
                         </span>
                     </span>)
                 </div>
@@ -40,7 +40,7 @@
                     [% l('See also from: [_1]', '{{seealso.heading}}') %]
                     (<span style="font-family: 'Lucida Console', Monaco, monospace;">
                         <span ng-repeat="sf in seealso.headingField.subfields">
-                            <span class="marcsfcodedelimiter">‡{{sf.0}}</span> {{sf.1}}
+                            <span class="marcsfcodedelimiter">‡{{sf[0]}}</span> {{sf[1]}}
                         </span>
                     </span>)
                 </div>
diff --git a/Open-ILS/src/templates/staff/cat/share/t_authority_linker.tt2 b/Open-ILS/src/templates/staff/cat/share/t_authority_linker.tt2
index 6dd57311c5..6c31eb5b1d 100644
--- a/Open-ILS/src/templates/staff/cat/share/t_authority_linker.tt2
+++ b/Open-ILS/src/templates/staff/cat/share/t_authority_linker.tt2
@@ -2,7 +2,7 @@
     <div class="row form-inline" style="font-family: 'Lucida Console', Monaco, monospace;">
         {{bibField.tag}} {{bibField.ind1}}{{bibField.ind2}} 
         <div ng-repeat="sf in bibField.subfields">
-            <span class="marcsfcodedelimiter">‡{{sf.0}}</span> {{sf.1}}
+            <span class="marcsfcodedelimiter">‡{{sf[0]}}</span> {{sf[1]}}
             <input type="checkbox" ng-model="sf.selected" ng-if="sf.selectable" />
         </div>
     </div>

commit 6343177c6f4dda71016976b17389e75ded7a2fcb
Author: Bill Erickson <berickxx at gmail.com>
Date:   Thu Dec 12 10:35:06 2019 -0500

    LP1852782 FF context menu repairs; Angular fixes
    
    Fix regression in context menu generation for fixed fields.
    
    Migrate some ViewChild's from static=true to static=false.
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    
    Signed-off-by: Jane Sandberg <sandbej at linnbenton.edu>

diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts
index 312f7b1b67..2eeadf16e4 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts
@@ -209,7 +209,7 @@ export class EditableContentComponent
     // These are served dynamically to handle cases where a tag or
     // subfield is modified in place.
     contextMenuEntries(): ContextMenuEntry[] {
-        if (!this.field) { return; }
+        if (this.isLeader) { return; }
 
         switch (this.fieldType) {
             case 'tag':
@@ -249,6 +249,7 @@ export class EditableContentComponent
         );
 
         if (!this.field.isCtrlField) {
+            // Only data field tags get these.
             this.tagMenuEntries.push(
                 {label: this.insertAfterStr.text,  value: '_insertAfter'},
                 {label: this.insertBeforeStr.text, value: '_insertBefore'}
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.ts
index 02c9b459cd..841ca075fe 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.ts
@@ -70,12 +70,12 @@ export class MarcEditorComponent implements OnInit {
     // the record is successfully saved.
     @Output() recordSaved: EventEmitter<MarcSavedEvent>;
 
-    @ViewChild('sourceSelector', { static: true }) sourceSelector: ComboboxComponent;
-    @ViewChild('confirmDelete', { static: true }) confirmDelete: ConfirmDialogComponent;
-    @ViewChild('confirmUndelete', { static: true }) confirmUndelete: ConfirmDialogComponent;
-    @ViewChild('cannotDelete', { static: true }) cannotDelete: ConfirmDialogComponent;
-    @ViewChild('successMsg', { static: true }) successMsg: StringComponent;
-    @ViewChild('failMsg', { static: true }) failMsg: StringComponent;
+    @ViewChild('sourceSelector', {static: false}) sourceSelector: ComboboxComponent;
+    @ViewChild('confirmDelete', {static: false}) confirmDelete: ConfirmDialogComponent;
+    @ViewChild('confirmUndelete', {static: false}) confirmUndelete: ConfirmDialogComponent;
+    @ViewChild('cannotDelete', {static: false}) cannotDelete: ConfirmDialogComponent;
+    @ViewChild('successMsg', {static: false}) successMsg: StringComponent;
+    @ViewChild('failMsg', {static: false}) failMsg: StringComponent;
 
     constructor(
         private evt: EventService,
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.html b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.html
index 506fc56a4d..a7ca33f082 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.html
@@ -100,8 +100,7 @@
     <!-- LEADER -->
     <div class="row pt-0 pb-0 pl-3">
       <eg-marc-editable-content
-        [context]="context" fieldType="tag"
-        fieldText="LDR" i18n-fieldText moreClasses="p-1">
+        [context]="context" fieldText="LDR" i18n-fieldText moreClasses="p-1">
       </eg-marc-editable-content>
 
       <eg-marc-editable-content

commit 95331c22c6c0a7557404dd0185ead17e120759ba
Author: Bill Erickson <berickxx at gmail.com>
Date:   Wed Dec 11 17:03:19 2019 -0500

    LP1852782 Progress indicator while saving MARC records
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    
    Signed-off-by: Jane Sandberg <sandbej at linnbenton.edu>

diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.html b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.html
index dea85817e1..7daa2c89b2 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.html
@@ -45,7 +45,16 @@
     [disabled]="record && record.deleted" i18n>Save Changes</button>
 </div>
 
-<div class="row">
+
+<ng-container *ngIf="dataSaving">
+  <div class="row mt-5">
+    <div class="offset-lg-3 col-lg-6">
+      <eg-progress-inline></eg-progress-inline>
+    </div>
+  </div>
+</ng-container>
+
+<div *ngIf="!dataSaving" class="row">
   <div class="col-lg-12">
     <ngb-tabset [activeId]="editorTab" (tabChange)="tabChange($event)">
       <ngb-tab title="Enhanced MARC Editor" i18n-title id="rich">
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.ts
index 1ecfb9e6e6..02c9b459cd 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.ts
@@ -35,6 +35,9 @@ export class MarcEditorComponent implements OnInit {
     sources: ComboboxEntry[];
     context: MarcEditContext;
 
+    // True if the save request is in flight
+    dataSaving: boolean;
+
     @Input() recordType: 'biblio' | 'authority' = 'biblio';
 
     @Input() set recordId(id: number) {
@@ -87,6 +90,8 @@ export class MarcEditorComponent implements OnInit {
         this.sources = [];
         this.recordSaved = new EventEmitter<MarcSavedEvent>();
         this.context = new MarcEditContext();
+
+        this.recordSaved.subscribe(_ => this.dataSaving = false);
     }
 
     ngOnInit() {
@@ -135,6 +140,7 @@ export class MarcEditorComponent implements OnInit {
 
     saveRecord(): Promise<any> {
         const xml = this.record.toXml();
+        this.dataSaving = true;
 
         // Save actions clears any pending changes.
         this.context.changesPending = false;
@@ -166,6 +172,7 @@ export class MarcEditorComponent implements OnInit {
                 if (evt) {
                     console.error(evt);
                     this.failMsg.current().then(msg => this.toast.warning(msg));
+                    this.dataSaving = false;
                     return;
                 }
 

commit 393373cc1509d8e4b0056b42c2ab9397571072fe
Author: Bill Erickson <berickxx at gmail.com>
Date:   Wed Dec 11 16:44:29 2019 -0500

    LP1852782 Tag menu static additions
    
    Adds support for add 006/007/008, delete fields and optionally add new
    field before and after actions to the context menus displayed for
    control field and data field tags.
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    
    Signed-off-by: Jane Sandberg <sandbej at linnbenton.edu>

diff --git a/Open-ILS/src/eg2/src/app/share/context-menu/context-menu-container.component.html b/Open-ILS/src/eg2/src/app/share/context-menu/context-menu-container.component.html
index 9a302883f7..b25f2cf356 100644
--- a/Open-ILS/src/eg2/src/app/share/context-menu/context-menu-container.component.html
+++ b/Open-ILS/src/eg2/src/app/share/context-menu/context-menu-container.component.html
@@ -1,10 +1,15 @@
 
 <ng-template #menuTemplate>
-  <!-- apply (click) to div so user can click anywhere in the row -->
-  <div *ngFor="let entry of menuEntries; first as isFirst" 
-    class="menu-entry {{entryClasses}}">
-    <button (click)="entryClicked(entry)" class="btn p-0 m-0">
-      {{entry.label}}
-    </button>
+
+  <div *ngFor="let entry of menuEntries" class="menu-entry {{entryClasses}}">
+
+    <div *ngIf="entry.divider" class="dropdown-divider"></div>
+
+    <ng-container *ngIf="!entry.divider">
+      <button (click)="entryClicked(entry)" class="btn p-0 m-0">
+        {{entry.label}}
+      </button>
+    </ng-container>
+
   </div>
 </ng-template>
diff --git a/Open-ILS/src/eg2/src/app/share/context-menu/context-menu.service.ts b/Open-ILS/src/eg2/src/app/share/context-menu/context-menu.service.ts
index 838274b388..cd9470015b 100644
--- a/Open-ILS/src/eg2/src/app/share/context-menu/context-menu.service.ts
+++ b/Open-ILS/src/eg2/src/app/share/context-menu/context-menu.service.ts
@@ -5,8 +5,9 @@ import {tap} from 'rxjs/operators';
  * template container component */
 
 export interface ContextMenuEntry {
-    value: string;
-    label: string;
+    value?: string;
+    label?: string;
+    divider?: boolean;
 }
 
 export class ContextMenu {
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.html b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.html
index 5dd8c2996c..07b67767a6 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.html
@@ -1,4 +1,15 @@
 
+<!-- 
+Some context menus have additional static options.  
+Track their labels here.
+-->
+<eg-string #add006 text="Add 006" i18n-text></eg-string>
+<eg-string #add007 text="Add 007" i18n-text></eg-string>
+<eg-string #add008 text="Add/Replace 008" i18n-text></eg-string>
+<eg-string #insertBefore text="Insert Field Before" i18n-text></eg-string>
+<eg-string #insertAfter text="Insert Field After" i18n-text></eg-string>
+<eg-string #deleteField text="Delete Field" i18n-text></eg-string>
+
 <ng-container *ngIf="bigText">
   <div contenteditable
     id='{{randId}}' 
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts
index 03acd76b18..312f7b1b67 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts
@@ -1,5 +1,5 @@
 import {ElementRef, Component, Input, Output, OnInit, OnDestroy,
-    EventEmitter, AfterViewInit, Renderer2} from '@angular/core';
+    ViewChild, EventEmitter, AfterViewInit, Renderer2} from '@angular/core';
 import {Subscription} from 'rxjs';
 import {filter} from 'rxjs/operators';
 import {MarcRecord, MarcField, MarcSubfield} from './marcrecord';
@@ -7,6 +7,7 @@ import {MarcEditContext, FieldFocusRequest, MARC_EDITABLE_FIELD_TYPE,
     TextUndoRedoAction} from './editor-context';
 import {ContextMenuEntry} from '@eg/share/context-menu/context-menu.service';
 import {TagTableService} from './tagtable.service';
+import {StringComponent} from '@eg/share/string/string.component';
 
 /**
  * MARC Editable Content Component
@@ -57,11 +58,21 @@ export class EditableContentComponent
     // Cache of fixed field menu options
     ffValues: ContextMenuEntry[] = [];
 
+    // Cache of tag context menu entries
+    tagMenuEntries: ContextMenuEntry[] = [];
+
     // Track the fixed field value locally since extracting the value
     // in real time from the record, which adds padding to the text,
     // causes usability problems.
     ffValue: string;
 
+    @ViewChild('add006', {static: false}) add006Str: StringComponent;
+    @ViewChild('add007', {static: false}) add007Str: StringComponent;
+    @ViewChild('add008', {static: false}) add008Str: StringComponent;
+    @ViewChild('insertBefore', {static: false}) insertBeforeStr: StringComponent;
+    @ViewChild('insertAfter', {static: false}) insertAfterStr: StringComponent;
+    @ViewChild('deleteField', {static: false}) deleteFieldStr: StringComponent;
+
     constructor(
         private renderer: Renderer2,
         private tagTable: TagTableService) {}
@@ -198,11 +209,11 @@ export class EditableContentComponent
     // These are served dynamically to handle cases where a tag or
     // subfield is modified in place.
     contextMenuEntries(): ContextMenuEntry[] {
-        if (this.isLeader) { return; }
+        if (!this.field) { return; }
 
         switch (this.fieldType) {
             case 'tag':
-                return this.tagTable.getFieldTags();
+                return this.tagContextMenuEntries();
 
             case 'sfc':
                 return this.tagTable.getSubfieldCodes(this.field.tag);
@@ -224,6 +235,36 @@ export class EditableContentComponent
         return null;
     }
 
+    tagContextMenuEntries(): ContextMenuEntry[] {
+
+        // string components may not yet be loaded.
+        if (this.tagMenuEntries.length > 0 || !this.add006Str) {
+            return this.tagMenuEntries;
+        }
+
+        this.tagMenuEntries.push(
+            {label: this.add006Str.text, value: '_add006'},
+            {label: this.add007Str.text, value: '_add007'},
+            {label: this.add008Str.text, value: '_add008'}
+        );
+
+        if (!this.field.isCtrlField) {
+            this.tagMenuEntries.push(
+                {label: this.insertAfterStr.text,  value: '_insertAfter'},
+                {label: this.insertBeforeStr.text, value: '_insertBefore'}
+            );
+        }
+
+        this.tagMenuEntries.push(
+            {label: this.deleteFieldStr.text,  value: '_deleteField'},
+            {divider: true}
+        );
+
+        this.tagTable.getFieldTags().forEach(e => this.tagMenuEntries.push(e));
+
+        return this.tagMenuEntries;
+    }
+
     getContent(): string {
         if (this.fieldText) { return this.fieldText; } // read-only
 
@@ -538,6 +579,18 @@ export class EditableContentComponent
     }
 
     contextMenuChange(value: string) {
+
+        switch (value) {
+            case '_add006': return this.context.add00X('006');
+            case '_add007': return this.context.add00X('007');
+            case '_add008': return this.context.insertReplace008();
+            case '_insertBefore':
+                return this.context.insertStubField(this.field, true);
+            case '_insertAfter':
+                return this.context.insertStubField(this.field);
+            case '_deleteField': return this.context.deleteField(this.field);
+        }
+
         this.setContent(value, true);
 
         // Context menus can steal focus.

commit dfe56547d235d65298ab4e4322621a1afba28f30
Author: Bill Erickson <berickxx at gmail.com>
Date:   Wed Dec 11 15:28:04 2019 -0500

    LP1852782 Prevents data fields swapping to control fields
    
    Prevent an existing data field from swapping to a control field while
    editing the tag.  This way if a tag is cleared the field won't jump from
    the data fields section up to the control fields section mid-edit.
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    
    Signed-off-by: Jane Sandberg <sandbej at linnbenton.edu>

diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marcrecord.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marcrecord.ts
index 69e095518d..cdc99aac4b 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marcrecord.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marcrecord.ts
@@ -19,6 +19,11 @@ export interface MarcField {
     ind2?: string;
     subfields?: MarcSubfield[];
 
+    // Fields are immutable when it comes to controlfield vs.
+    // data field.  Stamp the value when stamping field IDs.
+    isCtrlField: boolean;
+
+    // Pass-through to marcrecord.js
     isControlfield(): boolean;
 
     deleteExactSubfields(...subfield: MarcSubfield[]): number;
@@ -89,10 +94,15 @@ export class MarcRecord {
         this.fields.forEach(f => this.stampFieldId(f));
     }
 
+    // Stamp field IDs the the initial isCtrlField state.
     stampFieldId(field: MarcField) {
         if (!field.fieldId) {
             field.fieldId = Math.floor(Math.random() * 10000000);
         }
+
+        if (field.isCtrlField === undefined) {
+            field.isCtrlField = field.isControlfield();
+        }
     }
 
     field(spec: string, wantArray?: boolean): MarcField | MarcField[] {
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.ts
index 6b79fe2415..a1a7d552eb 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.ts
@@ -93,11 +93,11 @@ export class MarcRichEditorComponent implements OnInit {
     }
 
     controlFields(): MarcField[] {
-        return this.record.fields.filter(f => f.isControlfield());
+        return this.record.fields.filter(f => f.isCtrlField);
     }
 
     dataFields(): MarcField[] {
-        return this.record.fields.filter(f => !f.isControlfield());
+        return this.record.fields.filter(f => !f.isCtrlField);
     }
 }
 

commit f9fd5df8f4f83e043792c7934c249fbc4ef0d0a0
Author: Bill Erickson <berickxx at gmail.com>
Date:   Wed Dec 11 14:48:40 2019 -0500

    LP1852782 MARC editor subfield stacking support
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    
    Signed-off-by: Jane Sandberg <sandbej at linnbenton.edu>

diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts
index f1afe30922..03acd76b18 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts
@@ -177,6 +177,11 @@ export class EditableContentComponent
                 this.watchForFocusRequests();
                 this.watchForUndoRedoRequests();
                 break;
+
+            default:
+                if (this.fieldText) {
+                    this.maxLength = this.fieldText.length;
+                }
         }
     }
 
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.html b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.html
index 16d164933d..506fc56a4d 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.html
@@ -7,6 +7,32 @@
   </div>
 </ng-container>
 
+
+<ng-template #subfieldChunk let-field="field" let-subfield="subfield">
+
+  <!-- move these around depending on whether we are stacking subfields -->
+
+  <!-- SUBFIELD DECORATOR/DELIMITER -->
+  <eg-marc-editable-content fieldText="‡" i18n-fieldText
+    moreClasses="sf-delimiter border-right-0 bg-transparent p-1 pr-0">
+  </eg-marc-editable-content>
+
+  <!-- SUBFIELD CHARACTER -->
+  <eg-marc-editable-content
+    [context]="context" [field]="field" fieldType="sfc"
+    [subfield]="subfield" ariaLabel="Subfield Code" i18n-ariaLabel
+    moreClasses="sf-code border-left-0 p-1 pl-0">
+  </eg-marc-editable-content>
+
+  <!-- SUBFIELD VALUE -->
+  <eg-marc-editable-content
+    [context]="context" [field]="field" fieldType="sfv"
+    [subfield]="subfield" ariaLabel="Subfield Value" i18n-ariaLabel
+    moreClasses="p-1 pt-2">
+  </eg-marc-editable-content>
+
+</ng-template>
+
 <ng-container *ngIf="dataLoaded">
   <div class="mt-3 text-monospace"
     (contextmenu)="$event.preventDefault()">
@@ -33,7 +59,7 @@
         <div class="mt-2">
           <div class="form-check">
             <input class="form-check-input" type="checkbox" 
-              [disabled]="true"
+              (change)="stackSubfieldsChange()"
               [(ngModel)]="stackSubfields" id="stack-subfields-{{randId}}">
             <label class="form-check-label" for="stack-subfields-{{randId}}">
               Stack Subfields
@@ -72,7 +98,7 @@
     </div>
 
     <!-- LEADER -->
-    <div class="row pt-0 pb-0 pl-3 form-horizontal">
+    <div class="row pt-0 pb-0 pl-3">
       <eg-marc-editable-content
         [context]="context" fieldType="tag"
         fieldText="LDR" i18n-fieldText moreClasses="p-1">
@@ -85,7 +111,7 @@
     </div>
 
     <!-- CONTROL FIELDS -->
-    <div class="row pt-0 pb-0 pl-3 form-horizontal" 
+    <div class="row pt-0 pb-0 pl-3"
       *ngFor="let field of controlFields()">
 
       <eg-marc-editable-content
@@ -100,50 +126,51 @@
     </div>
 
     <!-- data fields -->
-    <div class="row pt-0 pb-0 pl-3 form-horizontal" 
-      *ngFor="let field of dataFields()">
-
-      <!-- TAG -->
-      <eg-marc-editable-content
-        [context]="context" [field]="field" fieldType="tag"
-        ariaLabel="Data Field Tag" i18n-ariaLabel moreClasses="p-1">
-      </eg-marc-editable-content>
-
-      <!-- INDICATOR 1 -->
-      <eg-marc-editable-content
-        [context]="context" [field]="field" fieldType="ind1"
-        ariaLabel="Data Field Indicator 1" i18n-ariaLabel moreClasses="p-1">
-      </eg-marc-editable-content>
-
-      <!-- INDICATOR 2 -->
-      <eg-marc-editable-content
-        [context]="context" [field]="field" fieldType="ind2"
-        ariaLabel="Data Field Indicator 2" i18n-ariaLabel moreClasses="p-1">
-      </eg-marc-editable-content>
+    <ng-container *ngFor="let field of dataFields()">
 
-      <!-- SUBFIELDS -->
-      <ng-container *ngFor="let subfield of field.subfields">
+      <div class="row pt-0 pb-0 pl-3">
 
-        <!-- SUBFIELD DECORATOR/DELIMITER -->
-        <eg-marc-editable-content fieldText="‡" i18n-fieldText
-          moreClasses="sf-delimiter border-right-0 bg-transparent p-1 pr-0">
+        <!-- TAG -->
+        <eg-marc-editable-content
+          [context]="context" [field]="field" fieldType="tag"
+          ariaLabel="Data Field Tag" i18n-ariaLabel moreClasses="p-1">
         </eg-marc-editable-content>
 
-        <!-- SUBFIELD CHARACTER -->
+        <!-- INDICATOR 1 -->
         <eg-marc-editable-content
-          [context]="context" [field]="field" fieldType="sfc"
-          [subfield]="subfield" ariaLabel="Subfield Code" i18n-ariaLabel
-          moreClasses="sf-code border-left-0 p-1 pl-0">
+          [context]="context" [field]="field" fieldType="ind1"
+          ariaLabel="Data Field Indicator 1" i18n-ariaLabel moreClasses="p-1">
         </eg-marc-editable-content>
 
-        <!-- SUBFIELD VALUE -->
+        <!-- INDICATOR 2 -->
         <eg-marc-editable-content
-          [context]="context" [field]="field" fieldType="sfv"
-          [subfield]="subfield" ariaLabel="Subfield Value" i18n-ariaLabel
-          moreClasses="p-1 pt-2">
+          [context]="context" [field]="field" fieldType="ind2"
+          ariaLabel="Data Field Indicator 2" i18n-ariaLabel moreClasses="p-1">
         </eg-marc-editable-content>
+
+        <!-- when not stacking subfields, render them inline -->
+        <ng-container *ngIf="!stackSubfields">
+          <ng-container *ngFor="let subfield of field.subfields">
+            <ng-container 
+              *ngTemplateOutlet="subfieldChunk;context:{field:field,subfield:subfield}">
+            </ng-container>
+          </ng-container>
+        </ng-container>
+      </div>
+
+      <!-- when stacking subfields, each subfield gets its own row 
+        preceeded by a placeholder for the tag as a way to 'tab' right -->
+      <ng-container *ngIf="stackSubfields">
+        <div class="form-inline" *ngFor="let subfield of field.subfields">
+          <eg-marc-editable-content fieldText="   " moreClasses="p-1 invisible">
+          </eg-marc-editable-content>
+          <ng-container 
+            *ngTemplateOutlet="subfieldChunk;context:{field:field,subfield:subfield}">
+          </ng-container>
+        </div>
       </ng-container>
-    </div>
+    </ng-container>
+
   </div>
 </ng-container>
 
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.ts
index 337756a79f..6b79fe2415 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.ts
@@ -3,6 +3,7 @@ import {Component, Input, Output, OnInit, AfterViewInit, EventEmitter,
 import {filter} from 'rxjs/operators';
 import {IdlService} from '@eg/core/idl.service';
 import {OrgService} from '@eg/core/org.service';
+import {ServerStoreService} from '@eg/core/server-store.service';
 import {TagTableService} from './tagtable.service';
 import {MarcRecord, MarcField} from './marcrecord';
 import {MarcEditContext} from './editor-context';
@@ -31,10 +32,15 @@ export class MarcRichEditorComponent implements OnInit {
     constructor(
         private idl: IdlService,
         private org: OrgService,
+        private store: ServerStoreService,
         private tagTable: TagTableService
     ) {}
 
     ngOnInit() {
+
+        this.store.getItem('cat.marcedit.stack_subfields')
+        .then(stack => this.stackSubfields = stack);
+
         this.init().then(_ =>
             this.context.recordChange.subscribe(__ => this.init()));
 
@@ -62,6 +68,14 @@ export class MarcRichEditorComponent implements OnInit {
         );
     }
 
+    stackSubfieldsChange() {
+        if (this.stackSubfields) {
+            this.store.setItem('cat.marcedit.stack_subfields', true);
+        } else {
+            this.store.removeItem('cat.marcedit.stack_subfields');
+        }
+    }
+
     undoCount(): number {
         return this.context.undoStack.length;
     }

commit 4162057c4e291163a9cda692f63f0777b21fc3d1
Author: Bill Erickson <berickxx at gmail.com>
Date:   Wed Dec 11 12:31:15 2019 -0500

    LP1852782 Context menu nagivation and FF repairs
    
    Allow keyboard navigation of context menus by changing the action links
    to buttons.  Teach the menu to close itself once an action has been
    selected to cover cases where the popover does not close itself,
    specifically on keyboard Enter to select.
    
    Teach the editor to reload the tagtable data when the record type has
    changed and refresh all of its child component, since a Type change
    impacts all of the tagtable options.
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    
    Signed-off-by: Jane Sandberg <sandbej at linnbenton.edu>

diff --git a/Open-ILS/src/eg2/src/app/share/context-menu/context-menu-container.component.html b/Open-ILS/src/eg2/src/app/share/context-menu/context-menu-container.component.html
index 0d6c0a0ede..9a302883f7 100644
--- a/Open-ILS/src/eg2/src/app/share/context-menu/context-menu-container.component.html
+++ b/Open-ILS/src/eg2/src/app/share/context-menu/context-menu-container.component.html
@@ -2,7 +2,9 @@
 <ng-template #menuTemplate>
   <!-- apply (click) to div so user can click anywhere in the row -->
   <div *ngFor="let entry of menuEntries; first as isFirst" 
-   (click)="entryClicked(entry)" class="menu-entry {{entryClasses}}">
-    <a>{{entry.label}}</a>
+    class="menu-entry {{entryClasses}}">
+    <button (click)="entryClicked(entry)" class="btn p-0 m-0">
+      {{entry.label}}
+    </button>
   </div>
 </ng-template>
diff --git a/Open-ILS/src/eg2/src/app/share/context-menu/context-menu.directive.ts b/Open-ILS/src/eg2/src/app/share/context-menu/context-menu.directive.ts
index 14de769c52..2b22c4cfb2 100644
--- a/Open-ILS/src/eg2/src/app/share/context-menu/context-menu.directive.ts
+++ b/Open-ILS/src/eg2/src/app/share/context-menu/context-menu.directive.ts
@@ -52,21 +52,38 @@ export class ContextMenuDirective extends NgbPopover {
             // Only broadcast entry selection to my listeners if I'm
             // hosting the menu where the selection occurred.
 
-            if (this.menu && this.menu.id === this.menuService.activeMenu.id) {
+            if (this.activeMenuIsMe()) {
                 this.menuItemSelected.emit(entry);
+
+                // Item selection via keyboard fails to close the menu.
+                // Force it closed.
+                this.cleanup();
             }
         });
     }
 
-    open() {
+    activeMenuIsMe(): boolean {
+        return (
+            this.menu &&
+            this.menuService.activeMenu &&
+            this.menu.id === this.menuService.activeMenu.id
+        );
+    }
 
-        // In certain scenarios (e.g. right-clicking on another context
-        // menu) an open popover will stay open.  Force it closed here.
+    // Close the active menu
+    cleanup() {
         if (ContextMenuDirective.activeDirective) {
             ContextMenuDirective.activeDirective.close();
             ContextMenuDirective.activeDirective = null;
             this.menuService.activeMenu = null;
         }
+    }
+
+    open() {
+
+        // In certain scenarios (e.g. right-clicking on another context
+        // menu) an open popover will stay open.  Force it closed here.
+        this.cleanup();
 
         if (!this.menuEntries ||
              this.menuEntries.length === 0) {
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts
index c6c1ebc498..f1afe30922 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts
@@ -188,9 +188,6 @@ export class EditableContentComponent
                 this.maxLength = fieldMeta.length || 1;
             }
         });
-
-        // Fixed field options change when the record type changes.
-        this.context.recordChange.subscribe(_ => this.applyFFOptions());
     }
 
     // These are served dynamically to handle cases where a tag or
@@ -537,6 +534,9 @@ export class EditableContentComponent
 
     contextMenuChange(value: string) {
         this.setContent(value, true);
+
+        // Context menus can steal focus.
+        this.context.requestFieldFocus(this.context.lastFocused);
     }
 }
 
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.ts
index 1c50c57c4f..337756a79f 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.ts
@@ -1,5 +1,6 @@
 import {Component, Input, Output, OnInit, AfterViewInit, EventEmitter,
     OnDestroy} from '@angular/core';
+import {filter} from 'rxjs/operators';
 import {IdlService} from '@eg/core/idl.service';
 import {OrgService} from '@eg/core/org.service';
 import {TagTableService} from './tagtable.service';
@@ -36,6 +37,10 @@ export class MarcRichEditorComponent implements OnInit {
     ngOnInit() {
         this.init().then(_ =>
             this.context.recordChange.subscribe(__ => this.init()));
+
+        // Changing the Type fixed field means loading new meta-metadata.
+        this.record.fixedFieldChange.pipe(filter(code => code === 'Type'))
+        .subscribe(_ => this.init());
     }
 
     init(): Promise<any> {
@@ -47,7 +52,14 @@ export class MarcRichEditorComponent implements OnInit {
             this.tagTable.loadTagTable({marcRecordType: this.context.recordType}),
             this.tagTable.getFfPosTable(this.record.recordType()),
             this.tagTable.getFfValueTable(this.record.recordType())
-        ]).then(_ => this.dataLoaded = true);
+        ]).then(_ =>
+            // setTimeout forces all of our sub-components to rerender
+            // themselves each time init() is called.  Without this,
+            // changing the record Type would only re-render the fixed
+            // fields editor when data had to be fetched from the
+            // network.  (Sometimes the data is cached).
+            setTimeout(() => this.dataLoaded = true)
+        );
     }
 
     undoCount(): number {
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/tagtable.service.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/tagtable.service.ts
index 13cbd2c0e8..e3571b1322 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/tagtable.service.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/tagtable.service.ts
@@ -135,7 +135,8 @@ export class TagTableService {
             selector = defaultTagTableSelector;
         }
 
-        const cacheKey = 'FFValueTable_' + selector.marcRecordType;
+        const cacheKey =
+            `current_tag_table_${selector.marcFormat}_${selector.marcRecordType}`;
 
         this.tagMap = this.store.getLocalItem(cacheKey);
 

commit 992402e003347e4e06e53a13b0801677b858eb1b
Author: Bill Erickson <berickxx at gmail.com>
Date:   Wed Dec 11 10:46:04 2019 -0500

    LP1852782 MARC editable content aria-labels
    
    Label fixed fields by their respective labels.  Label tags, indicators,
    subfield codes, and values with generic terms indicating their purpose.
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    
    Signed-off-by: Jane Sandberg <sandbej at linnbenton.edu>

diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.html b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.html
index 359128cee8..5dd8c2996c 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.html
@@ -5,6 +5,7 @@
     spellcheck="false"
     class="d-inline-block text-dark text-break {{moreClasses}}"
     [attr.tabindex]="fieldText ? -1 : ''"
+    [attr.aria-label]="ariaLabel"
     [egContextMenu]="contextMenuEntries()"
     (menuItemSelected)="contextMenuChange($event.value)"
     (keydown)="inputKeyDown($event)"
@@ -23,6 +24,7 @@
     [maxlength]="maxLength || ''"
     [disabled]="fieldText" 
     [attr.tabindex]="fieldText ? -1 : ''"
+    [attr.aria-label]="ariaLabel"
     [egContextMenu]="contextMenuEntries()"
     (menuItemSelected)="contextMenuChange($event.value)"
     (keydown)="inputKeyDown($event)"
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts
index c4d848dda0..c6c1ebc498 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts
@@ -36,6 +36,9 @@ export class EditableContentComponent
     // space-separated list of additional CSS classes to append
     @Input() moreClasses: string;
 
+    // aria-label text.  This will not be visible in the UI.
+    @Input() ariaLabel: string;
+
     get record(): MarcRecord { return this.context.record; }
 
     bigText = false;
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-field.component.html b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-field.component.html
index e2e8976197..fa11fef526 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-field.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-field.component.html
@@ -3,12 +3,13 @@
 
   <div class="d-flex">
     <div class="flex-4">
-      <span id='label-{{randId}}' class="text-left font-weight-bold">
+      <span id="label-{{randId}}" class="text-left font-weight-bold">
         {{fieldLabel}}
       </span>
     </div>
       <div class="flex-5">
-        <eg-marc-editable-content [context]="context"
+        <eg-marc-editable-content
+          [context]="context" [ariaLabel]="fieldLabel"
           [fixedFieldCode]="fieldCode" fieldType="ffld" moreClasses="p-1">
         </eg-marc-editable-content>
       </div>
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.html b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.html
index cf214424d6..16d164933d 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.html
@@ -73,12 +73,14 @@
 
     <!-- LEADER -->
     <div class="row pt-0 pb-0 pl-3 form-horizontal">
-      <eg-marc-editable-content [context]="context" fieldType="tag" 
+      <eg-marc-editable-content
+        [context]="context" fieldType="tag"
         fieldText="LDR" i18n-fieldText moreClasses="p-1">
       </eg-marc-editable-content>
 
-      <eg-marc-editable-content [context]="context" fieldType="ldr"
-         moreClasses="p-1 pr-2">
+      <eg-marc-editable-content
+        [context]="context" fieldType="ldr"
+        ariaLabel="Leader" i18n-ariaLabel moreClasses="p-1 pr-2">
       </eg-marc-editable-content>
     </div>
 
@@ -86,12 +88,14 @@
     <div class="row pt-0 pb-0 pl-3 form-horizontal" 
       *ngFor="let field of controlFields()">
 
-      <eg-marc-editable-content [context]="context" fieldType="tag"
-        [field]="field" moreClasses="p-1">
+      <eg-marc-editable-content
+        [context]="context" [field]="field" fieldType="tag"
+        ariaLabel="Control Field Tag" i18n-ariaLabel moreClasses="p-1">
       </eg-marc-editable-content>
 
-      <eg-marc-editable-content [context]="context" fieldType="cfld"
-        [field]="field" moreClasses="p-1">
+      <eg-marc-editable-content
+        [context]="context" [field]="field" fieldType="cfld"
+        ariaLabel="Control Field Content" i18n-ariaLabel moreClasses="p-1">
       </eg-marc-editable-content>
     </div>
 
@@ -100,18 +104,21 @@
       *ngFor="let field of dataFields()">
 
       <!-- TAG -->
-      <eg-marc-editable-content [context]="context" fieldType="tag"
-        [field]="field" moreClasses="p-1">
+      <eg-marc-editable-content
+        [context]="context" [field]="field" fieldType="tag"
+        ariaLabel="Data Field Tag" i18n-ariaLabel moreClasses="p-1">
       </eg-marc-editable-content>
 
       <!-- INDICATOR 1 -->
-      <eg-marc-editable-content [context]="context" fieldType="ind1" 
-        [field]="field" moreClasses="p-1">
+      <eg-marc-editable-content
+        [context]="context" [field]="field" fieldType="ind1"
+        ariaLabel="Data Field Indicator 1" i18n-ariaLabel moreClasses="p-1">
       </eg-marc-editable-content>
 
       <!-- INDICATOR 2 -->
-      <eg-marc-editable-content [context]="context" fieldType="ind2" 
-        [field]="field" moreClasses="p-1">
+      <eg-marc-editable-content
+        [context]="context" [field]="field" fieldType="ind2"
+        ariaLabel="Data Field Indicator 2" i18n-ariaLabel moreClasses="p-1">
       </eg-marc-editable-content>
 
       <!-- SUBFIELDS -->
@@ -123,14 +130,17 @@
         </eg-marc-editable-content>
 
         <!-- SUBFIELD CHARACTER -->
-        <eg-marc-editable-content [context]="context" fieldType="sfc" 
-          [field]="field" [subfield]="subfield" 
+        <eg-marc-editable-content
+          [context]="context" [field]="field" fieldType="sfc"
+          [subfield]="subfield" ariaLabel="Subfield Code" i18n-ariaLabel
           moreClasses="sf-code border-left-0 p-1 pl-0">
         </eg-marc-editable-content>
 
         <!-- SUBFIELD VALUE -->
-        <eg-marc-editable-content [context]="context" fieldType="sfv"
-          [field]="field" [subfield]="subfield" moreClasses="p-1 pt-2">
+        <eg-marc-editable-content
+          [context]="context" [field]="field" fieldType="sfv"
+          [subfield]="subfield" ariaLabel="Subfield Value" i18n-ariaLabel
+          moreClasses="p-1 pt-2">
         </eg-marc-editable-content>
       </ng-container>
     </div>

commit c83db17612f518ca8b6468aa9d1903dcf822ba2b
Author: Bill Erickson <berickxx at gmail.com>
Date:   Tue Dec 10 17:35:49 2019 -0500

    LP1852782 MARC editor prevent navigation with changes
    
    Show a confirmation dialog when the user attempts to navigate away from
    the MARC edit tab in the catalog if the MARC editor has pending changes.
    
    The dialog will be shown if the user attempts to change tabs or navigate
    away from the record detail page w/in Angular.
    
    If the user unloads / reloads the page, the stock browser onbeforeunload
    confirmation dialog will be displayed instead.
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    
    Signed-off-by: Jane Sandberg <sandbej at linnbenton.edu>

diff --git a/Open-ILS/src/eg2/src/app/share/util/can-deactivate.guard.ts b/Open-ILS/src/eg2/src/app/share/util/can-deactivate.guard.ts
new file mode 100644
index 0000000000..c0ddeffe86
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/share/util/can-deactivate.guard.ts
@@ -0,0 +1,33 @@
+import {Injectable} from '@angular/core';
+import {CanDeactivate} from '@angular/router';
+import {Observable} from 'rxjs';
+
+/**
+ * https://angular.io/guide/router#candeactivate-handling-unsaved-changes
+ *
+ * routing:
+ * {
+ *   path: 'record/:id/:tab',
+ *   component: MyComponent,
+ *   canDeactivate: [CanDeactivateGuard]
+ * }
+ *
+ * export class MyComponent {
+ *   canDeactivate() ... {
+ *      ...
+ *   }
+ * }
+ */
+
+export interface CanComponentDeactivate {
+    canDeactivate: () => Observable<boolean> | Promise<boolean> | boolean;
+}
+
+ at Injectable({providedIn: 'root'})
+export class CanDeactivateGuard
+    implements CanDeactivate<CanComponentDeactivate> {
+
+    canDeactivate(component: CanComponentDeactivate) {
+        return component.canDeactivate ? component.canDeactivate() : true;
+    }
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html
index 49ec2e55f5..51d81b330c 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html
@@ -4,6 +4,11 @@
   </eg-title>
 </ng-container>
 
+<eg-confirm-dialog #pendingChangesDialog
+  i18n-dialogTitle dialogTitle="Unsaved Changes Confirmation" 
+  i18n-dialogBoby  dialogBody="Unsaved changes will be lost.  Continue navigation?">
+</eg-confirm-dialog>
+
 <div id="staff-catalog-record-container">
   <div id='staff-catalog-bib-summary-container' class='mb-1'>
     <eg-bib-summary [bibSummary]="summary">
@@ -29,7 +34,8 @@
             (click)="setDefaultTab()" i18n>Set Default View</button>
       </div>
     </div>
-    <ngb-tabset #recordTabs [activeId]="recordTab" (tabChange)="onTabChange($event)">
+    <ngb-tabset #recordTabs [activeId]="recordTab" 
+      (tabChange)="beforeTabChange($event)">
       <ngb-tab title="Item Table" i18n-title id="catalog">
         <ng-template ngbTabContent>
           <eg-catalog-copies [recordId]="recordId"></eg-catalog-copies>
@@ -39,7 +45,7 @@
       <ngb-tab title="MARC Edit" i18n-title id="marc_edit">
         <ng-template ngbTabContent>
           <div class="mt-3">
-            <eg-marc-editor (recordSaved)="handleMarcRecordSaved()" 
+            <eg-marc-editor #marcEditor (recordSaved)="handleMarcRecordSaved()" 
               [recordId]="recordId"></eg-marc-editor>
           </div>
         </ng-template>
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.ts
index 83ce9b36c3..9c0ce9d762 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.ts
@@ -1,4 +1,4 @@
-import {Component, OnInit, Input, ViewChild} from '@angular/core';
+import {Component, OnInit, Input, ViewChild, HostListener} from '@angular/core';
 import {NgbTabset, NgbTabChangeEvent} from '@ng-bootstrap/ng-bootstrap';
 import {Router, ActivatedRoute, ParamMap} from '@angular/router';
 import {PcrudService} from '@eg/core/pcrud.service';
@@ -9,6 +9,8 @@ import {BibRecordService, BibRecordSummary} from '@eg/share/catalog/bib-record.s
 import {StaffCatalogService} from '../catalog.service';
 import {BibSummaryComponent} from '@eg/staff/share/bib-summary/bib-summary.component';
 import {StoreService} from '@eg/core/store.service';
+import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component';
+import {MarcEditorComponent} from '@eg/staff/share/marc-edit/editor.component';
 
 @Component({
   selector: 'eg-catalog-record',
@@ -21,8 +23,12 @@ export class RecordComponent implements OnInit {
     summary: BibRecordSummary;
     searchContext: CatalogSearchContext;
     @ViewChild('recordTabs', { static: true }) recordTabs: NgbTabset;
+    @ViewChild('marcEditor', {static: false}) marcEditor: MarcEditorComponent;
     defaultTab: string; // eg.cat.default_record_tab
 
+    @ViewChild('pendingChangesDialog', {static: false})
+        pendingChangesDialog: ConfirmDialogComponent;
+
     constructor(
         private router: Router,
         private route: ActivatedRoute,
@@ -66,13 +72,54 @@ export class RecordComponent implements OnInit {
 
     // Changing a tab in the UI means changing the route.
     // Changing the route ultimately results in changing the tab.
-    onTabChange(evt: NgbTabChangeEvent) {
-        this.recordTab = evt.nextId;
+    beforeTabChange(evt: NgbTabChangeEvent) {
 
         // prevent tab changing until after route navigation
         evt.preventDefault();
 
-        this.routeToTab();
+        // Protect against tab changes with dirty data.
+        this.canDeactivate().then(ok => {
+            if (ok) {
+                this.recordTab = evt.nextId;
+                this.routeToTab();
+            }
+        });
+    }
+
+    /*
+     * Handle 3 types of navigation which can cause loss of data.
+     * 1. Record detail tab navigation (see also beforeTabChange())
+     * 2. Intra-Angular route navigation away from the record detail page
+     * 3. Browser page unload/reload
+     *
+     * For the #1, and #2, display a eg confirmation dialog.
+     * For #3 use the stock browser onbeforeunload dialog.
+     *
+     * Note in this case a tab change is a route change, but it's one
+     * which does not cause RecordComponent to unload, so it has to be
+     * manually tracked in beforeTabChange().
+     */
+    @HostListener('window:beforeunload', ['$event'])
+    canDeactivate($event?: Event): Promise<boolean> {
+
+        if (this.marcEditor && this.marcEditor.changesPending()) {
+
+            // Each warning dialog clears the current "changes are pending"
+            // flag so the user is not presented with the dialog again
+            // unless new changes are made.
+            this.marcEditor.clearPendingChanges();
+
+            if ($event) { // window.onbeforeunload
+                $event.preventDefault();
+                $event.returnValue = true;
+
+            } else { // tab OR route change.
+                return this.pendingChangesDialog.open().toPromise();
+            }
+
+        } else {
+            return Promise.resolve(true);
+        }
     }
 
     routeToTab() {
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/routing.module.ts b/Open-ILS/src/eg2/src/app/staff/catalog/routing.module.ts
index e0da65f9d1..b70290583e 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/routing.module.ts
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/routing.module.ts
@@ -7,6 +7,7 @@ import {CatalogResolver} from './resolver.service';
 import {HoldComponent} from './hold/hold.component';
 import {BrowseComponent} from './browse.component';
 import {CnBrowseComponent} from './cnbrowse.component';
+import {CanDeactivateGuard} from '@eg/share/util/can-deactivate.guard';
 
 const routes: Routes = [{
   path: '',
@@ -23,7 +24,8 @@ const routes: Routes = [{
     component: HoldComponent
   }, {
     path: 'record/:id/:tab',
-    component: RecordComponent
+    component: RecordComponent,
+    canDeactivate: [CanDeactivateGuard]
   }]}, {
     // Browse is a top-level UI
     path: 'browse',
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts
index a926baa675..c4d848dda0 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts
@@ -304,7 +304,7 @@ export class EditableContentComponent
         undo.position = this.context.lastFocused;
         undo.textContent =  this.undoBackToText;
 
-        this.context.undoStack.unshift(undo);
+        this.context.addToUndoStack(undo);
     }
 
     // Apply the undo or redo action and track its opposite
@@ -436,10 +436,15 @@ export class EditableContentComponent
                     this.context.deleteField(this.field);
                     evt.preventDefault();
 
-                } else if (evt.shiftKey && this.subfield) {
-                    // shift+delete == delete subfield
+                } else if (evt.shiftKey) {
 
-                    this.context.deleteSubfield(this.field, this.subfield);
+                    if (this.subfield) {
+                        // shift+delete == delete subfield
+
+                        this.context.deleteSubfield(this.field, this.subfield);
+                    }
+                    // prevent any shift-delete from bubbling up becuase
+                    // unexpected stuff will be deleted.
                     evt.preventDefault();
                 }
 
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-context.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-context.ts
index 520ddbfbbe..ae7af497c4 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-context.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-context.ts
@@ -61,6 +61,11 @@ export class MarcEditContext {
     undoStack: UndoRedoAction[] = [];
     redoStack: UndoRedoAction[] = [];
 
+    // True if any changes have been made.
+    // For the 'rich' editor, this is any un-do-able actions.
+    // For the text edtior it's any text change.
+    changesPending: boolean;
+
     private _record: MarcRecord;
     set record(r: MarcRecord) {
         if (r !== this._record) {
@@ -117,6 +122,11 @@ export class MarcEditContext {
         }
     }
 
+    addToUndoStack(action: UndoRedoAction) {
+        this.changesPending = true;
+        this.undoStack.unshift(action);
+    }
+
     handleStructuralUndoRedo(action: StructUndoRedoAction) {
 
         if (action.wasAddition) {
@@ -208,7 +218,7 @@ export class MarcEditContext {
         // time of action will be different than the added field.
         action.prevFocus = this.lastFocused;
 
-        this.undoStack.unshift(action);
+        this.addToUndoStack(action);
     }
 
     deleteField(field: MarcField) {
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.ts
index 138161611d..1ecfb9e6e6 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.ts
@@ -111,6 +111,14 @@ export class MarcEditorComponent implements OnInit {
         );
     }
 
+    changesPending(): boolean {
+        return this.context.changesPending;
+    }
+
+    clearPendingChanges() {
+        this.context.changesPending = false;
+    }
+
     // Remember the last used tab as the preferred tab.
     tabChange(evt: NgbTabChangeEvent) {
 
@@ -128,6 +136,10 @@ export class MarcEditorComponent implements OnInit {
     saveRecord(): Promise<any> {
         const xml = this.record.toXml();
 
+        // Save actions clears any pending changes.
+        this.context.changesPending = false;
+        this.context.resetUndos();
+
         let sourceName: string = null;
         let sourceId: number = null;
 
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/flat-editor.component.html b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/flat-editor.component.html
index eaf54a92c1..0ef573f91a 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/flat-editor.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/flat-editor.component.html
@@ -1,6 +1,7 @@
 
 <div *ngIf="record">
   <textarea class="form-control flat-editor-content" 
+    (change)="textChanged()"
     (blur)="record.absorbBreakerChanges()"
     [(ngModel)]="record.breakerText" rows="{{rowCount()}}" spellcheck="false">
   </textarea>
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/flat-editor.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/flat-editor.component.ts
index 465a738eb2..86a64ac021 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/flat-editor.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/flat-editor.component.ts
@@ -42,6 +42,10 @@ export class MarcFlatEditorComponent implements OnInit {
         }
         return 40;
     }
+
+    textChanged() {
+        this.context.changesPending = true;
+    }
 }
 
 

commit 1ee7ef92ab92879b44f50d84ea41bab53882bbd1
Author: Bill Erickson <berickxx at gmail.com>
Date:   Tue Dec 10 12:00:20 2019 -0500

    LP1852782 MARC editor and related lint repairs
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    
    Signed-off-by: Jane Sandberg <sandbej at linnbenton.edu>

diff --git a/Open-ILS/src/eg2/src/app/share/context-menu/context-menu-container.component.ts b/Open-ILS/src/eg2/src/app/share/context-menu/context-menu-container.component.ts
index a5dfdcefbf..fc218c05f0 100644
--- a/Open-ILS/src/eg2/src/app/share/context-menu/context-menu-container.component.ts
+++ b/Open-ILS/src/eg2/src/app/share/context-menu/context-menu-container.component.ts
@@ -1,4 +1,4 @@
-import {Component, Input, Output, EventEmitter, OnInit, ViewChild, 
+import {Component, Input, Output, EventEmitter, OnInit, ViewChild,
     AfterViewInit, TemplateRef, ViewEncapsulation} from '@angular/core';
 import {ContextMenuService, ContextMenu, ContextMenuEntry} from './context-menu.service';
 
@@ -6,7 +6,7 @@ import {ContextMenuService, ContextMenu, ContextMenuEntry} from './context-menu.
   selector: 'eg-context-menu-container',
   templateUrl: './context-menu-container.component.html',
   styleUrls: ['context-menu-container.component.css'],
-  /* Our CSS affects the style of the popover, which may 
+  /* Our CSS affects the style of the popover, which may
    * be beyond our reach for standard view encapsulation */
   encapsulation: ViewEncapsulation.None
 })
@@ -19,11 +19,9 @@ export class ContextMenuContainerComponent implements OnInit, AfterViewInit {
     constructor(private menuService: ContextMenuService) {}
 
     ngOnInit() {
-
         this.menuService.showMenuRequest.subscribe(
             (menu: ContextMenu) => {
-
-            this.menuEntries = menu.entries
+            this.menuEntries = menu.entries;
         });
     }
 
diff --git a/Open-ILS/src/eg2/src/app/share/context-menu/context-menu.directive.ts b/Open-ILS/src/eg2/src/app/share/context-menu/context-menu.directive.ts
index 591d9d01f3..14de769c52 100644
--- a/Open-ILS/src/eg2/src/app/share/context-menu/context-menu.directive.ts
+++ b/Open-ILS/src/eg2/src/app/share/context-menu/context-menu.directive.ts
@@ -3,7 +3,7 @@ import {NgbPopover} from '@ng-bootstrap/ng-bootstrap';
 import {ContextMenuService, ContextMenu, ContextMenuEntry} from './context-menu.service';
 
 
-/* Import all of this stuff so we can pass it to our parent 
+/* Import all of this stuff so we can pass it to our parent
  * class via its constructor */
 import {
     Inject, Injector, Renderer2, ElementRef, TemplateRef, ViewContainerRef,
@@ -19,6 +19,10 @@ import {NgbPopoverConfig} from '@ng-bootstrap/ng-bootstrap';
 })
 export class ContextMenuDirective extends NgbPopover {
 
+    // Only one active menu is allowed at a time.
+    static activeDirective: ContextMenuDirective;
+    static menuId = 0;
+
     triggers = 'contextmenu';
     popoverClass = 'eg-context-menu';
 
@@ -31,10 +35,6 @@ export class ContextMenuDirective extends NgbPopover {
 
     @Output() menuItemSelected: EventEmitter<ContextMenuEntry>;
 
-    // Only one active menu is allowed at a time.
-    static activeDirective: ContextMenuDirective;
-    static menuId = 0;
-
     constructor(
         p1: ElementRef<HTMLElement>, p2: Renderer2, p3: Injector,
         p4: ComponentFactoryResolver, p5: ViewContainerRef, p6: NgbPopoverConfig,
@@ -49,7 +49,7 @@ export class ContextMenuDirective extends NgbPopover {
         this.menuService.menuItemSelected.subscribe(
             (entry: ContextMenuEntry) => {
 
-            // Only broadcast entry selection to my listeners if I'm 
+            // Only broadcast entry selection to my listeners if I'm
             // hosting the menu where the selection occurred.
 
             if (this.menu && this.menu.id === this.menuService.activeMenu.id) {
@@ -65,11 +65,11 @@ export class ContextMenuDirective extends NgbPopover {
         if (ContextMenuDirective.activeDirective) {
             ContextMenuDirective.activeDirective.close();
             ContextMenuDirective.activeDirective = null;
-            this.menuService.activeMenu == null;
+            this.menuService.activeMenu = null;
         }
 
         if (!this.menuEntries ||
-             this.menuEntries.length === 0) { 
+             this.menuEntries.length === 0) {
              return;
         }
 
diff --git a/Open-ILS/src/eg2/src/app/share/context-menu/context-menu.service.ts b/Open-ILS/src/eg2/src/app/share/context-menu/context-menu.service.ts
index bfedf4232f..838274b388 100644
--- a/Open-ILS/src/eg2/src/app/share/context-menu/context-menu.service.ts
+++ b/Open-ILS/src/eg2/src/app/share/context-menu/context-menu.service.ts
@@ -1,7 +1,7 @@
 import {Injectable, EventEmitter, TemplateRef} from '@angular/core';
 import {tap} from 'rxjs/operators';
 
-/* Relay requests to/from the context menu directive and its 
+/* Relay requests to/from the context menu directive and its
  * template container component */
 
 export interface ContextMenuEntry {
@@ -16,13 +16,13 @@ export class ContextMenu {
 
 @Injectable({providedIn: 'root'})
 export class ContextMenuService {
-    
+
     showMenuRequest: EventEmitter<ContextMenu>;
     menuItemSelected: EventEmitter<ContextMenuEntry>;
 
     menuTemplate: TemplateRef<any>;
     activeMenu: ContextMenu;
-    
+
     constructor() {
         this.showMenuRequest = new EventEmitter<ContextMenu>();
         this.menuItemSelected = new EventEmitter<ContextMenuEntry>();
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts
index 5f4b1ea8ab..a926baa675 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts
@@ -1,9 +1,9 @@
-import {ElementRef, Component, Input, Output, OnInit, OnDestroy, 
+import {ElementRef, Component, Input, Output, OnInit, OnDestroy,
     EventEmitter, AfterViewInit, Renderer2} from '@angular/core';
 import {Subscription} from 'rxjs';
 import {filter} from 'rxjs/operators';
 import {MarcRecord, MarcField, MarcSubfield} from './marcrecord';
-import {MarcEditContext, FieldFocusRequest, MARC_EDITABLE_FIELD_TYPE, 
+import {MarcEditContext, FieldFocusRequest, MARC_EDITABLE_FIELD_TYPE,
     TextUndoRedoAction} from './editor-context';
 import {ContextMenuEntry} from '@eg/share/context-menu/context-menu.service';
 import {TagTableService} from './tagtable.service';
@@ -18,7 +18,7 @@ import {TagTableService} from './tagtable.service';
   styleUrls: ['./editable-content.component.css']
 })
 
-export class EditableContentComponent 
+export class EditableContentComponent
     implements OnInit, AfterViewInit, OnDestroy {
 
     @Input() context: MarcEditContext;
@@ -43,7 +43,7 @@ export class EditableContentComponent
     editInput: any; // <input/> or <div contenteditable/>
     maxLength: number = null;
 
-    // Track the load-time content so we know what text value to 
+    // Track the load-time content so we know what text value to
     // track on our undo stack.
     undoBackToText: string;
 
@@ -91,12 +91,12 @@ export class EditableContentComponent
             if (req.fieldId !== this.field.fieldId) { return false; }
         } else if (req.target === 'ldr') {
             return this.isLeader;
-        } else if (req.target === 'ffld' && 
+        } else if (req.target === 'ffld' &&
             req.ffCode !== this.fixedFieldCode) {
             return false;
         }
 
-        if (req.sfOffset !== undefined && 
+        if (req.sfOffset !== undefined &&
             req.sfOffset !== this.subfield[2]) {
             // this is not the subfield you are looking for.
             return false;
@@ -113,7 +113,7 @@ export class EditableContentComponent
         }
 
         if (!req) {
-            // Focus request may have come from keyboard navigation, 
+            // Focus request may have come from keyboard navigation,
             // clicking, etc.  Model the event as a focus request
             // so it can be tracked the same.
             req = {
@@ -195,14 +195,14 @@ export class EditableContentComponent
     contextMenuEntries(): ContextMenuEntry[] {
         if (this.isLeader) { return; }
 
-        switch(this.fieldType) {
-            case 'tag': 
+        switch (this.fieldType) {
+            case 'tag':
                 return this.tagTable.getFieldTags();
 
-            case 'sfc': 
+            case 'sfc':
                 return this.tagTable.getSubfieldCodes(this.field.tag);
 
-            case 'sfv': 
+            case 'sfv':
                 return this.tagTable.getSubfieldValues(
                     this.field.tag, this.subfield[0]);
 
@@ -211,7 +211,7 @@ export class EditableContentComponent
                 return this.tagTable.getIndicatorValues(
                     this.field.tag, this.fieldType);
 
-            case 'ffld': 
+            case 'ffld':
                 return this.tagTable.getFfValues(
                     this.fixedFieldCode, this.record.recordType());
         }
@@ -223,7 +223,7 @@ export class EditableContentComponent
         if (this.fieldText) { return this.fieldText; } // read-only
 
         switch (this.fieldType) {
-            case 'ldr': return this.record.leader; 
+            case 'ldr': return this.record.leader;
             case 'cfld': return this.field.data;
             case 'tag': return this.field.tag;
             case 'sfc': return this.subfield[0];
@@ -231,7 +231,7 @@ export class EditableContentComponent
             case 'ind1': return this.field.ind1;
             case 'ind2': return this.field.ind2;
 
-            case 'ffld': 
+            case 'ffld':
                 // When actively editing a fixed field, track its value
                 // in a local variable instead of pulling the value
                 // from record.extractFixedField(), which applies
@@ -244,7 +244,7 @@ export class EditableContentComponent
                     !this.context.lastFocused ||
                     !this.focusRequestIsMe(this.context.lastFocused)) {
 
-                    this.ffValue = 
+                    this.ffValue =
                         this.record.extractFixedField(this.fixedFieldCode);
                 }
                 return this.ffValue;
@@ -264,7 +264,7 @@ export class EditableContentComponent
             case 'sfv': this.subfield[1] = value; break;
             case 'ind1': this.field.ind1 = value; break;
             case 'ind2': this.field.ind2 = value; break;
-            case 'ffld': 
+            case 'ffld':
                 // Track locally and propagate to the record.
                 this.ffValue = value;
                 this.record.setFixedField(this.fixedFieldCode, value);
@@ -316,7 +316,7 @@ export class EditableContentComponent
         this.setContent(action.textContent, true, true);
 
         action.textContent = recoverContent;
-        const moveTo = action.isRedo ? 
+        const moveTo = action.isRedo ?
             this.context.undoStack : this.context.redoStack;
 
         moveTo.unshift(action);
@@ -324,7 +324,7 @@ export class EditableContentComponent
 
     inputBlurred() {
         // If the text content changed during this focus session,
-        // track the new value as the value the next session of 
+        // track the new value as the value the next session of
         // text edits should return to upon undo.
         this.undoBackToText = this.getContent();
     }
@@ -370,22 +370,22 @@ export class EditableContentComponent
     // Route keydown events to the appropriate handler
     inputKeyDown(evt: KeyboardEvent) {
 
-        switch(evt.key) {
-            case 'y': 
+        switch (evt.key) {
+            case 'y':
                 if (evt.ctrlKey) { // redo
                     this.context.requestRedo();
                     evt.preventDefault();
                 }
                 return;
 
-            case 'z': 
+            case 'z':
                 if (evt.ctrlKey) { // undo
                     this.context.requestUndo();
                     evt.preventDefault();
                 }
                 return;
 
-            case 'F6': 
+            case 'F6':
                 if (evt.shiftKey) {
                     // shift+F6 => add 006
                     this.context.add00X('006');
@@ -394,7 +394,7 @@ export class EditableContentComponent
                 }
                 return;
 
-            case 'F7': 
+            case 'F7':
                 if (evt.shiftKey) {
                     // shift+F7 => add 007
                     this.context.add00X('007');
@@ -403,7 +403,7 @@ export class EditableContentComponent
                 }
                 return;
 
-            case 'F8': 
+            case 'F8':
                 if (evt.shiftKey) {
                     // shift+F8 => add/replace 008
                     this.context.insertReplace008();
@@ -419,7 +419,7 @@ export class EditableContentComponent
 
         switch (evt.key) {
 
-            case 'Enter': 
+            case 'Enter':
                 if (evt.ctrlKey) {
                     // ctrl+enter == insert stub field after focused field
                     // ctrl+shift+enter == insert stub field before focused field
@@ -431,7 +431,7 @@ export class EditableContentComponent
 
             case 'Delete':
 
-                if (evt.ctrlKey) { 
+                if (evt.ctrlKey) {
                     // ctrl+delete == delete whole field
                     this.context.deleteField(this.field);
                     evt.preventDefault();
@@ -445,7 +445,7 @@ export class EditableContentComponent
 
                 break;
 
-            case 'ArrowDown': 
+            case 'ArrowDown':
 
                 if (evt.ctrlKey) {
                     // ctrl+down == copy current field down one
@@ -502,8 +502,9 @@ export class EditableContentComponent
     }
 
     deleteField() {
-        this.context.focusNextTag(this.field) || 
+        if (!this.context.focusNextTag(this.field)) {
             this.context.focusPreviousTag(this.field);
+        }
 
         this.record.deleteFields(this.field);
     }
@@ -515,12 +516,12 @@ export class EditableContentComponent
 
         this.field.deleteExactSubfields(this.subfield);
 
-        const focus: FieldFocusRequest = 
-            {fieldId: this.field.fieldId, target: 'tag'};
+        const focus: FieldFocusRequest = {
+            fieldId: this.field.fieldId, target: 'tag'};
 
-        if (sfpos >= 0) { 
+        if (sfpos >= 0) {
             focus.target = 'sfv';
-            focus.sfOffset = sfpos; 
+            focus.sfOffset = sfpos;
         }
 
         this.context.requestFieldFocus(focus);
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-context.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-context.ts
index 168fbda376..520ddbfbbe 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-context.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-context.ts
@@ -6,7 +6,7 @@ import {NgbPopover} from '@ng-bootstrap/ng-bootstrap';
 
 const STUB_DATA_00X = '                                        ';
 
-export type MARC_EDITABLE_FIELD_TYPE = 
+export type MARC_EDITABLE_FIELD_TYPE =
     'ldr' | 'tag' | 'cfld' | 'ind1' | 'ind2' | 'sfc' | 'sfv' | 'ffld';
 
 export interface FieldFocusRequest {
@@ -137,24 +137,24 @@ export class MarcEditContext {
 
         } else {
             // Re-insert the removed field and focus it.
-            
-            if (action.subfield) { 
+
+            if (action.subfield) {
 
                 this.insertSubfield(action.field, action.subfield, true);
                 this.focusSubfield(action.field, action.subfield[2]);
 
             } else {
-                
+
                 const fieldId = action.position.fieldId;
-                const prevField = 
+                const prevField =
                     this.record.getField(action.prevPosition.fieldId);
 
                 this.record.insertFieldsAfter(prevField, action.field);
-                
+
                 // Recover the original fieldId, which gets re-stamped
                 // in this.record.insertFields* calls.
                 action.field.fieldId = fieldId;
-                
+
                 // Focus the newly recovered field.
                 this.requestFieldFocus(action.position);
             }
@@ -211,17 +211,19 @@ export class MarcEditContext {
         this.undoStack.unshift(action);
     }
 
-    deleteField(field: MarcField) { 
+    deleteField(field: MarcField) {
         this.trackStructuralUndo(field, false);
 
-        this.focusNextTag(field) || this.focusPreviousTag(field);
+        if (!this.focusNextTag(field)) {
+            this.focusPreviousTag(field);
+        }
 
         this.record.deleteFields(field);
     }
 
     add00X(tag: string) {
 
-        const field: MarcField = 
+        const field: MarcField =
             this.record.newField({tag : tag, data : STUB_DATA_00X});
 
         this.record.insertOrderedFields(field);
@@ -274,7 +276,7 @@ export class MarcEditContext {
 
     // Adds a new empty subfield to the provided field at the
     // requested subfield position
-    insertSubfield(field: MarcField, 
+    insertSubfield(field: MarcField,
         subfield: MarcSubfield, skipTracking?: boolean) {
         const position = subfield[2];
 
@@ -297,18 +299,18 @@ export class MarcEditContext {
         const newSf: MarcSubfield = [' ', '', position];
         this.insertSubfield(field, newSf);
     }
-    
-    // Focus the requested subfield by its position.  If its 
+
+    // Focus the requested subfield by its position.  If its
     // position is less than zero, focus the field's tag instead.
     focusSubfield(field: MarcField, position: number) {
 
         const focus: FieldFocusRequest = {fieldId: field.fieldId, target: 'tag'};
 
-        if (position >= 0) { 
+        if (position >= 0) {
             // Focus the code instead of the value, because attempting to
             // focus an empty (editable) div results in nothing getting focus.
             focus.target = 'sfc';
-            focus.sfOffset = position; 
+            focus.sfOffset = position;
         }
 
         this.requestFieldFocus(focus);
@@ -331,8 +333,8 @@ export class MarcEditContext {
     // Returns true if the field has a next tag to focus
     focusNextTag(field: MarcField) {
         const nextField = this.record.getNextField(field.fieldId);
-        if (nextField) { 
-            this.focusTag(nextField); 
+        if (nextField) {
+            this.focusTag(nextField);
             return true;
         }
         return false;
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marcrecord.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marcrecord.ts
index 61b716929b..69e095518d 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marcrecord.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marcrecord.ts
@@ -7,8 +7,8 @@ declare var MARC21;
 // MARC breaker delimiter
 const DELIMITER = '$';
 
-export interface MarcSubfield  // code, value, position
-    extends Array<string|number>{0: string; 1: string; 2: number}
+export interface MarcSubfield    // code, value, position
+    extends Array<string|number> { 0: string; 1: string; 2: number; }
 
 // Only contains the attributes/methods we need so far.
 export interface MarcField {
@@ -17,7 +17,7 @@ export interface MarcField {
     tag?: string;
     ind1?: string;
     ind2?: string;
-    subfields?: MarcSubfield[]; 
+    subfields?: MarcSubfield[];
 
     isControlfield(): boolean;
 
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/tagtable.service.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/tagtable.service.ts
index ec5abd04b3..13cbd2c0e8 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/tagtable.service.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/tagtable.service.ts
@@ -16,7 +16,7 @@ interface TagTableSelector {
 const defaultTagTableSelector: TagTableSelector = {
     marcFormat     : 'marc21',
     marcRecordType : 'biblio'
-}
+};
 
 @Injectable()
 export class TagTableService {
@@ -26,7 +26,7 @@ export class TagTableService {
     ffPosMap: {[rtype: string]: any[]} = {};
     ffValueMap: {[rtype: string]: any} = {};
 
-    extractedValuesCache: 
+    extractedValuesCache:
         {[valueType: string]: {[which: string]: any}} = {};
 
     constructor(
@@ -59,7 +59,7 @@ export class TagTableService {
         }
     }
 
-    toCache(dataType: string, which: string, 
+    toCache(dataType: string, which: string,
         which2: string, values: ContextMenuEntry[]): ContextMenuEntry[] {
         const base = this.extractedValuesCache[dataType];
         const part1 = base[which];
@@ -128,7 +128,7 @@ export class TagTableService {
                 selector.marcFormat = defaultTagTableSelector.marcFormat;
             }
             if (!selector.marcRecordType) {
-                selector.marcRecordType = 
+                selector.marcRecordType =
                     defaultTagTableSelector.marcRecordType;
             }
         } else {
@@ -160,15 +160,15 @@ export class TagTableService {
         })).toPromise();
     }
 
-    getSubfieldCodes(tag: string): ContextMenuEntry[] { 
+    getSubfieldCodes(tag: string): ContextMenuEntry[] {
         if (!tag || !this.tagMap[tag]) { return null; }
 
         const cached = this.fromCache('sfcodes', tag);
 
         const list = this.tagMap[tag].subfields.map(sf => ({
-            value: sf.code, 
+            value: sf.code,
             label: `${sf.code}: ${sf.description}`
-        })) 
+        }))
         .sort((a, b) => a.label < b.label ? -1 : 1);
 
         return this.toCache('sfcodes', tag, null, list);
@@ -178,7 +178,7 @@ export class TagTableService {
 
         const cached = this.fromCache('fieldtags');
         if (cached) { return cached; }
-        
+
         return Object.keys(this.tagMap)
         .filter(tag => Boolean(this.tagMap[tag]))
         .map(tag => ({
@@ -191,7 +191,7 @@ export class TagTableService {
     getSubfieldValues(tag: string, sfCode: string): ContextMenuEntry[] {
         if (!tag || !this.tagMap[tag]) { return []; }
 
-        const cached = this.fromCache('sfvalues', tag, sfCode)
+        const cached = this.fromCache('sfvalues', tag, sfCode);
         if (cached) { return cached; }
 
         const list: ContextMenuEntry[] = [];
@@ -203,18 +203,18 @@ export class TagTableService {
             sf.value_list.forEach(value => {
 
                 let label = value.description || value.code;
-                let code = value.code || label;
+                const code = value.code || label;
                 if (code !== label) { label = `${code}: ${label}`; }
 
                 list.push({value: code, label: label});
-            })
+            });
         });
 
         return this.toCache('sfvalues', tag, sfCode, list);
     }
 
     getIndicatorValues(tag: string, which: 'ind1' | 'ind2'): ContextMenuEntry[] {
-        if (!tag || !this.tagMap[tag]) { return }
+        if (!tag || !this.tagMap[tag]) { return; }
 
         const cached = this.fromCache('indicators', tag, which);
         if (cached) { return cached; }

commit 41231d34639ac05d8b811ac5afa87e1899d4bd62
Author: Bill Erickson <berickxx at gmail.com>
Date:   Fri Dec 6 15:27:28 2019 -0500

    LP1852782 Avoid unnecessary catalog pagination search
    
    Prevent the record detail paginator from trying to execute a search (to
    find the current details) as users type in new search params in the
    record detail seach form.
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    
    Signed-off-by: Jane Sandberg <sandbej at linnbenton.edu>

diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/pagination.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/record/pagination.component.ts
index b3e9a9c53e..88214cd805 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/record/pagination.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/pagination.component.ts
@@ -94,10 +94,6 @@ export class RecordPaginationComponent implements OnInit {
 
             return this.refreshSearch().then(ok => {
                 this.index = this.searchContext.indexForResult(this.id);
-                if (this.index === null) {
-                    console.warn(
-                        'No search results found containing the focused record.');
-                }
                 resolve();
             });
         });
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html
index 10f8c7c3b1..49ec2e55f5 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html
@@ -11,10 +11,8 @@
   </div>
   <div class="row ml-0 mr-0">
     <div id='staff-catalog-bib-navigation'>
-      <div *ngIf="searchContext.isSearchable()">
-        <eg-catalog-record-pagination [recordId]="recordId" [recordTab]="recordTab">
-        </eg-catalog-record-pagination>
-      </div>
+      <eg-catalog-record-pagination [recordId]="recordId" [recordTab]="recordTab">
+      </eg-catalog-record-pagination>
     </div>
     <!-- push the actions component to the right -->
     <div class="flex-1"></div>

commit 73e7e0c54d082af4d408caab8465e2654f8825e0
Author: Bill Erickson <berickxx at gmail.com>
Date:   Fri Dec 6 13:10:29 2019 -0500

    LP1852782 Record detail page shows summary first
    
    Consistent with other EG catalogs, show the record summary section first
    on the record detail page, with actions below.
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    
    Signed-off-by: Jane Sandberg <sandbej at linnbenton.edu>

diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html
index 29ac211bf7..10f8c7c3b1 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html
@@ -5,6 +5,10 @@
 </ng-container>
 
 <div id="staff-catalog-record-container">
+  <div id='staff-catalog-bib-summary-container' class='mb-1'>
+    <eg-bib-summary [bibSummary]="summary">
+    </eg-bib-summary>
+  </div>
   <div class="row ml-0 mr-0">
     <div id='staff-catalog-bib-navigation'>
       <div *ngIf="searchContext.isSearchable()">
@@ -19,10 +23,6 @@
       </eg-catalog-record-actions>
     </div>
   </div>
-  <div id='staff-catalog-bib-summary-container' class='mt-1'>
-    <eg-bib-summary [bibSummary]="summary">
-    </eg-bib-summary>
-  </div>
   <div id='staff-catalog-bib-tabs-container' class='mt-3'>
     <div class="row">
       <div class="col-lg-12 text-right">

commit 22e046d33c8bfce2c59f9de075c72ee0b4d9bdbd
Author: Bill Erickson <berickxx at gmail.com>
Date:   Fri Dec 6 13:02:55 2019 -0500

    LP1852782 Catalog search form expand/collapse
    
    Collapse the form by default on record detail pages for closer
    consistency with previous catalogs.  When collapsed, provide an option
    to expand the search form.
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    
    Signed-off-by: Jane Sandberg <sandbej at linnbenton.edu>

diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.html
index d032f3d08f..f5d2e50fb0 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.html
@@ -1,7 +1,15 @@
-<!--
-TODO focus search input
--->
-<div id='staffcat-search-form' class="row pt-3 pb-3 mb-3">
+<div id='staffcat-search-form'>
+
+  <div *ngIf="!showThyself" class="row pt-1 pb-1 mb-2 pr-2">
+    <div class="col-lg-12 d-flex">
+      <div class="flex-1"></div><!-- push right -->
+      <a (click)="showThyself=true" class="label-with-material-icon no-href" i18n>
+        Show Search Form <span class="material-icons">unfold_more</span>
+      </a>
+    </div>
+  </div>
+  
+  <div *ngIf="showThyself" class="row pt-3 pb-3 mb-3">
   <div class="col-lg-8">
     <ngb-tabset #searchTabs [activeId]="searchTab" (tabChange)="onTabChange($event)">
       <ngb-tab title="Keyword Search" i18n-title id="term">
@@ -351,5 +359,6 @@ TODO focus search input
       </div>
     </div>
   </div>
+  </div>
 </div>
 
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.ts
index 84dd830538..42e70861f6 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.ts
@@ -1,5 +1,5 @@
 import {Component, OnInit, AfterViewInit, Renderer2} from '@angular/core';
-import {ActivatedRoute} from '@angular/router';
+import {Router, ActivatedRoute, NavigationEnd} from '@angular/router';
 import {IdlObject} from '@eg/core/idl.service';
 import {OrgService} from '@eg/core/org.service';
 import {CatalogService} from '@eg/share/catalog/catalog.service';
@@ -21,8 +21,12 @@ export class SearchFormComponent implements OnInit, AfterViewInit {
     copyLocations: IdlObject[];
     searchTab: string;
 
+    // Display the full form if true, otherwise display the expandy.
+    showThyself = true;
+
     constructor(
         private renderer: Renderer2,
+        private router: Router,
         private route: ActivatedRoute,
         private org: OrgService,
         private cat: CatalogService,
@@ -39,6 +43,16 @@ export class SearchFormComponent implements OnInit, AfterViewInit {
                 this.searchTab = params.searchTab;
             }
         });
+
+        this.router.events.subscribe(routeEvent => {
+            if (routeEvent instanceof NavigationEnd) {
+                if (routeEvent.url.match(/catalog\/record/)) {
+                    this.showThyself = false;
+                } else {
+                    this.showThyself = true;
+                }
+            }
+        });
     }
 
     ngOnInit() {

commit 086f54a1e8d56bc7fc8f649b44eddae6dae12e7c
Author: Bill Erickson <berickxx at gmail.com>
Date:   Thu Nov 14 16:54:21 2019 -0500

    LP1852782 Angular MARC enriched editor (first batch)
    
    Main rich MARC editor component.  Includes fixed fields editor, context
    menus for value selection, undo/redo, help display, keyboard shortcuts.
    
    Also includes a standalone context menu component.
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    
    Signed-off-by: Jane Sandberg <sandbej at linnbenton.edu>

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 3d9860471f..02839579d3 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
@@ -52,6 +52,8 @@ export class ComboboxComponent implements ControlValueAccessor, OnInit {
 
     @Input() allowFreeText = false;
 
+    @Input() inputSize: number = null;
+
     // Add a 'required' attribute to the input
     isRequired: boolean;
     @Input() set required(r: boolean) {
diff --git a/Open-ILS/src/eg2/src/app/share/common-widgets.module.ts b/Open-ILS/src/eg2/src/app/share/common-widgets.module.ts
index 01b16bd4da..e1f85cdd3e 100644
--- a/Open-ILS/src/eg2/src/app/share/common-widgets.module.ts
+++ b/Open-ILS/src/eg2/src/app/share/common-widgets.module.ts
@@ -14,6 +14,7 @@ import {DateSelectComponent} from '@eg/share/date-select/date-select.component';
 import {OrgSelectComponent} from '@eg/share/org-select/org-select.component';
 import {DateRangeSelectComponent} from '@eg/share/daterange-select/daterange-select.component';
 import {DateTimeSelectComponent} from '@eg/share/datetime-select/datetime-select.component';
+import {ContextMenuModule} from '@eg/share/context-menu/context-menu.module';
 
 
 @NgModule({
@@ -23,14 +24,15 @@ import {DateTimeSelectComponent} from '@eg/share/datetime-select/datetime-select
     DateSelectComponent,
     OrgSelectComponent,
     DateRangeSelectComponent,
-    DateTimeSelectComponent,
+    DateTimeSelectComponent
   ],
   imports: [
     CommonModule,
     FormsModule,
     ReactiveFormsModule,
     NgbModule,
-    EgCoreModule
+    EgCoreModule,
+    ContextMenuModule
   ],
   exports: [
     CommonModule,
@@ -43,7 +45,8 @@ import {DateTimeSelectComponent} from '@eg/share/datetime-select/datetime-select
     OrgSelectComponent,
     DateRangeSelectComponent,
     DateTimeSelectComponent,
-  ],
+    ContextMenuModule
+  ]
 })
 
 export class CommonWidgetsModule { }
diff --git a/Open-ILS/src/eg2/src/app/share/context-menu/context-menu-container.component.css b/Open-ILS/src/eg2/src/app/share/context-menu/context-menu-container.component.css
new file mode 100644
index 0000000000..3323d2a3e7
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/share/context-menu/context-menu-container.component.css
@@ -0,0 +1,27 @@
+
+.eg-context-menu {
+  /* These fonts were applied specifically for the MARC editor
+   * context menus.  Might want to make these optional. */
+  font-family: 'Lucida Console', Monaco, monospace;
+
+  /* put a hard limit on the popover width */
+  max-width: 550px;
+}
+
+.eg-context-menu .popover-body {
+  max-height: 400px;
+
+  /* Text exceeding the max-height / max-width will results in scrolls.
+   * In most cases, this should not happen. */
+  overflow-y: auto;
+  overflow-x: auto;
+}
+
+.eg-context-menu .popover-body .menu-entry {
+  /* force the menu to expand horizontally to display the text */
+  white-space: nowrap;
+}
+
+.eg-context-menu .popover-body .menu-entry:hover {
+  background-color: #f8f9fa; 
+}
diff --git a/Open-ILS/src/eg2/src/app/share/context-menu/context-menu-container.component.html b/Open-ILS/src/eg2/src/app/share/context-menu/context-menu-container.component.html
new file mode 100644
index 0000000000..0d6c0a0ede
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/share/context-menu/context-menu-container.component.html
@@ -0,0 +1,8 @@
+
+<ng-template #menuTemplate>
+  <!-- apply (click) to div so user can click anywhere in the row -->
+  <div *ngFor="let entry of menuEntries; first as isFirst" 
+   (click)="entryClicked(entry)" class="menu-entry {{entryClasses}}">
+    <a>{{entry.label}}</a>
+  </div>
+</ng-template>
diff --git a/Open-ILS/src/eg2/src/app/share/context-menu/context-menu-container.component.ts b/Open-ILS/src/eg2/src/app/share/context-menu/context-menu-container.component.ts
new file mode 100644
index 0000000000..a5dfdcefbf
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/share/context-menu/context-menu-container.component.ts
@@ -0,0 +1,38 @@
+import {Component, Input, Output, EventEmitter, OnInit, ViewChild, 
+    AfterViewInit, TemplateRef, ViewEncapsulation} from '@angular/core';
+import {ContextMenuService, ContextMenu, ContextMenuEntry} from './context-menu.service';
+
+ at Component({
+  selector: 'eg-context-menu-container',
+  templateUrl: './context-menu-container.component.html',
+  styleUrls: ['context-menu-container.component.css'],
+  /* Our CSS affects the style of the popover, which may 
+   * be beyond our reach for standard view encapsulation */
+  encapsulation: ViewEncapsulation.None
+})
+
+export class ContextMenuContainerComponent implements OnInit, AfterViewInit {
+
+    menuEntries: ContextMenuEntry[] = [];
+    @ViewChild('menuTemplate', {static: false}) menuTemplate: TemplateRef<any>;
+
+    constructor(private menuService: ContextMenuService) {}
+
+    ngOnInit() {
+
+        this.menuService.showMenuRequest.subscribe(
+            (menu: ContextMenu) => {
+
+            this.menuEntries = menu.entries
+        });
+    }
+
+    ngAfterViewInit() {
+        this.menuService.menuTemplate = this.menuTemplate;
+    }
+
+    entryClicked(entry: ContextMenuEntry) {
+        this.menuService.menuItemSelected.emit(entry);
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/share/context-menu/context-menu.directive.ts b/Open-ILS/src/eg2/src/app/share/context-menu/context-menu.directive.ts
new file mode 100644
index 0000000000..591d9d01f3
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/share/context-menu/context-menu.directive.ts
@@ -0,0 +1,90 @@
+import {Input, Output, EventEmitter, Directive} from '@angular/core';
+import {NgbPopover} from '@ng-bootstrap/ng-bootstrap';
+import {ContextMenuService, ContextMenu, ContextMenuEntry} from './context-menu.service';
+
+
+/* Import all of this stuff so we can pass it to our parent 
+ * class via its constructor */
+import {
+    Inject, Injector, Renderer2, ElementRef, TemplateRef, ViewContainerRef,
+    ComponentFactoryResolver, NgZone, ChangeDetectorRef, ApplicationRef
+} from '@angular/core';
+import {DOCUMENT} from '@angular/common';
+import {NgbPopoverConfig} from '@ng-bootstrap/ng-bootstrap';
+/* --- */
+
+ at Directive({
+  selector: '[egContextMenu]',
+  exportAs: 'egContextMenu'
+})
+export class ContextMenuDirective extends NgbPopover {
+
+    triggers = 'contextmenu';
+    popoverClass = 'eg-context-menu';
+
+    menuEntries: ContextMenuEntry[] = [];
+    menu: ContextMenu;
+
+    @Input() set egContextMenu(menuEntries: ContextMenuEntry[]) {
+        this.menuEntries = menuEntries;
+    }
+
+    @Output() menuItemSelected: EventEmitter<ContextMenuEntry>;
+
+    // Only one active menu is allowed at a time.
+    static activeDirective: ContextMenuDirective;
+    static menuId = 0;
+
+    constructor(
+        p1: ElementRef<HTMLElement>, p2: Renderer2, p3: Injector,
+        p4: ComponentFactoryResolver, p5: ViewContainerRef, p6: NgbPopoverConfig,
+        p7: NgZone, @Inject(DOCUMENT) p8: any, p9: ChangeDetectorRef,
+        p10: ApplicationRef, private menuService: ContextMenuService) {
+
+        // relay injected services to parent
+        super(p1, p2, p3, p4, p5, p6, p7, p8, p9, p10);
+
+        this.menuItemSelected = new EventEmitter<ContextMenuEntry>();
+
+        this.menuService.menuItemSelected.subscribe(
+            (entry: ContextMenuEntry) => {
+
+            // Only broadcast entry selection to my listeners if I'm 
+            // hosting the menu where the selection occurred.
+
+            if (this.menu && this.menu.id === this.menuService.activeMenu.id) {
+                this.menuItemSelected.emit(entry);
+            }
+        });
+    }
+
+    open() {
+
+        // In certain scenarios (e.g. right-clicking on another context
+        // menu) an open popover will stay open.  Force it closed here.
+        if (ContextMenuDirective.activeDirective) {
+            ContextMenuDirective.activeDirective.close();
+            ContextMenuDirective.activeDirective = null;
+            this.menuService.activeMenu == null;
+        }
+
+        if (!this.menuEntries ||
+             this.menuEntries.length === 0) { 
+             return;
+        }
+
+        this.menu = new ContextMenu();
+        this.menu.id = ContextMenuDirective.menuId++;
+        this.menu.entries = this.menuEntries;
+
+        this.menuService.activeMenu = this.menu;
+        this.menuService.showMenuRequest.emit(this.menu);
+        this.ngbPopover = this.menuService.menuTemplate;
+
+        ContextMenuDirective.activeDirective = this;
+
+        super.open();
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/share/context-menu/context-menu.module.ts b/Open-ILS/src/eg2/src/app/share/context-menu/context-menu.module.ts
new file mode 100644
index 0000000000..fb25e6144a
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/share/context-menu/context-menu.module.ts
@@ -0,0 +1,24 @@
+import {NgModule} from '@angular/core';
+import {CommonModule} from '@angular/common';
+import {NgbModule} from '@ng-bootstrap/ng-bootstrap';
+import {ContextMenuService} from './context-menu.service';
+import {ContextMenuDirective} from './context-menu.directive';
+import {ContextMenuContainerComponent} from './context-menu-container.component';
+
+ at NgModule({
+  declarations: [
+    ContextMenuDirective,
+    ContextMenuContainerComponent
+  ],
+  imports: [
+    CommonModule,
+    NgbModule
+  ],
+  exports: [
+    ContextMenuDirective,
+    ContextMenuContainerComponent
+  ]
+})
+
+export class ContextMenuModule { }
+
diff --git a/Open-ILS/src/eg2/src/app/share/context-menu/context-menu.service.ts b/Open-ILS/src/eg2/src/app/share/context-menu/context-menu.service.ts
new file mode 100644
index 0000000000..bfedf4232f
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/share/context-menu/context-menu.service.ts
@@ -0,0 +1,32 @@
+import {Injectable, EventEmitter, TemplateRef} from '@angular/core';
+import {tap} from 'rxjs/operators';
+
+/* Relay requests to/from the context menu directive and its 
+ * template container component */
+
+export interface ContextMenuEntry {
+    value: string;
+    label: string;
+}
+
+export class ContextMenu {
+    id: number;
+    entries: ContextMenuEntry[];
+}
+
+ at Injectable({providedIn: 'root'})
+export class ContextMenuService {
+    
+    showMenuRequest: EventEmitter<ContextMenu>;
+    menuItemSelected: EventEmitter<ContextMenuEntry>;
+
+    menuTemplate: TemplateRef<any>;
+    activeMenu: ContextMenu;
+    
+    constructor() {
+        this.showMenuRequest = new EventEmitter<ContextMenu>();
+        this.menuItemSelected = new EventEmitter<ContextMenuEntry>();
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.css b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.css
new file mode 100644
index 0000000000..e21bb843d8
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.css
@@ -0,0 +1,22 @@
+
+div[contenteditable] {
+   /* provide plenty of input space */
+   min-width: 2em;
+   /* match BS form-control border color */
+   border: 1px solid rgb(206, 212, 218);
+   /* match BS form-control input height */
+   min-height: calc(1.5em + .75rem + 2px);
+}
+
+.sf-delimiter { 
+  /* match angjs color */
+  color: rgb(0, 0, 255)!important; 
+  /* snuggle up to my subfield code */
+  margin-right: -0.5rem; 
+}
+
+.sf-code { 
+  /* match angjs color */
+  color: rgb(0, 0, 255)!important; 
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.html b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.html
new file mode 100644
index 0000000000..359128cee8
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.html
@@ -0,0 +1,35 @@
+
+<ng-container *ngIf="bigText">
+  <div contenteditable
+    id='{{randId}}' 
+    spellcheck="false"
+    class="d-inline-block text-dark text-break {{moreClasses}}"
+    [attr.tabindex]="fieldText ? -1 : ''"
+    [egContextMenu]="contextMenuEntries()"
+    (menuItemSelected)="contextMenuChange($event.value)"
+    (keydown)="inputKeyDown($event)"
+    (focus)="selectText()"
+    (blur)="inputBlurred()"
+    (input)="bigTextValueChange()">
+  </div>
+</ng-container>
+
+<ng-container *ngIf="!bigText">
+  <input 
+    id='{{randId}}' 
+    spellcheck="false"
+    class="text-dark rounded-0 form-control {{moreClasses}}"
+    [size]="inputSize()" 
+    [maxlength]="maxLength || ''"
+    [disabled]="fieldText" 
+    [attr.tabindex]="fieldText ? -1 : ''"
+    [egContextMenu]="contextMenuEntries()"
+    (menuItemSelected)="contextMenuChange($event.value)"
+    (keydown)="inputKeyDown($event)"
+    (focus)="selectText()"
+    (blur)="inputBlurred()"
+    [ngModel]="getContent()"
+    (ngModelChange)="setContent($event)"
+  />
+</ng-container>
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts
new file mode 100644
index 0000000000..5f4b1ea8ab
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts
@@ -0,0 +1,534 @@
+import {ElementRef, Component, Input, Output, OnInit, OnDestroy, 
+    EventEmitter, AfterViewInit, Renderer2} from '@angular/core';
+import {Subscription} from 'rxjs';
+import {filter} from 'rxjs/operators';
+import {MarcRecord, MarcField, MarcSubfield} from './marcrecord';
+import {MarcEditContext, FieldFocusRequest, MARC_EDITABLE_FIELD_TYPE, 
+    TextUndoRedoAction} from './editor-context';
+import {ContextMenuEntry} from '@eg/share/context-menu/context-menu.service';
+import {TagTableService} from './tagtable.service';
+
+/**
+ * MARC Editable Content Component
+ */
+
+ at Component({
+  selector: 'eg-marc-editable-content',
+  templateUrl: './editable-content.component.html',
+  styleUrls: ['./editable-content.component.css']
+})
+
+export class EditableContentComponent 
+    implements OnInit, AfterViewInit, OnDestroy {
+
+    @Input() context: MarcEditContext;
+    @Input() field: MarcField;
+    @Input() fieldType: MARC_EDITABLE_FIELD_TYPE = null;
+
+    // read-only field text.  E.g. 'LDR'
+    @Input() fieldText: string = null;
+
+    // array of subfield code and subfield value
+    @Input() subfield: MarcSubfield;
+
+    @Input() fixedFieldCode: string;
+
+    // space-separated list of additional CSS classes to append
+    @Input() moreClasses: string;
+
+    get record(): MarcRecord { return this.context.record; }
+
+    bigText = false;
+    randId = Math.floor(Math.random() * 100000);
+    editInput: any; // <input/> or <div contenteditable/>
+    maxLength: number = null;
+
+    // Track the load-time content so we know what text value to 
+    // track on our undo stack.
+    undoBackToText: string;
+
+    focusSub: Subscription;
+    undoRedoSub: Subscription;
+    isLeader: boolean; // convenience
+
+    // Cache of fixed field menu options
+    ffValues: ContextMenuEntry[] = [];
+
+    // Track the fixed field value locally since extracting the value
+    // in real time from the record, which adds padding to the text,
+    // causes usability problems.
+    ffValue: string;
+
+    constructor(
+        private renderer: Renderer2,
+        private tagTable: TagTableService) {}
+
+    ngOnInit() {
+        this.setupFieldType();
+    }
+
+    ngOnDestroy() {
+        if (this.focusSub) { this.focusSub.unsubscribe(); }
+        if (this.undoRedoSub) { this.undoRedoSub.unsubscribe(); }
+    }
+
+    watchForFocusRequests() {
+        this.focusSub = this.context.fieldFocusRequest.pipe(
+            filter((req: FieldFocusRequest) => this.focusRequestIsMe(req)))
+        .subscribe((req: FieldFocusRequest) => this.selectText(req));
+    }
+
+    watchForUndoRedoRequests() {
+        this.undoRedoSub = this.context.textUndoRedoRequest.pipe(
+            filter((action: TextUndoRedoAction) => this.focusRequestIsMe(action.position)))
+        .subscribe((action: TextUndoRedoAction) => this.processUndoRedo(action));
+    }
+
+    focusRequestIsMe(req: FieldFocusRequest): boolean {
+        if (req.target !== this.fieldType) { return false; }
+
+        if (this.field) {
+            if (req.fieldId !== this.field.fieldId) { return false; }
+        } else if (req.target === 'ldr') {
+            return this.isLeader;
+        } else if (req.target === 'ffld' && 
+            req.ffCode !== this.fixedFieldCode) {
+            return false;
+        }
+
+        if (req.sfOffset !== undefined && 
+            req.sfOffset !== this.subfield[2]) {
+            // this is not the subfield you are looking for.
+            return false;
+        }
+
+        return true;
+    }
+
+    selectText(req?: FieldFocusRequest) {
+        if (this.bigText) {
+            this.focusBigText();
+        } else {
+            this.editInput.select();
+        }
+
+        if (!req) {
+            // Focus request may have come from keyboard navigation, 
+            // clicking, etc.  Model the event as a focus request
+            // so it can be tracked the same.
+            req = {
+                fieldId: this.field ? this.field.fieldId : -1,
+                target: this.fieldType,
+                sfOffset: this.subfield ? this.subfield[2] : undefined,
+                ffCode: this.fixedFieldCode
+            };
+        }
+
+        this.context.lastFocused = req;
+    }
+
+    setupFieldType() {
+        const content = this.getContent();
+        this.undoBackToText = content;
+
+        switch (this.fieldType) {
+            case 'ldr':
+                this.isLeader = true;
+                if (content) { this.maxLength = content.length; }
+                this.watchForFocusRequests();
+                this.watchForUndoRedoRequests();
+                break;
+
+            case 'tag':
+                this.maxLength = 3;
+                this.watchForFocusRequests();
+                this.watchForUndoRedoRequests();
+                break;
+
+            case 'cfld':
+                this.watchForFocusRequests();
+                this.watchForUndoRedoRequests();
+                break;
+
+            case 'ffld':
+                this.applyFFOptions();
+                this.watchForFocusRequests();
+                this.watchForUndoRedoRequests();
+                break;
+
+            case 'ind1':
+            case 'ind2':
+                this.maxLength = 1;
+                this.watchForFocusRequests();
+                this.watchForUndoRedoRequests();
+                break;
+
+            case 'sfc':
+                this.maxLength = 1;
+                this.watchForFocusRequests();
+                this.watchForUndoRedoRequests();
+                break;
+
+            case 'sfv':
+                this.bigText = true;
+                this.watchForFocusRequests();
+                this.watchForUndoRedoRequests();
+                break;
+        }
+    }
+
+    applyFFOptions() {
+        return this.tagTable.getFfFieldMeta(
+            this.fixedFieldCode, this.record.recordType())
+        .then(fieldMeta => {
+            if (fieldMeta) {
+                this.maxLength = fieldMeta.length || 1;
+            }
+        });
+
+        // Fixed field options change when the record type changes.
+        this.context.recordChange.subscribe(_ => this.applyFFOptions());
+    }
+
+    // These are served dynamically to handle cases where a tag or
+    // subfield is modified in place.
+    contextMenuEntries(): ContextMenuEntry[] {
+        if (this.isLeader) { return; }
+
+        switch(this.fieldType) {
+            case 'tag': 
+                return this.tagTable.getFieldTags();
+
+            case 'sfc': 
+                return this.tagTable.getSubfieldCodes(this.field.tag);
+
+            case 'sfv': 
+                return this.tagTable.getSubfieldValues(
+                    this.field.tag, this.subfield[0]);
+
+            case 'ind1':
+            case 'ind2':
+                return this.tagTable.getIndicatorValues(
+                    this.field.tag, this.fieldType);
+
+            case 'ffld': 
+                return this.tagTable.getFfValues(
+                    this.fixedFieldCode, this.record.recordType());
+        }
+
+        return null;
+    }
+
+    getContent(): string {
+        if (this.fieldText) { return this.fieldText; } // read-only
+
+        switch (this.fieldType) {
+            case 'ldr': return this.record.leader; 
+            case 'cfld': return this.field.data;
+            case 'tag': return this.field.tag;
+            case 'sfc': return this.subfield[0];
+            case 'sfv': return this.subfield[1];
+            case 'ind1': return this.field.ind1;
+            case 'ind2': return this.field.ind2;
+
+            case 'ffld': 
+                // When actively editing a fixed field, track its value
+                // in a local variable instead of pulling the value
+                // from record.extractFixedField(), which applies
+                // additional formattting, causing usability problems
+                // (e.g. unexpected spaces).  Once focus is gone, the
+                // view will be updated with the correctly formatted
+                // value.
+
+                if ( this.ffValue === undefined ||
+                    !this.context.lastFocused ||
+                    !this.focusRequestIsMe(this.context.lastFocused)) {
+
+                    this.ffValue = 
+                        this.record.extractFixedField(this.fixedFieldCode);
+                }
+                return this.ffValue;
+        }
+        return 'X';
+    }
+
+    setContent(value: string, propagatBigText?: boolean, skipUndoTrack?: boolean) {
+
+        if (this.fieldText) { return; } // read-only text
+
+        switch (this.fieldType) {
+            case 'ldr': this.record.leader = value; break;
+            case 'cfld': this.field.data = value; break;
+            case 'tag': this.field.tag = value; break;
+            case 'sfc': this.subfield[0] = value; break;
+            case 'sfv': this.subfield[1] = value; break;
+            case 'ind1': this.field.ind1 = value; break;
+            case 'ind2': this.field.ind2 = value; break;
+            case 'ffld': 
+                // Track locally and propagate to the record.
+                this.ffValue = value;
+                this.record.setFixedField(this.fixedFieldCode, value);
+                break;
+        }
+
+        if (propagatBigText && this.bigText) {
+            // Propagate new content to the bigtext div.
+            // Should only be used when a content change occurrs via
+            // external means (i.e. not from a direct edit of the div).
+            this.editInput.innerText = value;
+        }
+
+        if (!skipUndoTrack) {
+            this.trackTextChangeForUndo(value);
+        }
+    }
+
+    trackTextChangeForUndo(value: string) {
+
+        // Human-driven changes invalidate the redo stack.
+        this.context.redoStack = [];
+
+        const lastUndo = this.context.undoStack[0];
+
+        if (lastUndo
+            && lastUndo instanceof TextUndoRedoAction
+            && lastUndo.textContent === this.undoBackToText
+            && this.focusRequestIsMe(lastUndo.position)) {
+            // Most recent undo entry was a text change event within the
+            // current atomic editing (focused) session for the input.
+            // Nothing else to track.
+            return;
+        }
+
+        const undo = new TextUndoRedoAction();
+        undo.position = this.context.lastFocused;
+        undo.textContent =  this.undoBackToText;
+
+        this.context.undoStack.unshift(undo);
+    }
+
+    // Apply the undo or redo action and track its opposite
+    // action on the necessary stack
+    processUndoRedo(action: TextUndoRedoAction) {
+
+        // Undoing a text change
+        const recoverContent = this.getContent();
+        this.setContent(action.textContent, true, true);
+
+        action.textContent = recoverContent;
+        const moveTo = action.isRedo ? 
+            this.context.undoStack : this.context.redoStack;
+
+        moveTo.unshift(action);
+    }
+
+    inputBlurred() {
+        // If the text content changed during this focus session,
+        // track the new value as the value the next session of 
+        // text edits should return to upon undo.
+        this.undoBackToText = this.getContent();
+    }
+
+    // Propagate editable div content into our record
+    bigTextValueChange() {
+        this.setContent(this.editInput.innerText);
+    }
+
+    ngAfterViewInit() {
+        this.editInput = // numeric id requires [id=...] query selector
+            this.renderer.selectRootElement(`[id='${this.randId}']`);
+
+        // Initialize the editable div
+        this.editInput.innerText = this.getContent();
+    }
+
+    inputSize(): number {
+        if (this.maxLength) {
+            return this.maxLength + 1;
+        }
+        // give at least 2+ chars space and grow with the content
+        return Math.max(2, (this.getContent() || '').length) * 1.1;
+    }
+
+    focusBigText() {
+        const targetNode = this.editInput.firstChild;
+
+        if (!targetNode) {
+            // Div contains no text content, nothing to select
+            return;
+        }
+
+        const range = document.createRange();
+        range.setStart(targetNode, 0);
+        range.setEnd(targetNode, targetNode.length);
+
+        const selection = window.getSelection();
+        selection.removeAllRanges();
+        selection.addRange(range);
+    }
+
+    // Route keydown events to the appropriate handler
+    inputKeyDown(evt: KeyboardEvent) {
+
+        switch(evt.key) {
+            case 'y': 
+                if (evt.ctrlKey) { // redo
+                    this.context.requestRedo();
+                    evt.preventDefault();
+                }
+                return;
+
+            case 'z': 
+                if (evt.ctrlKey) { // undo
+                    this.context.requestUndo();
+                    evt.preventDefault();
+                }
+                return;
+
+            case 'F6': 
+                if (evt.shiftKey) {
+                    // shift+F6 => add 006
+                    this.context.add00X('006');
+                    evt.preventDefault();
+                    evt.stopPropagation();
+                }
+                return;
+
+            case 'F7': 
+                if (evt.shiftKey) {
+                    // shift+F7 => add 007
+                    this.context.add00X('007');
+                    evt.preventDefault();
+                    evt.stopPropagation();
+                }
+                return;
+
+            case 'F8': 
+                if (evt.shiftKey) {
+                    // shift+F8 => add/replace 008
+                    this.context.insertReplace008();
+                    evt.preventDefault();
+                    evt.stopPropagation();
+                }
+                return;
+        }
+
+        // None of the remaining key combos are supported by the LDR
+        // or fixed field editor.
+        if (this.fieldType === 'ldr' || this.fieldType === 'ffld') { return; }
+
+        switch (evt.key) {
+
+            case 'Enter': 
+                if (evt.ctrlKey) {
+                    // ctrl+enter == insert stub field after focused field
+                    // ctrl+shift+enter == insert stub field before focused field
+                    this.context.insertStubField(this.field, evt.shiftKey);
+                }
+
+                evt.preventDefault(); // Bare newlines not allowed.
+                break;
+
+            case 'Delete':
+
+                if (evt.ctrlKey) { 
+                    // ctrl+delete == delete whole field
+                    this.context.deleteField(this.field);
+                    evt.preventDefault();
+
+                } else if (evt.shiftKey && this.subfield) {
+                    // shift+delete == delete subfield
+
+                    this.context.deleteSubfield(this.field, this.subfield);
+                    evt.preventDefault();
+                }
+
+                break;
+
+            case 'ArrowDown': 
+
+                if (evt.ctrlKey) {
+                    // ctrl+down == copy current field down one
+                    this.context.insertField(
+                        this.field, this.record.cloneField(this.field));
+                } else {
+                    // avoid dupe focus requests
+                    this.context.focusNextTag(this.field);
+                }
+
+                evt.preventDefault();
+                break;
+
+            case 'ArrowUp':
+
+                if (evt.ctrlKey) {
+                    // ctrl+up == copy current field up one
+                    this.context.insertField(
+                        this.field, this.record.cloneField(this.field), true);
+                } else {
+                    // avoid dupe focus requests
+                    this.context.focusPreviousTag(this.field);
+                }
+
+                // up == move focus to tag of previous field
+                evt.preventDefault();
+                break;
+
+            case 'd': // thunk
+            case 'i':
+                if (evt.ctrlKey) {
+                    // ctrl+i / ctrl+d == insert subfield
+                    const pos = this.subfield ? this.subfield[2] + 1 : 0;
+                    this.context.insertStubSubfield(this.field, pos);
+                    evt.preventDefault();
+                }
+                break;
+        }
+    }
+
+    insertField(before: boolean) {
+
+        const newField = this.record.newField(
+            {tag: '999', subfields: [[' ', '', 0]]});
+
+        if (before) {
+            this.record.insertFieldsBefore(this.field, newField);
+        } else {
+            this.record.insertFieldsAfter(this.field, newField);
+        }
+
+        this.context.requestFieldFocus(
+            {fieldId: newField.fieldId, target: 'tag'});
+    }
+
+    deleteField() {
+        this.context.focusNextTag(this.field) || 
+            this.context.focusPreviousTag(this.field);
+
+        this.record.deleteFields(this.field);
+    }
+
+    deleteSubfield() {
+        // If subfields remain, focus the previous subfield.
+        // otherwise focus our tag.
+        const sfpos = this.subfield[2] - 1;
+
+        this.field.deleteExactSubfields(this.subfield);
+
+        const focus: FieldFocusRequest = 
+            {fieldId: this.field.fieldId, target: 'tag'};
+
+        if (sfpos >= 0) { 
+            focus.target = 'sfv';
+            focus.sfOffset = sfpos; 
+        }
+
+        this.context.requestFieldFocus(focus);
+    }
+
+    contextMenuChange(value: string) {
+        this.setContent(value, true);
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-context.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-context.ts
new file mode 100644
index 0000000000..168fbda376
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-context.ts
@@ -0,0 +1,351 @@
+import {EventEmitter} from '@angular/core';
+import {MarcRecord, MarcField, MarcSubfield} from './marcrecord';
+import {NgbPopover} from '@ng-bootstrap/ng-bootstrap';
+
+/* Per-instance MARC editor context. */
+
+const STUB_DATA_00X = '                                        ';
+
+export type MARC_EDITABLE_FIELD_TYPE = 
+    'ldr' | 'tag' | 'cfld' | 'ind1' | 'ind2' | 'sfc' | 'sfv' | 'ffld';
+
+export interface FieldFocusRequest {
+    fieldId: number;
+    target: MARC_EDITABLE_FIELD_TYPE;
+    sfOffset?: number; // focus a specific subfield by its offset
+    ffCode?: string; // fixed field code
+}
+
+export class UndoRedoAction {
+    // Which point in the record was modified.
+    position: FieldFocusRequest;
+
+    // Which stack do we toss this on once it's been applied?
+    isRedo: boolean;
+}
+
+export class TextUndoRedoAction extends UndoRedoAction {
+    textContent: string;
+}
+
+export class StructUndoRedoAction extends UndoRedoAction {
+    /* Add or remove a part of the record (field, subfield, etc.) */
+
+    // Does this action track an addition or deletion.
+    wasAddition: boolean;
+
+    // Field to add/delete or field to modify for subfield adds/deletes
+    field: MarcField;
+
+    // If this is a subfield modification.
+    subfield: MarcSubfield;
+
+    // Position preceding the modified position to mark the position
+    // of deletion recovery.
+    prevPosition: FieldFocusRequest;
+
+    // Location of the cursor at time of initial action.
+    prevFocus: FieldFocusRequest;
+}
+
+
+export class MarcEditContext {
+
+    recordChange: EventEmitter<MarcRecord>;
+    fieldFocusRequest: EventEmitter<FieldFocusRequest>;
+    textUndoRedoRequest: EventEmitter<TextUndoRedoAction>;
+    recordType: 'biblio' | 'authority' = 'biblio';
+
+    lastFocused: FieldFocusRequest = null;
+
+    undoStack: UndoRedoAction[] = [];
+    redoStack: UndoRedoAction[] = [];
+
+    private _record: MarcRecord;
+    set record(r: MarcRecord) {
+        if (r !== this._record) {
+            this._record = r;
+            this._record.stampFieldIds();
+            this.recordChange.emit(r);
+        }
+    }
+
+    get record(): MarcRecord {
+        return this._record;
+    }
+
+    constructor() {
+        this.recordChange = new EventEmitter<MarcRecord>();
+        this.fieldFocusRequest = new EventEmitter<FieldFocusRequest>();
+        this.textUndoRedoRequest = new EventEmitter<TextUndoRedoAction>();
+    }
+
+    requestFieldFocus(req: FieldFocusRequest) {
+        // timeout allows for new components to be built before the
+        // focus request is emitted.
+        setTimeout(() => this.fieldFocusRequest.emit(req));
+    }
+
+    resetUndos() {
+        this.undoStack = [];
+        this.redoStack = [];
+    }
+
+    requestUndo() {
+        const undo = this.undoStack.shift();
+        if (undo) {
+            undo.isRedo = false;
+            this.distributeUndoRedo(undo);
+        }
+    }
+
+    requestRedo() {
+        const redo = this.redoStack.shift();
+        if (redo) {
+            redo.isRedo = true;
+            this.distributeUndoRedo(redo);
+        }
+    }
+
+    distributeUndoRedo(action: UndoRedoAction) {
+        if (action instanceof TextUndoRedoAction) {
+            // Let the editable content component handle it.
+            this.textUndoRedoRequest.emit(action);
+        } else {
+            // Manage structural changes within
+            this.handleStructuralUndoRedo(action as StructUndoRedoAction);
+        }
+    }
+
+    handleStructuralUndoRedo(action: StructUndoRedoAction) {
+
+        if (action.wasAddition) {
+            // Remove the added field
+
+            if (action.subfield) {
+                const prevPos = action.subfield[2] - 1;
+                action.field.deleteExactSubfields(action.subfield);
+                this.focusSubfield(action.field, prevPos);
+
+            } else {
+                this.record.deleteFields(action.field);
+            }
+
+            // When deleting chunks, always return focus to the
+            // pre-insert position.
+            this.requestFieldFocus(action.prevFocus);
+
+        } else {
+            // Re-insert the removed field and focus it.
+            
+            if (action.subfield) { 
+
+                this.insertSubfield(action.field, action.subfield, true);
+                this.focusSubfield(action.field, action.subfield[2]);
+
+            } else {
+                
+                const fieldId = action.position.fieldId;
+                const prevField = 
+                    this.record.getField(action.prevPosition.fieldId);
+
+                this.record.insertFieldsAfter(prevField, action.field);
+                
+                // Recover the original fieldId, which gets re-stamped
+                // in this.record.insertFields* calls.
+                action.field.fieldId = fieldId;
+                
+                // Focus the newly recovered field.
+                this.requestFieldFocus(action.position);
+            }
+
+            // When inserting chunks, track the location where the
+            // insert was requested so we can return the cursor so we
+            // can return the cursor to the scene of the crime if the
+            // undo is re-done or vice versa.  This is primarily useful
+            // when performing global inserts like add00X, which can be
+            // done without the 00X field itself having focus.
+            action.prevFocus = this.lastFocused;
+        }
+
+        action.wasAddition = !action.wasAddition;
+
+        const moveTo = action.isRedo ? this.undoStack : this.redoStack;
+
+        moveTo.unshift(action);
+    }
+
+    trackStructuralUndo(field: MarcField, isAddition: boolean, subfield?: MarcSubfield) {
+
+        // Human-driven changes invalidate the redo stack.
+        this.redoStack = [];
+
+        const position: FieldFocusRequest = {fieldId: field.fieldId, target: 'tag'};
+
+        let prevPos: FieldFocusRequest = null;
+
+        if (subfield) {
+            position.target = 'sfc';
+            position.sfOffset = subfield[2];
+
+        } else {
+            // No need to track the previous field for subfield mods.
+
+            const prevField = this.record.getPreviousField(field.fieldId);
+            if (prevField) {
+                prevPos = {fieldId: prevField.fieldId, target: 'tag'};
+            }
+        }
+
+        const action = new StructUndoRedoAction();
+        action.field = field;
+        action.subfield = subfield;
+        action.wasAddition = isAddition;
+        action.position = position;
+        action.prevPosition = prevPos;
+
+        // For bulk adds (e.g. add a whole row) the field focused at
+        // time of action will be different than the added field.
+        action.prevFocus = this.lastFocused;
+
+        this.undoStack.unshift(action);
+    }
+
+    deleteField(field: MarcField) { 
+        this.trackStructuralUndo(field, false);
+
+        this.focusNextTag(field) || this.focusPreviousTag(field);
+
+        this.record.deleteFields(field);
+    }
+
+    add00X(tag: string) {
+
+        const field: MarcField = 
+            this.record.newField({tag : tag, data : STUB_DATA_00X});
+
+        this.record.insertOrderedFields(field);
+
+        this.trackStructuralUndo(field, true);
+
+        this.focusTag(field);
+    }
+
+    insertReplace008() {
+
+        // delete all of the 008s
+        [].concat(this.record.field('008', true)).forEach(f => {
+            this.trackStructuralUndo(f, false);
+            this.record.deleteFields(f);
+        });
+
+        const field = this.record.newField({
+            tag : '008', data : this.record.generate008()});
+
+        this.record.insertOrderedFields(field);
+
+        this.trackStructuralUndo(field, true);
+
+        this.focusTag(field);
+    }
+
+    // Add stub field before or after the context field
+    insertStubField(field: MarcField, before?: boolean) {
+
+        const newField = this.record.newField(
+            {tag: '999', subfields: [[' ', '', 0]]});
+
+        this.insertField(field, newField, before);
+    }
+
+    insertField(contextField: MarcField, newField: MarcField, before?: boolean) {
+
+        if (before) {
+            this.record.insertFieldsBefore(contextField, newField);
+            this.focusPreviousTag(contextField);
+
+        } else {
+            this.record.insertFieldsAfter(contextField, newField);
+            this.focusNextTag(contextField);
+        }
+
+        this.trackStructuralUndo(newField, true);
+    }
+
+    // Adds a new empty subfield to the provided field at the
+    // requested subfield position
+    insertSubfield(field: MarcField, 
+        subfield: MarcSubfield, skipTracking?: boolean) {
+        const position = subfield[2];
+
+        // array index 3 contains that position of the subfield
+        // in the MARC field.  When splicing a new subfield into
+        // the set, be sure the any that come after the new one
+        // have their positions bumped to reflect the shift.
+        field.subfields.forEach(
+            sf => {if (sf[2] >= position) { sf[2]++; }});
+
+        field.subfields.splice(position, 0, subfield);
+
+        if (!skipTracking) {
+            this.focusSubfield(field, position);
+            this.trackStructuralUndo(field, true, subfield);
+        }
+    }
+
+    insertStubSubfield(field: MarcField, position: number) {
+        const newSf: MarcSubfield = [' ', '', position];
+        this.insertSubfield(field, newSf);
+    }
+    
+    // Focus the requested subfield by its position.  If its 
+    // position is less than zero, focus the field's tag instead.
+    focusSubfield(field: MarcField, position: number) {
+
+        const focus: FieldFocusRequest = {fieldId: field.fieldId, target: 'tag'};
+
+        if (position >= 0) { 
+            // Focus the code instead of the value, because attempting to
+            // focus an empty (editable) div results in nothing getting focus.
+            focus.target = 'sfc';
+            focus.sfOffset = position; 
+        }
+
+        this.requestFieldFocus(focus);
+    }
+
+    deleteSubfield(field: MarcField, subfield: MarcSubfield) {
+        const sfpos = subfield[2] - 1; // previous subfield
+
+        this.trackStructuralUndo(field, false, subfield);
+
+        field.deleteExactSubfields(subfield);
+
+        this.focusSubfield(field, sfpos);
+    }
+
+    focusTag(field: MarcField) {
+        this.requestFieldFocus({fieldId: field.fieldId, target: 'tag'});
+    }
+
+    // Returns true if the field has a next tag to focus
+    focusNextTag(field: MarcField) {
+        const nextField = this.record.getNextField(field.fieldId);
+        if (nextField) { 
+            this.focusTag(nextField); 
+            return true;
+        }
+        return false;
+    }
+
+    // Returns true if the field has a previous tag to focus
+    focusPreviousTag(field: MarcField): boolean {
+        const prevField = this.record.getPreviousField(field.fieldId);
+        if (prevField) {
+            this.focusTag(prevField);
+            return true;
+        }
+        return false;
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.html b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.html
index 55c5af69ea..dea85817e1 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.html
@@ -19,6 +19,13 @@
 
 <div class="row d-flex p-2 m-2">
   <div class="flex-1"></div>
+
+  <h3 class="mr-2">
+    <span class="badge badge-light p-2" i18n>
+      Record Type {{record ? record.recordType() : ''}}
+    </span>
+  </h3>
+    
   <div class="mr-2">
     <eg-combobox #sourceSelector
       [entries]="sources"
@@ -40,28 +47,19 @@
 
 <div class="row">
   <div class="col-lg-12">
-    <ngb-tabset [activeId]="editorTab">
-      <ngb-tab title="Enhanced MARC Editor" i18n-title id="rich" *ngIf="!inPlaceMode">
+    <ngb-tabset [activeId]="editorTab" (tabChange)="tabChange($event)">
+      <ngb-tab title="Enhanced MARC Editor" i18n-title id="rich">
         <ng-template ngbTabContent>
-          <div class="alert alert-info mt-3" i18n>
-          Enhanced MARC Editor is not yet implemented.  See the
-          <ng-container *ngIf="record && record.id">
-            <a target="_blank"
-              href="/eg/staff/cat/catalog/record/{{record.id}}/marc_edit">
-              AngularJS MARC Editor.
-            </a>
-          </ng-container>
-          <ng-container *ngIf="!record || !record.id">
-            <a target="_blank" href="/eg/staff/cat/catalog/new_bib">
-              AngularJS MARC Editor.
-            </a>
+          <ng-container *ngIf="context && context.record">
+            <eg-marc-rich-editor [context]="context"></eg-marc-rich-editor>
           </ng-container>
-          </div>
         </ng-template>
       </ngb-tab>
       <ngb-tab title="Flat Text Editor" i18n-title id="flat">
         <ng-template ngbTabContent>
-          <eg-marc-flat-editor></eg-marc-flat-editor>
+          <ng-container *ngIf="context && context.record">
+            <eg-marc-flat-editor [context]="context"></eg-marc-flat-editor>
+          </ng-container>
         </ng-template>
       </ngb-tab>
     </ngb-tabset>
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.ts
index 44da299df0..138161611d 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.ts
@@ -6,11 +6,14 @@ import {AuthService} from '@eg/core/auth.service';
 import {OrgService} from '@eg/core/org.service';
 import {PcrudService} from '@eg/core/pcrud.service';
 import {ToastService} from '@eg/share/toast/toast.service';
+import {ServerStoreService} from '@eg/core/server-store.service';
 import {StringComponent} from '@eg/share/string/string.component';
 import {MarcRecord} from './marcrecord';
 import {ComboboxEntry, ComboboxComponent
   } from '@eg/share/combobox/combobox.component';
 import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component';
+import {MarcEditContext} from './editor-context';
+import {NgbTabset, NgbTabChangeEvent} from '@ng-bootstrap/ng-bootstrap';
 
 interface MarcSavedEvent {
     marcXml: string;
@@ -28,9 +31,11 @@ interface MarcSavedEvent {
 
 export class MarcEditorComponent implements OnInit {
 
-    record: MarcRecord;
     editorTab: 'rich' | 'flat';
     sources: ComboboxEntry[];
+    context: MarcEditContext;
+
+    @Input() recordType: 'biblio' | 'authority' = 'biblio';
 
     @Input() set recordId(id: number) {
         if (!id) { return; }
@@ -39,7 +44,13 @@ export class MarcEditorComponent implements OnInit {
     }
 
     @Input() set recordXml(xml: string) {
-        if (xml) { this.fromXml(xml); }
+        if (xml) {
+            this.fromXml(xml);
+        }
+    }
+
+    get record(): MarcRecord {
+        return this.context.record;
     }
 
     // Tell us which record source to select by default.
@@ -70,15 +81,20 @@ export class MarcEditorComponent implements OnInit {
         private auth: AuthService,
         private org: OrgService,
         private pcrud: PcrudService,
-        private toast: ToastService
+        private toast: ToastService,
+        private store: ServerStoreService
     ) {
         this.sources = [];
         this.recordSaved = new EventEmitter<MarcSavedEvent>();
+        this.context = new MarcEditContext();
     }
 
     ngOnInit() {
-        // Default to flat for now since it's all that's supported.
-        this.editorTab = 'flat';
+
+        this.context.recordType = this.recordType;
+
+        this.store.getItem('cat.marcedit.flateditor').then(
+            useFlat => this.editorTab = useFlat ? 'flat' : 'rich');
 
         this.pcrud.retrieveAll('cbs').subscribe(
             src => this.sources.push({id: +src.id(), label: src.source()}),
@@ -95,6 +111,20 @@ export class MarcEditorComponent implements OnInit {
         );
     }
 
+    // Remember the last used tab as the preferred tab.
+    tabChange(evt: NgbTabChangeEvent) {
+
+        // Avoid undo persistence across tabs since that could result
+        // in changes getting lost.
+        this.context.resetUndos();
+
+        if (evt.nextId === 'flat') {
+            this.store.setItem('cat.marcedit.flateditor', true);
+        } else {
+            this.store.removeItem('cat.marcedit.flateditor');
+        }
+    }
+
     saveRecord(): Promise<any> {
         const xml = this.record.toXml();
 
@@ -140,7 +170,7 @@ export class MarcEditorComponent implements OnInit {
     fromId(id: number): Promise<any> {
         return this.pcrud.retrieve('bre', id)
         .toPromise().then(bib => {
-            this.record = new MarcRecord(bib.marc());
+            this.context.record = new MarcRecord(bib.marc());
             this.record.id = id;
             this.record.deleted = bib.deleted() === 't';
             if (bib.source()) {
@@ -150,7 +180,7 @@ export class MarcEditorComponent implements OnInit {
     }
 
     fromXml(xml: string) {
-        this.record = new MarcRecord(xml);
+        this.context.record = new MarcRecord(xml);
         this.record.id = null;
     }
 
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-field.component.css b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-field.component.css
new file mode 100644
index 0000000000..88b31f275a
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-field.component.css
@@ -0,0 +1,20 @@
+
+:host >>> .popover {
+  font-family: 'Lucida Console', Monaco, monospace;
+  max-width: 550px;
+}
+
+:host >>> .popover-body {
+  max-height: 400px;
+  overflow-y: auto;
+  overflow-x: auto;
+}
+
+:host >>> .popover-body .menu-entry {
+  white-space: nowrap;
+}
+
+:host >>> .popover-body .menu-entry:hover {
+  background-color: #f8f9fa; /* bootstrap color */
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-field.component.html b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-field.component.html
new file mode 100644
index 0000000000..e2e8976197
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-field.component.html
@@ -0,0 +1,16 @@
+
+<ng-container *ngIf="fieldMeta">
+
+  <div class="d-flex">
+    <div class="flex-4">
+      <span id='label-{{randId}}' class="text-left font-weight-bold">
+        {{fieldLabel}}
+      </span>
+    </div>
+      <div class="flex-5">
+        <eg-marc-editable-content [context]="context"
+          [fixedFieldCode]="fieldCode" fieldType="ffld" moreClasses="p-1">
+        </eg-marc-editable-content>
+      </div>
+  </div>
+</ng-container>
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-field.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-field.component.ts
new file mode 100644
index 0000000000..a81255dc6d
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-field.component.ts
@@ -0,0 +1,46 @@
+import {Component, Input, Output, OnInit, EventEmitter} from '@angular/core';
+import {IdlObject} from '@eg/core/idl.service';
+import {MarcRecord} from './marcrecord';
+import {MarcEditContext} from './editor-context';
+import {TagTableService} from './tagtable.service';
+
+/**
+ * MARC Fixed Field Editing Component
+ */
+
+ at Component({
+  selector: 'eg-fixed-field',
+  templateUrl: './fixed-field.component.html',
+  styleUrls: ['fixed-field.component.css']
+})
+
+export class FixedFieldComponent implements OnInit {
+
+    @Input() fieldCode: string;
+    @Input() fieldLabel: string;
+    @Input() context: MarcEditContext;
+
+    get record(): MarcRecord { return this.context.record; }
+
+    fieldMeta: IdlObject;
+    randId = Math.floor(Math.random() * 10000000);
+
+    constructor(private tagTable: TagTableService) {}
+
+    ngOnInit() {
+        this.init().then(_ =>
+            this.context.recordChange.subscribe(__ => this.init()));
+    }
+
+    init(): Promise<any> {
+        if (!this.record) { return Promise.resolve(); }
+
+        // If no field metadata is found for this fixed field code and
+        // record type combo, the field will be hidden in the UI.
+        return this.tagTable.getFfFieldMeta(
+            this.fieldCode, this.record.recordType())
+        .then(fieldMeta => this.fieldMeta = fieldMeta);
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-fields-editor.component.html b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-fields-editor.component.html
new file mode 100644
index 0000000000..97d866f54a
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-fields-editor.component.html
@@ -0,0 +1,281 @@
+<div class="row p-0">
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Type" fieldLabel="Type"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="ELvl" fieldLabel="ELvl"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Source" fieldLabel="Source"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Audn" fieldLabel="Audn"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Ctrl" fieldLabel="Ctrl"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Lang" fieldLabel="Lang"></eg-fixed-field>
+  </div>
+</div>
+<div class="row p-0">
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="BLvl" fieldLabel="BLvl"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Form" fieldLabel="Form"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Conf" fieldLabel="Conf"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Biog" fieldLabel="Biog"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="MRec" fieldLabel="MRec"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Ctry" fieldLabel="Ctry"></eg-fixed-field>
+  </div>
+</div>
+<div class="row p-0">
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="s_l" fieldLabel="s_l"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Cont" fieldLabel="Cont"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="GPub" fieldLabel="GPub"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="LitF" fieldLabel="LitF"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Indx" fieldLabel="Indx"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Alph" fieldLabel="Alph"></eg-fixed-field>
+  </div>
+</div>
+<div class="row p-0">
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Desc" fieldLabel="Desc"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Ills" fieldLabel="Ills"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Fest" fieldLabel="Fest"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="DtSt" fieldLabel="DtSt"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Date1" fieldLabel="Date1"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Date2" fieldLabel="Date2"></eg-fixed-field>
+  </div>
+</div>
+<div class="row p-0">
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="SrTp" fieldLabel="SrTp"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Regl" fieldLabel="Regl"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Orig" fieldLabel="Orig"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Freq" fieldLabel="Freq"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="EntW" fieldLabel="EntW"></eg-fixed-field>
+  </div>
+</div>
+<div class="row p-0">
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="TrAr" fieldLabel="TrAr"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Part" fieldLabel="Part"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="LTxt" fieldLabel="LTxt"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="FMus" fieldLabel="FMus"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="AccM" fieldLabel="AccM"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Comp" fieldLabel="Comp"></eg-fixed-field>
+  </div>
+</div>
+<div class="row p-0">
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="SpFm" fieldLabel="SpFm"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Relf" fieldLabel="Relf"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Proj" fieldLabel="Proj"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="CrTp" fieldLabel="CrTp"></eg-fixed-field>
+  </div>
+</div>
+<div class="row p-0">
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="TMat" fieldLabel="TMat"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Time" fieldLabel="Time"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Tech" fieldLabel="Tech"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="File" fieldLabel="File"></eg-fixed-field>
+  </div>
+</div>
+<div class="row p-0">
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Type_tbmfhd" fieldLabel="Type_tbmfhd"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="ELvl_tbmfhd" fieldLabel="ELvl_tbmfhd"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Item_tbmfhd" fieldLabel="Item_tbmfhd"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="GeoDiv" fieldLabel="GeoDiv"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Roman" fieldLabel="Roman"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="CatLang" fieldLabel="CatLang"></eg-fixed-field>
+  </div>
+</div>
+<div class="row p-0">
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Kind" fieldLabel="Kind"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Rules" fieldLabel="Rules"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Subj" fieldLabel="Subj"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Series" fieldLabel="Series"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="SerNum" fieldLabel="SerNum"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="NameUse" fieldLabel="NameUse"></eg-fixed-field>
+  </div>
+</div>
+<div class="row p-0">
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="SubjUse" fieldLabel="SubjUse"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="SerUse" fieldLabel="SerUse"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="TypeSubd" fieldLabel="TypeSubd"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="GovtAgn" fieldLabel="GovtAgn"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="RefStatus" fieldLabel="RefStatus"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="UpdStatus" fieldLabel="UpdStatus"></eg-fixed-field>
+  </div>
+</div>
+<div class="row p-0">
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Name" fieldLabel="Name"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Status" fieldLabel="Status"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="ModRec" fieldLabel="ModRec"></eg-fixed-field>
+  </div>
+</div>
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-fields-editor.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-fields-editor.component.ts
new file mode 100644
index 0000000000..d02981606b
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-fields-editor.component.ts
@@ -0,0 +1,31 @@
+import {Component, Input, Output, OnInit, AfterViewInit, EventEmitter,
+    OnDestroy} from '@angular/core';
+import {IdlService} from '@eg/core/idl.service';
+import {OrgService} from '@eg/core/org.service';
+import {MarcRecord} from './marcrecord';
+import {MarcEditContext} from './editor-context';
+import {TagTableService} from './tagtable.service';
+
+/**
+ * MARC Fixed Fields Editor Component
+ */
+
+ at Component({
+  selector: 'eg-fixed-fields-editor',
+  templateUrl: './fixed-fields-editor.component.html'
+})
+
+export class FixedFieldsEditorComponent implements OnInit {
+
+    @Input() context: MarcEditContext;
+    get record(): MarcRecord { return this.context.record; }
+
+    constructor(
+        private idl: IdlService,
+        private org: OrgService,
+        private tagTable: TagTableService
+    ) {}
+
+    ngOnInit() {}
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/flat-editor.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/flat-editor.component.ts
index b5e2f41277..465a738eb2 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/flat-editor.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/flat-editor.component.ts
@@ -1,9 +1,9 @@
-import {Component, Input, OnInit, Host} from '@angular/core';
+import {Component, Input, OnInit} from '@angular/core';
 import {IdlService} from '@eg/core/idl.service';
 import {OrgService} from '@eg/core/org.service';
 import {ServerStoreService} from '@eg/core/server-store.service';
-import {MarcEditorComponent} from './editor.component';
 import {MarcRecord} from './marcrecord';
+import {MarcEditContext} from './editor-context';
 
 /**
  * MARC Record flat text (marc-breaker) editor.
@@ -17,19 +17,22 @@ import {MarcRecord} from './marcrecord';
 
 export class MarcFlatEditorComponent implements OnInit {
 
+    @Input() context: MarcEditContext;
     get record(): MarcRecord {
-        return this.editor.record;
+        return this.context.record;
     }
 
     constructor(
         private idl: IdlService,
         private org: OrgService,
-        private store: ServerStoreService,
-        @Host() private editor: MarcEditorComponent
-    ) {
-    }
+        private store: ServerStoreService
+    ) {}
 
-    ngOnInit() {}
+    ngOnInit() {
+        // Be sure changes made in the enriched editor are
+        // reflected here.
+        this.record.breakerText = this.record.toBreaker();
+    }
 
     // When we have breaker text, limit the vertical expansion of the
     // text area to the size of the data plus a little padding.
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marc-edit.module.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marc-edit.module.ts
index a18eb0b7a4..c7bbaba481 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marc-edit.module.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marc-edit.module.ts
@@ -1,22 +1,32 @@
 import {NgModule} from '@angular/core';
 import {StaffCommonModule} from '@eg/staff/common.module';
+import {CommonWidgetsModule} from '@eg/share/common-widgets.module';
 import {MarcEditorComponent} from './editor.component';
 import {MarcRichEditorComponent} from './rich-editor.component';
 import {MarcFlatEditorComponent} from './flat-editor.component';
+import {FixedFieldsEditorComponent} from './fixed-fields-editor.component';
+import {FixedFieldComponent} from './fixed-field.component';
+import {TagTableService} from './tagtable.service';
+import {EditableContentComponent} from './editable-content.component';
 
 @NgModule({
     declarations: [
         MarcEditorComponent,
         MarcRichEditorComponent,
-        MarcFlatEditorComponent
+        MarcFlatEditorComponent,
+        FixedFieldsEditorComponent,
+        FixedFieldComponent,
+        EditableContentComponent
     ],
     imports: [
-        StaffCommonModule
+        StaffCommonModule,
+        CommonWidgetsModule
     ],
     exports: [
         MarcEditorComponent
     ],
     providers: [
+        TagTableService
     ]
 })
 
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marcrecord.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marcrecord.ts
index df1a492762..61b716929b 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marcrecord.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marcrecord.ts
@@ -1,12 +1,29 @@
-/**
-  * Simple wrapper class for our external MARC21.Record JS library.
-  */
+import {EventEmitter} from '@angular/core';
+
+/* Wrapper class for our external MARC21.Record JS library. */
 
 declare var MARC21;
 
 // MARC breaker delimiter
 const DELIMITER = '$';
 
+export interface MarcSubfield  // code, value, position
+    extends Array<string|number>{0: string; 1: string; 2: number}
+
+// Only contains the attributes/methods we need so far.
+export interface MarcField {
+    fieldId?: number;
+    data?: string;
+    tag?: string;
+    ind1?: string;
+    ind2?: string;
+    subfields?: MarcSubfield[]; 
+
+    isControlfield(): boolean;
+
+    deleteExactSubfields(...subfield: MarcSubfield[]): number;
+}
+
 export class MarcRecord {
 
     id: number; // Database ID when known.
@@ -14,9 +31,30 @@ export class MarcRecord {
     record: any; // MARC21.Record object
     breakerText: string;
 
+    // Let clients know some fixed field shuffling may have occured.
+    // Emits the fixed field code.
+    fixedFieldChange: EventEmitter<string>;
+
+    get leader(): string {
+        return this.record.leader;
+    }
+
+    set leader(l: string) {
+        this.record.leader = l;
+    }
+
+    get fields(): MarcField[] {
+       return this.record.fields;
+    }
+
+    set fields(f: MarcField[]) {
+        this.record.fields = f;
+    }
+
     constructor(xml: string) {
         this.record = new MARC21.Record({marcxml: xml, delimiter: DELIMITER});
         this.breakerText = this.record.toBreaker();
+        this.fixedFieldChange = new EventEmitter<string>();
     }
 
     toXml(): string {
@@ -27,9 +65,112 @@ export class MarcRecord {
         return this.record.toBreaker();
     }
 
+    recordType(): string {
+        return this.record.recordType();
+    }
+
     absorbBreakerChanges() {
         this.record = new MARC21.Record(
             {marcbreaker: this.breakerText, delimiter: DELIMITER});
     }
+
+    extractFixedField(fieldCode: string): string {
+        return this.record.extractFixedField(fieldCode);
+    }
+
+    setFixedField(fieldCode: string, fieldValue: string): string {
+        const response = this.record.setFixedField(fieldCode, fieldValue);
+        this.fixedFieldChange.emit(fieldCode);
+        return response;
+    }
+
+    // Give each field an identifier so it may be referenced later.
+    stampFieldIds() {
+        this.fields.forEach(f => this.stampFieldId(f));
+    }
+
+    stampFieldId(field: MarcField) {
+        if (!field.fieldId) {
+            field.fieldId = Math.floor(Math.random() * 10000000);
+        }
+    }
+
+    field(spec: string, wantArray?: boolean): MarcField | MarcField[] {
+        return this.record.field(spec, wantArray);
+    }
+
+    insertFieldsBefore(field: MarcField, ...newFields: MarcField[]) {
+        this.record.insertFieldsBefore.apply(
+            this.record, [field].concat(newFields));
+        this.stampFieldIds();
+    }
+
+    insertFieldsAfter(field: MarcField, ...newFields: MarcField[]) {
+        this.record.insertFieldsAfter.apply(
+            this.record, [field].concat(newFields));
+        this.stampFieldIds();
+    }
+
+    insertOrderedFields(...newFields: MarcField[]) {
+        this.record.insertOrderedFields.apply(this.record, newFields);
+        this.stampFieldIds();
+    }
+
+    generate008(): MarcField {
+        return this.record.generate008();
+    }
+
+
+    deleteFields(...fields: MarcField[]) {
+        this.record.deleteFields.apply(this.record, fields);
+    }
+
+    getField(id: number): MarcField {
+        return this.fields.filter(f => f.fieldId === id)[0];
+    }
+
+    getPreviousField(id: number): MarcField {
+        for (let idx = 0; idx < this.fields.length; idx++) {
+            if (this.fields[idx].fieldId === id) {
+                return this.fields[idx - 1];
+            }
+        }
+    }
+
+    getNextField(id: number): MarcField {
+        for (let idx = 0; idx < this.fields.length; idx++) {
+            if (this.fields[idx].fieldId === id) {
+                return this.fields[idx + 1];
+            }
+        }
+    }
+
+    // Turn an field-ish object into a proper MARC.Field
+    newField(props: any): MarcField {
+        const field = new MARC21.Field(props);
+        this.stampFieldId(field);
+        return field;
+    }
+
+    cloneField(field: any): MarcField {
+        const props: any = {tag: field.tag};
+
+        if (field.isControlfield()) {
+            props.data = field.data;
+
+        } else {
+            props.ind1 = field.ind1;
+            props.ind2 = field.ind2;
+            props.subfields = this.cloneSubfields(field.subfields);
+        }
+
+        return this.newField(props);
+    }
+
+    cloneSubfields(subfields: MarcSubfield[]): MarcSubfield[] {
+        const root = [];
+        subfields.forEach(sf => root.push([].concat(sf)));
+        return root;
+    }
 }
 
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.css b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.css
index e69de29bb2..5c90f28b9b 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.css
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.css
@@ -0,0 +1,8 @@
+
+.fixed-fields-container {
+  /*
+   * wait for https://bugs.launchpad.net/evergreen/+bug/1735568 approval
+  background-color: lightcyan;
+  border-bottom: 1px solid gray;
+  */
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.html b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.html
index e69de29bb2..cf214424d6 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.html
@@ -0,0 +1,139 @@
+
+<ng-container *ngIf="!dataLoaded">
+  <div class="row mt-5">
+    <div class="offset-lg-3 col-lg-6">
+      <eg-progress-inline></eg-progress-inline>
+    </div>
+  </div>
+</ng-container>
+
+<ng-container *ngIf="dataLoaded">
+  <div class="mt-3 text-monospace"
+    (contextmenu)="$event.preventDefault()">
+    <div class="row pb-2 mb-2 border-bottom border-muted">
+      <div class="col-lg-9 fixed-fields-container">
+        <eg-fixed-fields-editor [context]="context"></eg-fixed-fields-editor>
+      </div>
+      <div class="col-lg-3">
+        <div><button class="btn btn-outline-dark"
+          (click)="showHelp = !showHelp" i18n>Help</button></div>
+        <div class="mt-2"><button class="btn btn-outline-dark"
+          [disabled]="true"
+          (click)="validate()" i18n>Validate</button></div>
+        <div class="mt-2">
+          <button type="button" class="btn btn-outline-info" 
+            [disabled]="undoCount() < 1" (click)="undo()">
+            Undo <span class="badge badge-info">{{undoCount()}}</span>
+          </button>
+          <button type="button" class="btn btn-outline-info ml-2" 
+            [disabled]="redoCount() < 1" (click)="redo()">
+            Redo <span class="badge badge-info">{{redoCount()}}</span>
+          </button>
+        </div>
+        <div class="mt-2">
+          <div class="form-check">
+            <input class="form-check-input" type="checkbox" 
+              [disabled]="true"
+              [(ngModel)]="stackSubfields" id="stack-subfields-{{randId}}">
+            <label class="form-check-label" for="stack-subfields-{{randId}}">
+              Stack Subfields
+            </label>
+          </div>
+        </div>
+      </div>
+      <div class="col-lg-1">
+      </div>
+    </div>
+    <div *ngIf="showHelp" class="row m-2">
+      <div class="col-lg-4">
+        <ul>
+          <li>Undo: CTRL-z</li>
+          <li>Redo: CTRL-y</li>
+          <li>Add Row: CTRL+Enter</li>
+          <li>Insert Row: CTRL+Shift+Enter</li>
+        </ul>
+      </div>
+      <div class="col-lg-4">
+        <ul>
+         <li>Copy Current Row Above: CTRL+Up</li>
+         <li>Copy Current Row Below: CTRL+Down</li>
+         <li>Add Subfield: CTRL+D or CTRL+I</li>
+         <li>Remove Row: CTRL+Del</li>
+        </ul>
+      </div>
+      <div class="col-lg-4">
+        <ul>
+         <li>Remove Subfield: Shift+Del</li>
+         <li>Create/Replace 006: Shift+F6</li>
+         <li>Create/Replace 007: Shift+F7</li>
+         <li>Create/Replace 008: Shift+F8</li>
+        </ul>
+      </div>
+    </div>
+
+    <!-- LEADER -->
+    <div class="row pt-0 pb-0 pl-3 form-horizontal">
+      <eg-marc-editable-content [context]="context" fieldType="tag" 
+        fieldText="LDR" i18n-fieldText moreClasses="p-1">
+      </eg-marc-editable-content>
+
+      <eg-marc-editable-content [context]="context" fieldType="ldr"
+         moreClasses="p-1 pr-2">
+      </eg-marc-editable-content>
+    </div>
+
+    <!-- CONTROL FIELDS -->
+    <div class="row pt-0 pb-0 pl-3 form-horizontal" 
+      *ngFor="let field of controlFields()">
+
+      <eg-marc-editable-content [context]="context" fieldType="tag"
+        [field]="field" moreClasses="p-1">
+      </eg-marc-editable-content>
+
+      <eg-marc-editable-content [context]="context" fieldType="cfld"
+        [field]="field" moreClasses="p-1">
+      </eg-marc-editable-content>
+    </div>
+
+    <!-- data fields -->
+    <div class="row pt-0 pb-0 pl-3 form-horizontal" 
+      *ngFor="let field of dataFields()">
+
+      <!-- TAG -->
+      <eg-marc-editable-content [context]="context" fieldType="tag"
+        [field]="field" moreClasses="p-1">
+      </eg-marc-editable-content>
+
+      <!-- INDICATOR 1 -->
+      <eg-marc-editable-content [context]="context" fieldType="ind1" 
+        [field]="field" moreClasses="p-1">
+      </eg-marc-editable-content>
+
+      <!-- INDICATOR 2 -->
+      <eg-marc-editable-content [context]="context" fieldType="ind2" 
+        [field]="field" moreClasses="p-1">
+      </eg-marc-editable-content>
+
+      <!-- SUBFIELDS -->
+      <ng-container *ngFor="let subfield of field.subfields">
+
+        <!-- SUBFIELD DECORATOR/DELIMITER -->
+        <eg-marc-editable-content fieldText="‡" i18n-fieldText
+          moreClasses="sf-delimiter border-right-0 bg-transparent p-1 pr-0">
+        </eg-marc-editable-content>
+
+        <!-- SUBFIELD CHARACTER -->
+        <eg-marc-editable-content [context]="context" fieldType="sfc" 
+          [field]="field" [subfield]="subfield" 
+          moreClasses="sf-code border-left-0 p-1 pl-0">
+        </eg-marc-editable-content>
+
+        <!-- SUBFIELD VALUE -->
+        <eg-marc-editable-content [context]="context" fieldType="sfv"
+          [field]="field" [subfield]="subfield" moreClasses="p-1 pt-2">
+        </eg-marc-editable-content>
+      </ng-container>
+    </div>
+  </div>
+</ng-container>
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.ts
index 7f8ac334e3..1c50c57c4f 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.ts
@@ -2,6 +2,10 @@ import {Component, Input, Output, OnInit, AfterViewInit, EventEmitter,
     OnDestroy} from '@angular/core';
 import {IdlService} from '@eg/core/idl.service';
 import {OrgService} from '@eg/core/org.service';
+import {TagTableService} from './tagtable.service';
+import {MarcRecord, MarcField} from './marcrecord';
+import {MarcEditContext} from './editor-context';
+
 
 /**
  * MARC Record rich editor interface.
@@ -15,13 +19,60 @@ import {OrgService} from '@eg/core/org.service';
 
 export class MarcRichEditorComponent implements OnInit {
 
+    @Input() context: MarcEditContext;
+    get record(): MarcRecord { return this.context.record; }
+
+    dataLoaded: boolean;
+    showHelp: boolean;
+    randId = Math.floor(Math.random() * 100000);
+    stackSubfields: boolean;
+
     constructor(
         private idl: IdlService,
-        private org: OrgService
-    ) {
+        private org: OrgService,
+        private tagTable: TagTableService
+    ) {}
+
+    ngOnInit() {
+        this.init().then(_ =>
+            this.context.recordChange.subscribe(__ => this.init()));
+    }
+
+    init(): Promise<any> {
+        this.dataLoaded = false;
+
+        if (!this.record) { return Promise.resolve(); }
+
+        return Promise.all([
+            this.tagTable.loadTagTable({marcRecordType: this.context.recordType}),
+            this.tagTable.getFfPosTable(this.record.recordType()),
+            this.tagTable.getFfValueTable(this.record.recordType())
+        ]).then(_ => this.dataLoaded = true);
+    }
+
+    undoCount(): number {
+        return this.context.undoStack.length;
     }
 
-    ngOnInit() {}
+    redoCount(): number {
+        return this.context.redoStack.length;
+    }
+
+    undo() {
+        this.context.requestUndo();
+    }
+
+    redo() {
+        this.context.requestRedo();
+    }
+
+    controlFields(): MarcField[] {
+        return this.record.fields.filter(f => f.isControlfield());
+    }
+
+    dataFields(): MarcField[] {
+        return this.record.fields.filter(f => !f.isControlfield());
+    }
 }
 
 
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/tagtable.service.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/tagtable.service.ts
new file mode 100644
index 0000000000..ec5abd04b3
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/tagtable.service.ts
@@ -0,0 +1,274 @@
+import {Injectable, EventEmitter} from '@angular/core';
+import {map, tap} from 'rxjs/operators';
+import {StoreService} from '@eg/core/store.service';
+import {IdlObject} from '@eg/core/idl.service';
+import {AuthService} from '@eg/core/auth.service';
+import {NetService} from '@eg/core/net.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {EventService} from '@eg/core/event.service';
+import {ContextMenuEntry} from '@eg/share/context-menu/context-menu.service';
+
+interface TagTableSelector {
+    marcFormat?: string;
+    marcRecordType?: string;
+}
+
+const defaultTagTableSelector: TagTableSelector = {
+    marcFormat     : 'marc21',
+    marcRecordType : 'biblio'
+}
+
+ at Injectable()
+export class TagTableService {
+
+    // Current set of tags in list and map form.
+    tagMap: {[tag: string]: any} = {};
+    ffPosMap: {[rtype: string]: any[]} = {};
+    ffValueMap: {[rtype: string]: any} = {};
+
+    extractedValuesCache: 
+        {[valueType: string]: {[which: string]: any}} = {};
+
+    constructor(
+        private store: StoreService,
+        private auth: AuthService,
+        private net: NetService,
+        private pcrud: PcrudService,
+        private evt: EventService
+    ) {
+
+        this.extractedValuesCache = {
+            fieldtags: {},
+            indicators: {},
+            sfcodes: {},
+            sfvalues: {},
+            ffvalues: {}
+        };
+    }
+
+    // Various data needs munging for display.  Cached the modified
+    // values since they are refernced repeatedly by the UI code.
+    fromCache(dataType: string, which?: string, which2?: string): ContextMenuEntry[] {
+        const part1 = this.extractedValuesCache[dataType][which];
+        if (which2) {
+            if (part1) {
+                return part1[which2];
+            }
+        } else {
+            return part1;
+        }
+    }
+
+    toCache(dataType: string, which: string, 
+        which2: string, values: ContextMenuEntry[]): ContextMenuEntry[] {
+        const base = this.extractedValuesCache[dataType];
+        const part1 = base[which];
+
+        if (which2) {
+            if (!base[which]) { base[which] = {}; }
+            base[which][which2] = values;
+        } else {
+            base[which] = values;
+        }
+
+        return values;
+    }
+
+    getFfPosTable(rtype: string): Promise<any> {
+        const storeKey = 'FFPosTable_' + rtype;
+
+        if (this.ffPosMap[rtype]) {
+            return Promise.resolve(this.ffPosMap[rtype]);
+        }
+
+        this.ffPosMap[rtype] = this.store.getLocalItem(storeKey);
+
+        if (this.ffPosMap[rtype]) {
+            return Promise.resolve(this.ffPosMap[rtype]);
+        }
+
+        return this.net.request(
+            'open-ils.fielder', 'open-ils.fielder.cmfpm.atomic',
+            {query: {tag: {'!=' : '006'}, rec_type: rtype}}
+
+        ).toPromise().then(table => {
+            this.store.setLocalItem(storeKey, table);
+            return this.ffPosMap[rtype] = table;
+        });
+    }
+
+    getFfValueTable(rtype: string): Promise<any> {
+
+        const storeKey = 'FFValueTable_' + rtype;
+
+        if (this.ffValueMap[rtype]) {
+            return Promise.resolve(this.ffValueMap[rtype]);
+        }
+
+        this.ffValueMap[rtype] = this.store.getLocalItem(storeKey);
+
+        if (this.ffValueMap[rtype]) {
+            return Promise.resolve(this.ffValueMap[rtype]);
+        }
+
+        return this.net.request(
+            'open-ils.cat',
+            'open-ils.cat.biblio.fixed_field_values.by_rec_type', rtype
+
+        ).toPromise().then(table => {
+            this.store.setLocalItem(storeKey, table);
+            return this.ffValueMap[rtype] = table;
+        });
+    }
+
+    loadTagTable(selector?: TagTableSelector): Promise<any> {
+
+        if (selector) {
+            if (!selector.marcFormat) {
+                selector.marcFormat = defaultTagTableSelector.marcFormat;
+            }
+            if (!selector.marcRecordType) {
+                selector.marcRecordType = 
+                    defaultTagTableSelector.marcRecordType;
+            }
+        } else {
+            selector = defaultTagTableSelector;
+        }
+
+        const cacheKey = 'FFValueTable_' + selector.marcRecordType;
+
+        this.tagMap = this.store.getLocalItem(cacheKey);
+
+        if (this.tagMap) {
+            return Promise.resolve(this.tagMap);
+        }
+
+        return this.fetchTagTable(selector).then(_ => {
+            this.store.setLocalItem(cacheKey, this.tagMap);
+            return this.tagMap;
+        });
+    }
+
+    fetchTagTable(selector?: TagTableSelector): Promise<any> {
+        this.tagMap = [];
+        return this.net.request(
+            'open-ils.cat',
+            'open-ils.cat.tag_table.all.retrieve.local',
+            this.auth.token(), selector.marcFormat, selector.marcRecordType
+        ).pipe(tap(tagData => {
+            this.tagMap[tagData.tag] = tagData;
+        })).toPromise();
+    }
+
+    getSubfieldCodes(tag: string): ContextMenuEntry[] { 
+        if (!tag || !this.tagMap[tag]) { return null; }
+
+        const cached = this.fromCache('sfcodes', tag);
+
+        const list = this.tagMap[tag].subfields.map(sf => ({
+            value: sf.code, 
+            label: `${sf.code}: ${sf.description}`
+        })) 
+        .sort((a, b) => a.label < b.label ? -1 : 1);
+
+        return this.toCache('sfcodes', tag, null, list);
+    }
+
+    getFieldTags(): ContextMenuEntry[] {
+
+        const cached = this.fromCache('fieldtags');
+        if (cached) { return cached; }
+        
+        return Object.keys(this.tagMap)
+        .filter(tag => Boolean(this.tagMap[tag]))
+        .map(tag => ({
+            value: tag,
+            label: `${tag}: ${this.tagMap[tag].name}`
+        }))
+        .sort((a, b) => a.label < b.label ? -1 : 1);
+    }
+
+    getSubfieldValues(tag: string, sfCode: string): ContextMenuEntry[] {
+        if (!tag || !this.tagMap[tag]) { return []; }
+
+        const cached = this.fromCache('sfvalues', tag, sfCode)
+        if (cached) { return cached; }
+
+        const list: ContextMenuEntry[] = [];
+
+        this.tagMap[tag].subfields
+        .filter(sf =>
+            sf.code === sfCode && sf.hasOwnProperty('value_list'))
+        .forEach(sf => {
+            sf.value_list.forEach(value => {
+
+                let label = value.description || value.code;
+                let code = value.code || label;
+                if (code !== label) { label = `${code}: ${label}`; }
+
+                list.push({value: code, label: label});
+            })
+        });
+
+        return this.toCache('sfvalues', tag, sfCode, list);
+    }
+
+    getIndicatorValues(tag: string, which: 'ind1' | 'ind2'): ContextMenuEntry[] {
+        if (!tag || !this.tagMap[tag]) { return }
+
+        const cached = this.fromCache('indicators', tag, which);
+        if (cached) { return cached; }
+
+        let values = this.tagMap[tag][which];
+        if (!values) { return; }
+
+        values = values.map(value => ({
+            value: value.code,
+            label: `${value.code}: ${value.description}`
+        }))
+        .sort((a, b) => a.label < b.label ? -1 : 1);
+
+        return this.toCache('indicators', tag, which, values);
+    }
+
+
+    getFfFieldMeta(fieldCode: string, recordType: string): Promise<IdlObject> {
+        return this.getFfPosTable(recordType).then(table => {
+
+            // Note the AngJS MARC editor stores the full POS table
+            // for all record types in every copy of the table, hence
+            // the seemingly extraneous check in recordType.
+            return table.filter(
+                field =>
+                    field.fixed_field === fieldCode
+                 && field.rec_type === recordType
+            )[0];
+        });
+    }
+
+
+    // Assumes getFfPosTable and getFfValueTable have already been
+    // invoked for the request record type.
+    getFfValues(fieldCode: string, recordType: string): ContextMenuEntry[] {
+
+        const cached = this.fromCache('ffvalues', recordType, fieldCode);
+        if (cached) { return cached; }
+
+        let values = this.ffValueMap[recordType];
+
+        if (!values || !values[fieldCode]) { return null; }
+
+        // extract the canned set of possible values for our
+        // fixed field.  Ignore those whose value exceeds the
+        // specified field length.
+        values = values[fieldCode]
+            .filter(val => val[0].length <= val[2])
+            .map(val => ({value: val[0], label: `${val[0]}: ${val[1]}`}))
+            .sort((a, b) => a.label < b.label ? -1 : 1);
+
+        return this.toCache('ffvalues', recordType, fieldCode, values);
+    }
+}
+
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/staff.component.html b/Open-ILS/src/eg2/src/app/staff/staff.component.html
index 6cc1bc02fd..78657470cf 100644
--- a/Open-ILS/src/eg2/src/app/staff/staff.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/staff.component.html
@@ -25,3 +25,5 @@
 <!-- global print handler component -->
 <eg-print></eg-print>
 
+<!-- context menu DOM insertion point -->
+<eg-context-menu-container></eg-context-menu-container>
diff --git a/Open-ILS/src/eg2/src/styles.css b/Open-ILS/src/eg2/src/styles.css
index ef97e2a93d..9c120cce7d 100644
--- a/Open-ILS/src/eg2/src/styles.css
+++ b/Open-ILS/src/eg2/src/styles.css
@@ -219,3 +219,4 @@ body>.dropdown-menu {z-index: 2100;}
   background-color: #c9efe4;
   color: black;
 }
+

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

Summary of changes:
 Open-ILS/src/eg2/src/app/common.module.ts          |   3 +-
 .../share/catalog/bib-display-field.component.css  |  11 +
 .../share/catalog/bib-display-field.component.html |   7 +
 .../share/catalog/bib-display-field.component.ts   |  62 ++
 .../src/app/share/catalog/bib-record.service.ts    |   1 +
 .../src/app/share/catalog/catalog-common.module.ts |  11 +-
 .../src/app/share/catalog/catalog-url.service.ts   |   2 +
 .../eg2/src/app/share/catalog/catalog.service.ts   |  92 ++-
 .../eg2/src/app/share/catalog/search-context.ts    |  13 +
 .../src/app/share/combobox/combobox.component.ts   |  23 +-
 .../src/eg2/src/app/share/common-widgets.module.ts |   9 +-
 .../context-menu-container.component.css           |  27 +
 .../context-menu-container.component.html          |  15 +
 .../context-menu-container.component.ts            |  36 ++
 .../share/context-menu/context-menu.directive.ts   | 107 ++++
 .../app/share/context-menu/context-menu.module.ts  |  24 +
 .../app/share/context-menu/context-menu.service.ts |  33 ++
 Open-ILS/src/eg2/src/app/share/grid/grid.ts        |   4 +-
 .../eg2/src/app/share/util/can-deactivate.guard.ts |  33 ++
 .../eg2/src/app/staff/booking/booking.module.ts    |   6 +-
 .../eg2/src/app/staff/booking/pickup.component.ts  |   2 +-
 .../eg2/src/app/staff/booking/return.component.ts  |   2 +-
 .../app/staff/cat/authority/authority.module.ts    |  23 +
 .../staff/cat/authority/marc-edit.component.html   |  32 ++
 .../app/staff/cat/authority/marc-edit.component.ts |  35 ++
 .../src/app/staff/cat/authority/routing.module.ts  |  20 +
 .../src/eg2/src/app/staff/cat/routing.module.ts    |   3 +
 .../staff/catalog/basket-actions.component.html    |  38 +-
 .../eg2/src/app/staff/catalog/catalog.module.ts    |   6 +-
 .../eg2/src/app/staff/catalog/catalog.service.ts   |  16 +-
 .../staff/catalog/cnbrowse/results.component.html  |  49 +-
 .../staff/catalog/cnbrowse/results.component.ts    |  99 +++-
 .../src/app/staff/catalog/hold/hold.component.html |   6 +-
 .../src/app/staff/catalog/hold/hold.component.ts   |  21 +
 .../eg2/src/app/staff/catalog/prefs.component.html |  84 +++
 .../eg2/src/app/staff/catalog/prefs.component.ts   |  73 +++
 .../staff/catalog/record/pagination.component.ts   |  24 +-
 .../app/staff/catalog/record/parts.component.ts    |   4 +
 .../app/staff/catalog/record/record.component.html |  38 +-
 .../app/staff/catalog/record/record.component.ts   |  76 ++-
 .../eg2/src/app/staff/catalog/resolver.service.ts  |   9 +
 .../app/staff/catalog/result/record.component.css  |   4 +-
 .../app/staff/catalog/result/record.component.html |  50 +-
 .../app/staff/catalog/result/record.component.ts   |   6 +-
 .../app/staff/catalog/result/results.component.ts  |   8 +-
 .../eg2/src/app/staff/catalog/routing.module.ts    |   9 +-
 .../app/staff/catalog/search-form.component.html   |  24 +-
 .../src/app/staff/catalog/search-form.component.ts |  46 +-
 .../staff/catalog/search-templates.component.ts    |   5 +-
 Open-ILS/src/eg2/src/app/staff/common.module.ts    |   5 +-
 Open-ILS/src/eg2/src/app/staff/nav.component.html  |   2 +-
 .../share/bib-summary/bib-summary.component.html   |   5 +-
 .../share/buckets/bucket-dialog.component.html     |  12 +-
 .../app/staff/share/holdings/holdings.service.ts   |   8 +-
 .../authority-linking-dialog.component.html        | 123 ++++
 .../authority-linking-dialog.component.ts          | 202 +++++++
 .../share/marc-edit/editable-content.component.css |  26 +
 .../marc-edit/editable-content.component.html      |  50 ++
 .../share/marc-edit/editable-content.component.ts  | 637 +++++++++++++++++++++
 .../app/staff/share/marc-edit/editor-context.ts    | 434 ++++++++++++++
 .../share/marc-edit/editor-dialog.component.html   |  14 +
 .../share/marc-edit/editor-dialog.component.ts     |  44 ++
 .../staff/share/marc-edit/editor.component.html    |  82 ++-
 .../app/staff/share/marc-edit/editor.component.ts  | 230 ++++++--
 .../share/marc-edit/fixed-field.component.css      |  20 +
 .../share/marc-edit/fixed-field.component.html     |  17 +
 .../staff/share/marc-edit/fixed-field.component.ts |  45 ++
 .../marc-edit/fixed-fields-editor.component.html   | 281 +++++++++
 .../marc-edit/fixed-fields-editor.component.ts     |  31 +
 .../share/marc-edit/flat-editor.component.html     |   1 +
 .../staff/share/marc-edit/flat-editor.component.ts |  23 +-
 .../app/staff/share/marc-edit/marc-edit.module.ts  |  22 +-
 .../src/app/staff/share/marc-edit/marcrecord.ts    | 161 +++++-
 .../marc-edit/phys-char-dialog.component.html      |  59 ++
 .../share/marc-edit/phys-char-dialog.component.ts  | 220 +++++++
 .../share/marc-edit/rich-editor.component.css      |  15 +
 .../share/marc-edit/rich-editor.component.html     | 220 +++++++
 .../staff/share/marc-edit/rich-editor.component.ts | 173 +++++-
 .../app/staff/share/marc-edit/tagtable.service.ts  | 336 +++++++++++
 .../src/app/staff/share/patron/patron.module.ts    |  30 +
 .../app/staff/share/{ => patron}/patron.service.ts |   0
 .../share/patron/profile-select.component.html     |   6 +
 .../staff/share/patron/profile-select.component.ts | 178 ++++++
 .../share/patron/search-dialog.component.html      |  23 +
 .../staff/share/patron/search-dialog.component.ts  |  36 ++
 .../app/staff/share/patron/search.component.html   | 233 ++++++++
 .../src/app/staff/share/patron/search.component.ts | 239 ++++++++
 .../src/eg2/src/app/staff/staff.component.html     |   2 +
 Open-ILS/src/eg2/src/assets/js/marcrecord.js       |   6 +-
 Open-ILS/src/eg2/src/styles.css                    |  11 +
 .../lib/OpenILS/Application/Cat/Authority.pm       | 347 +++++++++++
 Open-ILS/src/sql/Pg/002.schema.config.sql          |   2 +-
 Open-ILS/src/sql/Pg/950.data.seed-values.sql       |  10 +
 .../src/sql/Pg/upgrade/1198.data.catalog-prefs.sql |  15 +
 .../staff/cat/share/t_authority_browser.tt2        |   6 +-
 .../staff/cat/share/t_authority_linker.tt2         |   2 +-
 Open-ILS/src/templates/staff/navbar.tt2            |   2 +-
 .../web/js/ui/default/staff/cat/catalog/app.js     |  16 +-
 Open-ILS/web/js/ui/default/staff/marcrecord.js     |   4 +-
 .../Cataloging/ang-marc-editor.adoc                |   7 +
 .../Circulation/ang-cat-holds-patron-search.adoc   |   5 +
 .../Client/ang-cat-prefs-page.adoc                 |  12 +
 .../Client/staff-cat-highlighting.adoc             |   6 +
 103 files changed, 5766 insertions(+), 261 deletions(-)
 create mode 100644 Open-ILS/src/eg2/src/app/share/catalog/bib-display-field.component.css
 create mode 100644 Open-ILS/src/eg2/src/app/share/catalog/bib-display-field.component.html
 create mode 100644 Open-ILS/src/eg2/src/app/share/catalog/bib-display-field.component.ts
 create mode 100644 Open-ILS/src/eg2/src/app/share/context-menu/context-menu-container.component.css
 create mode 100644 Open-ILS/src/eg2/src/app/share/context-menu/context-menu-container.component.html
 create mode 100644 Open-ILS/src/eg2/src/app/share/context-menu/context-menu-container.component.ts
 create mode 100644 Open-ILS/src/eg2/src/app/share/context-menu/context-menu.directive.ts
 create mode 100644 Open-ILS/src/eg2/src/app/share/context-menu/context-menu.module.ts
 create mode 100644 Open-ILS/src/eg2/src/app/share/context-menu/context-menu.service.ts
 create mode 100644 Open-ILS/src/eg2/src/app/share/util/can-deactivate.guard.ts
 create mode 100644 Open-ILS/src/eg2/src/app/staff/cat/authority/authority.module.ts
 create mode 100644 Open-ILS/src/eg2/src/app/staff/cat/authority/marc-edit.component.html
 create mode 100644 Open-ILS/src/eg2/src/app/staff/cat/authority/marc-edit.component.ts
 create mode 100644 Open-ILS/src/eg2/src/app/staff/cat/authority/routing.module.ts
 create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/prefs.component.html
 create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/prefs.component.ts
 create mode 100644 Open-ILS/src/eg2/src/app/staff/share/marc-edit/authority-linking-dialog.component.html
 create mode 100644 Open-ILS/src/eg2/src/app/staff/share/marc-edit/authority-linking-dialog.component.ts
 create mode 100644 Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.css
 create mode 100644 Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.html
 create mode 100644 Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts
 create mode 100644 Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-context.ts
 create mode 100644 Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-dialog.component.html
 create mode 100644 Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-dialog.component.ts
 create mode 100644 Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-field.component.css
 create mode 100644 Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-field.component.html
 create mode 100644 Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-field.component.ts
 create mode 100644 Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-fields-editor.component.html
 create mode 100644 Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-fields-editor.component.ts
 create mode 100644 Open-ILS/src/eg2/src/app/staff/share/marc-edit/phys-char-dialog.component.html
 create mode 100644 Open-ILS/src/eg2/src/app/staff/share/marc-edit/phys-char-dialog.component.ts
 create mode 100644 Open-ILS/src/eg2/src/app/staff/share/marc-edit/tagtable.service.ts
 create mode 100644 Open-ILS/src/eg2/src/app/staff/share/patron/patron.module.ts
 rename Open-ILS/src/eg2/src/app/staff/share/{ => patron}/patron.service.ts (100%)
 create mode 100644 Open-ILS/src/eg2/src/app/staff/share/patron/profile-select.component.html
 create mode 100644 Open-ILS/src/eg2/src/app/staff/share/patron/profile-select.component.ts
 create mode 100644 Open-ILS/src/eg2/src/app/staff/share/patron/search-dialog.component.html
 create mode 100644 Open-ILS/src/eg2/src/app/staff/share/patron/search-dialog.component.ts
 create mode 100644 Open-ILS/src/eg2/src/app/staff/share/patron/search.component.html
 create mode 100644 Open-ILS/src/eg2/src/app/staff/share/patron/search.component.ts
 create mode 100644 Open-ILS/src/sql/Pg/upgrade/1198.data.catalog-prefs.sql
 create mode 100644 docs/RELEASE_NOTES_NEXT/Cataloging/ang-marc-editor.adoc
 create mode 100644 docs/RELEASE_NOTES_NEXT/Circulation/ang-cat-holds-patron-search.adoc
 create mode 100644 docs/RELEASE_NOTES_NEXT/Client/ang-cat-prefs-page.adoc
 create mode 100644 docs/RELEASE_NOTES_NEXT/Client/staff-cat-highlighting.adoc


hooks/post-receive
-- 
Evergreen ILS


More information about the open-ils-commits mailing list