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

Evergreen Git git at git.evergreen-ils.org
Wed Feb 20 17:07:00 EST 2019


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

The branch, master has been updated
       via  0b4f44e817c2a91a93d1a13cba2bf8318fb12c8c (commit)
       via  abfa1eab40c9979fdac2baa184b1b969c72396d8 (commit)
       via  582bb8924e1801aaee40e73e79c3ebd312dc8a2e (commit)
       via  3cf69812344e55e0a647d3c344e3c7665efc9493 (commit)
       via  38feacd1a79ab733e3ae8cfb356fba18383795f8 (commit)
       via  7075905d588919bce7e6a520b3a061e142e16e19 (commit)
      from  39c71a13a01e89e91a91472492adef5fa8910084 (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 0b4f44e817c2a91a93d1a13cba2bf8318fb12c8c
Author: Dan Wells <dbw2 at calvin.edu>
Date:   Wed Feb 20 17:05:25 2019 -0500

    LP#1806087 Stamp upgrade script for staff catalog preview
    
    Signed-off-by: Dan Wells <dbw2 at calvin.edu>

diff --git a/Open-ILS/src/sql/Pg/002.schema.config.sql b/Open-ILS/src/sql/Pg/002.schema.config.sql
index fc8ff662fe..eb484ceab5 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 ('1149', :eg_version); -- Dyrcona/mmorgan/gmcharlt
+INSERT INTO config.upgrade_log (version, applied_to) VALUES ('1152', :eg_version); -- berick/dbwells
 
 CREATE TABLE config.bib_source (
 	id		SERIAL	PRIMARY KEY,
diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.data.ang-catalog-preview-setting.sql b/Open-ILS/src/sql/Pg/upgrade/1151.data.ang-catalog-preview-setting.sql
similarity index 89%
rename from Open-ILS/src/sql/Pg/upgrade/XXXX.data.ang-catalog-preview-setting.sql
rename to Open-ILS/src/sql/Pg/upgrade/1151.data.ang-catalog-preview-setting.sql
index 1bc4888045..794d5ca73b 100644
--- a/Open-ILS/src/sql/Pg/upgrade/XXXX.data.ang-catalog-preview-setting.sql
+++ b/Open-ILS/src/sql/Pg/upgrade/1151.data.ang-catalog-preview-setting.sql
@@ -1,6 +1,6 @@
 BEGIN;
 
--- SELECT evergreen.upgrade_deps_block_check('XXXX', :eg_version);
+SELECT evergreen.upgrade_deps_block_check('1152', :eg_version);
 
 INSERT into config.org_unit_setting_type 
     (name, datatype, grp, label, description)

commit abfa1eab40c9979fdac2baa184b1b969c72396d8
Author: Kyle Huckins <khuckins at catalyte.io>
Date:   Thu Feb 7 19:54:56 2019 +0000

    lp1806087 Place Holds Improvements
    
    - Disable SMS Carriers dropdown if Notify by SMS  unchecked.
    - Move Search for patron button to same level as "Place Hold (name)" text.
    - Add Search icon to "Search for Patron" button
    - Add "Submit" button to Barcode input for improved UX.
    
    Signed-off-by: Kyle Huckins <khuckins at catalyte.io>
    
     Changes to be committed:
            modified:   Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.html
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Dan Wells <dbw2 at calvin.edu>

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 1ef096c495..998aa212fe 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,9 +1,19 @@
-
-<h3 i18n>Place Hold 
-  <small *ngIf="user"> 
-   ({{user.family_name()}}, {{user.first_given_name()}})
-  </small>
-</h3>
+<div class="row">
+  <div class="col-lg-3">
+    <h3 i18n>Place Hold 
+      <small *ngIf="user">
+       ({{user.family_name()}}, {{user.first_given_name()}})
+      </small>
+    </h3>
+  </div>
+  <div class="col-lg-3 text-right">
+    <button class="btn btn-outline-dark btn-sm"
+      [disabled]="true" i18n>
+      <span class="material-icons mat-icon-in-button align-middle" title="Search for Patron">search</span>
+      <span class="align-middle">Search for Patron</span>
+    </button>
+  </div>
+</div>
 
 <form class="form form-validated common-form" 
   autocomplete="off" (keydown.enter)="$event.preventDefault()">
@@ -27,8 +37,7 @@
               (keyup.enter)="userBarcodeChanged()"
               [(ngModel)]="userBarcode" (change)="userBarcodeChanged()"/>
             <div class="input-group-append">
-              <button class="btn btn-outline-dark" 
-                [disabled]="true" i18n>Search</button>
+              <button class="btn btn-outline-dark" (click)="userBarcodeChanged()">Submit</button>
             </div>
           </div>
         </div>
@@ -134,7 +143,7 @@
               <label i18n>SMS Carrier</label>
             </div>
             <div class="flex-1">
-              <eg-combobox
+              <eg-combobox [disabled]="!notifySms"
                 placeholder="SMS Carriers" i18n-placeholder
                 [entries]="smsCarriers">
               </eg-combobox>

commit 582bb8924e1801aaee40e73e79c3ebd312dc8a2e
Author: Bill Erickson <berickxx at gmail.com>
Date:   Thu Jan 31 10:42:57 2019 -0500

    LP1806087 Angular catalog Ang7 & lint repairs
    
    Update rxjs imports to work with Angular7.  Add ng-lint repairs.
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Dan Wells <dbw2 at calvin.edu>

diff --git a/Open-ILS/src/eg2/src/app/core/org.service.ts b/Open-ILS/src/eg2/src/app/core/org.service.ts
index 0acb2279e9..c666957d2c 100644
--- a/Open-ILS/src/eg2/src/app/core/org.service.ts
+++ b/Open-ILS/src/eg2/src/app/core/org.service.ts
@@ -253,7 +253,7 @@ export class OrgService {
             }
 
         } else if (!anonymous) {
-            console.warn('Attempt to fetch org setting(s)', 
+            console.warn('Attempt to fetch org setting(s)',
                 name, 'in non-anonymous mode without an authtoken');
             return Promise.resolve({});
         }
diff --git a/Open-ILS/src/eg2/src/app/share/catalog/basket.service.ts b/Open-ILS/src/eg2/src/app/share/catalog/basket.service.ts
index 99c8c24ca0..0d97220c80 100644
--- a/Open-ILS/src/eg2/src/app/share/catalog/basket.service.ts
+++ b/Open-ILS/src/eg2/src/app/share/catalog/basket.service.ts
@@ -1,5 +1,5 @@
 import {Injectable, EventEmitter} from '@angular/core';
-import {Observable} from 'rxjs/Observable';
+import {Observable} from 'rxjs';
 import {StoreService} from '@eg/core/store.service';
 import {NetService} from '@eg/core/net.service';
 import {PcrudService} from '@eg/core/pcrud.service';
@@ -27,8 +27,8 @@ export class BasketService {
         private pcrud: PcrudService,
         private store: StoreService,
         private anonCache: AnonCacheService
-    ) { 
-        this.idList = []; 
+    ) {
+        this.idList = [];
         this.onChange = new EventEmitter<number[]>();
     }
 
@@ -50,7 +50,7 @@ export class BasketService {
 
         return this.anonCache.getItem(cacheKey, BASKET_CACHE_ATTR).then(
             list => {
-                if (!list) {return this.idList};
+                if (!list) { return this.idList; }
                 this.idList = list.map(id => Number(id));
                 return this.idList;
             }
@@ -66,8 +66,8 @@ export class BasketService {
         const cacheKey = this.store.getLoginSessionItem(BASKET_CACHE_KEY_COOKIE);
 
         return this.anonCache.setItem(cacheKey, BASKET_CACHE_ATTR, this.idList)
-        .then(cacheKey => {
-            this.store.setLoginSessionItem(BASKET_CACHE_KEY_COOKIE, cacheKey);
+        .then(key => {
+            this.store.setLoginSessionItem(BASKET_CACHE_KEY_COOKIE, key);
             this.onChange.emit(this.idList);
             return this.idList;
         });
@@ -76,8 +76,8 @@ export class BasketService {
     addRecordIds(ids: number[]): Promise<number[]> {
         ids = ids.filter(id => !this.hasRecordId(id)); // avoid dupes
 
-        if (ids.length === 0) { 
-            return Promise.resolve(this.idList); 
+        if (ids.length === 0) {
+            return Promise.resolve(this.idList);
         }
         return this.setRecordIds(
             this.idList.concat(ids.map(id => Number(id))));
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 5602bbb192..d536c8beb5 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
@@ -180,10 +180,10 @@ export class BibRecordService {
         }));
     }
 
-    // A Metabib Summary is a BibRecordSummary with the lead record as 
-    // its core bib record plus attributes (e.g. formats) from related 
+    // A Metabib Summary is a BibRecordSummary with the lead record as
+    // its core bib record plus attributes (e.g. formats) from related
     // records.
-    getMetabibSummary(metabibIds: number | number[], 
+    getMetabibSummary(metabibIds: number | number[],
         orgId?: number, orgDepth?: number): Observable<BibRecordSummary> {
 
         const ids = [].concat(metabibIds);
@@ -192,16 +192,16 @@ export class BibRecordService {
             return from([]);
         }
 
-        return this.pcrud.search('mmr', {id: ids}, 
-            {flesh: 1, flesh_fields: {mmr: ['source_maps']}}, 
+        return this.pcrud.search('mmr', {id: ids},
+            {flesh: 1, flesh_fields: {mmr: ['source_maps']}},
             {anonymous: true}
         ).pipe(mergeMap(mmr => this.compileMetabib(mmr, orgId, orgDepth)));
     }
 
     // 'metabib' must have its "source_maps" field fleshed.
-    // Get bib summaries for all related bib records so we can 
+    // Get bib summaries for all related bib records so we can
     // extract data that must be appended to the master record summary.
-    compileMetabib(metabib: IdlObject, 
+    compileMetabib(metabib: IdlObject,
         orgId?: number, orgDepth?: number): Observable<BibRecordSummary> {
 
         // TODO: Create an API similar to the one that builds a combined
@@ -210,7 +210,7 @@ export class BibRecordService {
 
         // Non-master records
         const relatedBibIds = metabib.source_maps()
-            .map(map => map.source())
+            .map(m => m.source())
             .filter(id => id !== metabib.master_record());
 
         let observer;
@@ -222,8 +222,8 @@ export class BibRecordService {
         this.getBibSummary(metabib.master_record(), orgId, orgDepth)
         .subscribe(summary => {
             summary.metabibId = metabib.id();
-            summary.metabibRecords = 
-                metabib.source_maps().map(map => Number(map.source()))
+            summary.metabibRecords =
+                metabib.source_maps().map(m => Number(m.source()));
 
             let promise;
 
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 eeaf38af29..5b45d00a60 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,7 @@
 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 {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';
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 0f07070656..8f326adead 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
@@ -1,7 +1,7 @@
 import {Injectable} from '@angular/core';
 import {ParamMap} from '@angular/router';
 import {OrgService} from '@eg/core/org.service';
-import {CatalogSearchContext, CatalogBrowseContext, CatalogMarcContext, 
+import {CatalogSearchContext, CatalogBrowseContext, CatalogMarcContext,
    CatalogTermContext, FacetFilter} from './search-context';
 import {CATALOG_CCVM_FILTERS} from './search-context';
 
@@ -80,7 +80,7 @@ export class CatalogUrlService {
             params.joinOp = [];
             params.matchOp = [];
 
-            ['format', 'available', 'hasBrowseEntry', 'date1', 
+            ['format', 'available', 'hasBrowseEntry', 'date1',
                 'date2', 'dateOp', 'groupByMetarecord', 'fromMetarecord']
             .forEach(field => {
                 if (ts[field]) {
@@ -116,7 +116,7 @@ export class CatalogUrlService {
                     }));
                 });
             }
-        
+
             if (ts.copyLocations.length && ts.copyLocations[0] !== '') {
                 params.copyLocations = ts.copyLocations.join(',');
             }
@@ -200,7 +200,7 @@ export class CatalogUrlService {
         } else if (params.has('query')) {
 
             // Scalars
-            ['format', 'available', 'date1', 'date2', 
+            ['format', 'available', 'date1', 'date2',
                 'dateOp', 'groupByMetarecord', 'fromMetarecord']
             .forEach(field => {
                 if (params.has(field)) {
@@ -212,14 +212,14 @@ export class CatalogUrlService {
             ['query', 'fieldClass', 'joinOp', 'matchOp'].forEach(field => {
                 const arr = params.getAll(field);
                 if (params.has(field)) {
-                    ts[field] = params.getAll(field); 
+                    ts[field] = params.getAll(field);
                 }
             });
 
             CATALOG_CCVM_FILTERS.forEach(code => {
-                const val = params.get(code);
-                if (val) {
-                    ts.ccvmFilters[code] = val.split(/,/);
+                const ccvmVal = params.get(code);
+                if (ccvmVal) {
+                    ts.ccvmFilters[code] = ccvmVal.split(/,/);
                 } else {
                     ts.ccvmFilters[code] = [''];
                 }
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 b8ffb857d4..90a959975a 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
@@ -37,7 +37,7 @@ export class CatalogService {
         private basket: BasketService
     ) {
         this.onSearchComplete = new EventEmitter<CatalogSearchContext>();
-        
+
     }
 
     search(ctx: CatalogSearchContext): Promise<void> {
@@ -47,7 +47,7 @@ export class CatalogService {
             return this.basketSearch(ctx);
         } else if (ctx.marcSearch.isSearchable()) {
             return this.marcSearch(ctx);
-        } else if (ctx.identSearch.isSearchable() && 
+        } else if (ctx.identSearch.isSearchable() &&
             ctx.identSearch.queryType === 'item_barcode') {
             return this.barcodeSearch(ctx);
         } else {
@@ -119,7 +119,7 @@ export class CatalogService {
         } else {
             fullQuery = ctx.compileTermSearchQuery();
 
-            if (ctx.termSearch.groupByMetarecord 
+            if (ctx.termSearch.groupByMetarecord
                 && !ctx.termSearch.fromMetarecord) {
                 method = 'open-ils.search.metabib.multiclass.query';
             }
@@ -134,7 +134,7 @@ export class CatalogService {
         if (ctx.isStaff) {
             method += '.staff';
         }
-        
+
         return new Promise((resolve, reject) => {
             this.net.request(
                 'open-ils.search', method, {
@@ -151,7 +151,7 @@ export class CatalogService {
 
     }
 
-    // When showing titles linked to a browse entry, fetch 
+    // When showing titles linked to a browse entry, fetch
     // the entry data as well so the UI can display it.
     fetchBrowseEntry(ctx: CatalogSearchContext) {
         const ts = ctx.termSearch;
@@ -190,7 +190,7 @@ export class CatalogService {
         const isMeta = ctx.termSearch.isMetarecordSearch();
 
         let observable: Observable<BibRecordSummary>;
-        
+
         if (isMeta) {
             observable = this.bibService.getMetabibSummary(
                 ctx.currentResultIds(), ctx.searchOrg.id(), depth);
@@ -307,9 +307,9 @@ export class CatalogService {
                 format => format.code() === code)[0];
             if (ccvm) {
                 return ccvm.search_label();
-            }                                                                  
-        }                                                                      
-    }      
+            }
+        }
+    }
 
     fetchCmfs(): Promise<void> {
         // At the moment, we only need facet CMFs.
@@ -333,11 +333,11 @@ export class CatalogService {
         const orgIds = this.org.fullPath(contextOrg, true);
         this.copyLocations = [];
 
-        return this.pcrud.search('acpl', 
+        return this.pcrud.search('acpl',
             {deleted: 'f', opac_visible: 't', owning_lib: orgIds},
             {order_by: {acpl: 'name'}},
             {anonymous: true}
-        ).pipe(tap(loc => this.copyLocations.push(loc))).toPromise()
+        ).pipe(tap(loc => this.copyLocations.push(loc))).toPromise();
     }
 
     browse(ctx: CatalogSearchContext): Observable<any> {
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 d34d71105d..7e5f8109e6 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
@@ -96,7 +96,7 @@ export class CatalogMarcContext {
 
 export class CatalogIdentContext {
     value: string;
-    queryType: string; 
+    queryType: string;
 
     reset() {
         this.value = '';
@@ -105,7 +105,7 @@ export class CatalogIdentContext {
 
     isSearchable() {
         return (
-            this.value !== '' 
+            this.value !== ''
             && this.queryType !== ''
         );
     }
@@ -158,8 +158,8 @@ export class CatalogTermContext {
     // contents of a metarecord.
     isMetarecordSearch(): boolean {
         return (
-            this.isSearchable() && 
-            this.groupByMetarecord && 
+            this.isSearchable() &&
+            this.groupByMetarecord &&
             this.fromMetarecord === null
         );
     }
@@ -321,16 +321,15 @@ export class CatalogSearchContext {
         if (this.sort) {
             const parts = this.sort.split(/\./);
             args.sort = parts[0]; // title, author, etc.
-            if (parts[1]) { args.sort_dir = 'descending' };
+            if (parts[1]) { args.sort_dir = 'descending'; }
         }
 
         return args;
     }
 
     compileIdentSearchQuery(): string {
-
-        let str = ' site(' + this.searchOrg.shortname() + ')';
-        return str + ' ' + 
+        const str = ' site(' + this.searchOrg.shortname() + ')';
+        return str + ' ' +
             this.identSearch.queryType + ':' + this.identSearch.value;
     }
 
@@ -430,7 +429,7 @@ export class CatalogSearchContext {
         if (qcount > 1) { str += ')'; }
         // -------
 
-        if (ts.hasBrowseEntry) { 
+        if (ts.hasBrowseEntry) {
             // stored as a comma-separated string of "entryId,fieldId"
             str += ` has_browse_entry(${ts.hasBrowseEntry})`;
         }
diff --git a/Open-ILS/src/eg2/src/app/share/util/anon-cache.service.ts b/Open-ILS/src/eg2/src/app/share/util/anon-cache.service.ts
index 29c168dc71..6431160d5f 100644
--- a/Open-ILS/src/eg2/src/app/share/util/anon-cache.service.ts
+++ b/Open-ILS/src/eg2/src/app/share/util/anon-cache.service.ts
@@ -2,7 +2,7 @@
  * Service for communicating with the server-side "anonymous" cache.
  */
 import {Injectable} from '@angular/core';
-import {Observable} from 'rxjs/Observable';
+import {Observable} from 'rxjs';
 import {StoreService} from '@eg/core/store.service';
 import {NetService} from '@eg/core/net.service';
 
@@ -30,14 +30,14 @@ export class AnonCacheService {
             'open-ils.actor',
             'open-ils.actor.anon_cache.set_value',
             cacheKey, attr, value
-        ).toPromise().then(cacheKey => {
-            if (cacheKey) {
-                return cacheKey;
+        ).toPromise().then(key => {
+            if (key) {
+                return key;
             } else {
                 return Promise.reject(
-                    `Could not apply a value for attr=${attr} cacheKey=${cacheKey}`);
+                    `Could not apply a value for attr=${attr} cacheKey=${key}`);
             }
-        })
+        });
     }
 
     removeItem(cacheKey: string, attr: string): Promise<string> {
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/basket-actions.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/basket-actions.component.ts
index 08d02bcc63..e00a396d17 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/basket-actions.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/basket-actions.component.ts
@@ -1,12 +1,12 @@
 import {Component, OnInit, ViewChild} from '@angular/core';
 import {BasketService} from '@eg/share/catalog/basket.service';
-import {Subscription} from 'rxjs/Subscription';
+import {Subscription} from 'rxjs';
 import {Router} from '@angular/router';
 import {NetService} from '@eg/core/net.service';
 import {AuthService} from '@eg/core/auth.service';
 import {PrintService} from '@eg/share/print/print.service';
-import {RecordBucketDialogComponent} 
-    from '@eg/staff/share/buckets/record-bucket-dialog.component';
+import {RecordBucketDialogComponent
+    } from '@eg/staff/share/buckets/record-bucket-dialog.component';
 
 @Component({
   selector: 'eg-catalog-basket-actions',
@@ -41,10 +41,10 @@ export class BasketActionsComponent implements OnInit {
     applyAction() {
         console.debug('Performing basket action', this.basketAction);
 
-        switch(this.basketAction) {
+        switch (this.basketAction) {
             case 'view':
                 // This does not propagate search params -- unclear if needed.
-                this.router.navigate(['/staff/catalog/search'], 
+                this.router.navigate(['/staff/catalog/search'],
                     {queryParams: {showBasket: true}});
                 break;
 
@@ -57,7 +57,7 @@ export class BasketActionsComponent implements OnInit {
                     this.router.navigate(['/staff/catalog/hold/T'],
                         {queryParams: {target: ids}});
                 });
-                break; 
+                break;
 
             case 'print':
                 this.basket.getRecordIds().then(ids => {
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/browse/results.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/browse/results.component.ts
index 8fcbce1e65..65d02e5342 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/browse/results.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/browse/results.component.ts
@@ -1,6 +1,5 @@
 import {Component, OnInit, Input} from '@angular/core';
-import {Observable} from 'rxjs/Observable';
-import {Subscription} from 'rxjs/Subscription';
+import {Observable, Subscription} from 'rxjs';
 import {map, switchMap, distinctUntilChanged} from 'rxjs/operators';
 import {ActivatedRoute, ParamMap} from '@angular/router';
 import {CatalogService} from '@eg/share/catalog/catalog.service';
@@ -49,7 +48,7 @@ export class BrowseResultsComponent implements OnInit {
         if (bs.isSearchable()) {
             this.results = [];
             this.cat.browse(this.searchContext)
-                .subscribe(result => this.addResult(result))
+                .subscribe(result => this.addResult(result));
         }
     }
 
@@ -68,11 +67,11 @@ export class BrowseResultsComponent implements OnInit {
                 const heading = headingStruct[fieldId][0];
 
                 const inList = result.list_authorities.filter(
-                    id => Number(id) === Number(heading.target))[0]
+                    id => Number(id) === Number(heading.target))[0];
 
-                if (   heading.target 
+                if (   heading.target
                     && heading.main_entry
-                    && heading.target_count 
+                    && heading.target_count
                     && !inList
                     && !seen[heading.target]) {
 
@@ -119,12 +118,12 @@ export class BrowseResultsComponent implements OnInit {
         }
     }
 
-    searchByBrowseEntry(result) { 
+    searchByBrowseEntry(result) {
 
         // Avoid propagating browse values to term search.
         this.searchContext.browseSearch.reset();
 
-        this.searchContext.termSearch.hasBrowseEntry = 
+        this.searchContext.termSearch.hasBrowseEntry =
             result.browse_entry + ',' + result.fields;
         this.staffCat.search();
     }
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 a0a0dc24f4..3cfbb19ba9 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
@@ -1,7 +1,7 @@
 import {Component, OnInit, Input, ViewChild, Renderer2} from '@angular/core';
-import {Observable} from 'rxjs/Observable';
 import {Router, ActivatedRoute, ParamMap} from '@angular/router';
-import {tap} from 'rxjs/operators/tap';
+import {Observable} from 'rxjs';
+import {tap} from 'rxjs/operators';
 import {EventService} from '@eg/core/event.service';
 import {NetService} from '@eg/core/net.service';
 import {AuthService} from '@eg/core/auth.service';
@@ -13,8 +13,8 @@ import {BibRecordService, BibRecordSummary} from '@eg/share/catalog/bib-record.s
 import {CatalogSearchContext, CatalogSearchState} from '@eg/share/catalog/search-context';
 import {CatalogService} from '@eg/share/catalog/catalog.service';
 import {StaffCatalogService} from '../catalog.service';
-import {HoldService, HoldRequest, HoldRequestTarget} 
-    from '@eg/staff/share/hold.service';
+import {HoldService, HoldRequest,
+    HoldRequestTarget} from '@eg/staff/share/hold.service';
 import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
 
 class HoldContext {
@@ -32,7 +32,7 @@ class HoldContext {
            // code => selected-boolean
            formats: {},
            langs: {}
-        }
+        };
     }
 }
 
@@ -40,7 +40,7 @@ class HoldContext {
   templateUrl: 'hold.component.html'
 })
 export class HoldComponent implements OnInit {
-    
+
     holdType: string;
     holdTargets: number[];
     user: IdlObject; //
@@ -107,15 +107,15 @@ export class HoldComponent implements OnInit {
         this.getTargetMeta();
 
         this.org.settings('sms.enable').then(sets => {
-            this.smsEnabled = sets['sms.enable']
+            this.smsEnabled = sets['sms.enable'];
             if (!this.smsEnabled) { return; }
 
             this.pcrud.search('csc', {active: 't'}, {order_by: {csc: 'name'}})
             .subscribe(carrier => {
                 this.smsCarriers.push({
-                    id: carrier.id(), 
+                    id: carrier.id(),
                     label: carrier.name()
-                })
+                });
             });
         });
 
@@ -154,7 +154,7 @@ export class HoldComponent implements OnInit {
 
     // Map the selected metarecord filters optoins to a JSON-encoded
     // list of attr filters as required by the API.
-    // Compiles a blob of 
+    // Compiles a blob of
     // {target: JSON({"0": [{_attr: ctype, _val: code}, ...], "1": [...]})}
     // TODO: this should live in the hold service, not in the UI code.
     mrSelectorsToFilters(ctx: HoldContext): {[target: number]: string} {
@@ -196,9 +196,9 @@ export class HoldComponent implements OnInit {
         }
 
         if (Object.keys(compiled).length > 0) {
-            const result = {};
-            result[ctx.holdTarget] = JSON.stringify(compiled);
-            return result;
+            const res = {};
+            res[ctx.holdTarget] = JSON.stringify(compiled);
+            return res;
         }
 
         return null;
@@ -225,15 +225,15 @@ export class HoldComponent implements OnInit {
     userBarcodeChanged() {
 
         // Avoid simultaneous or duplicate lookups
-        if (this.userBarcode === this.currentUserBarcode) { 
-            return; 
+        if (this.userBarcode === this.currentUserBarcode) {
+            return;
         }
 
         this.resetForm();
 
-        if (!this.userBarcode) { 
+        if (!this.userBarcode) {
             this.user = null;
-            return; 
+            return;
         }
 
         this.user = null;
@@ -242,7 +242,7 @@ export class HoldComponent implements OnInit {
         this.net.request(
             'open-ils.actor',
             'open-ils.actor.get_barcodes',
-            this.auth.token(), this.auth.user().ws_ou(), 
+            this.auth.token(), this.auth.user().ws_ou(),
             'actor', this.userBarcode
         ).subscribe(barcodes => {
 
@@ -292,7 +292,7 @@ export class HoldComponent implements OnInit {
 
             if (value === '' || value === null) { return; }
 
-            switch(name) {
+            switch (name) {
                 case 'opac.hold_notify':
                     this.notifyPhone = Boolean(value.match(/phone/));
                     this.notifyEmail = Boolean(value.match(/email/));
@@ -300,7 +300,7 @@ export class HoldComponent implements OnInit {
                     break;
 
                 case 'opac.default_pickup_location':
-                    this.pickupLib = value; 
+                    this.pickupLib = value;
                     break;
             }
         });
@@ -322,7 +322,7 @@ export class HoldComponent implements OnInit {
 
         const target = this.holdTargets[idx];
         const ctx = this.holdContexts.filter(
-            ctx => ctx.holdTarget === target)[0];
+            c => c.holdTarget === target)[0];
 
         this.placeOneHold(ctx).then(() => this.placeHolds(idx + 1));
     }
@@ -352,15 +352,15 @@ export class HoldComponent implements OnInit {
                 console.log('hold returned: ', request);
                 ctx.lastRequest = request;
                 ctx.processing = false;
-    
+
                 // If this request failed and was not already an override,
                 // see of this user has permission to override.
-                if (!request.override && 
+                if (!request.override &&
                     !request.result.success && request.result.evt) {
-    
+
                     const txtcode = request.result.evt.textcode;
                     const perm = txtcode + '.override';
-    
+
                     return this.perm.hasWorkPermHere(perm).then(
                         permResult => ctx.canOverride = permResult[perm]);
                 }
@@ -377,7 +377,7 @@ export class HoldComponent implements OnInit {
     }
 
     canOverride(ctx: HoldContext): boolean {
-        return ctx.lastRequest && 
+        return ctx.lastRequest &&
                 !ctx.lastRequest.result.success && ctx.canOverride;
     }
 
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 e74fc12d42..3ab8e8f8e9 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
@@ -73,7 +73,7 @@ export class PartsComponent implements OnInit {
                 order_by: orderBy
             };
 
-            return this.pcrud.search('bmp', 
+            return this.pcrud.search('bmp',
                 {record: this.recId, deleted: 'f'}, searchOps);
         };
 
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 0414a076b4..e6832f1ab9 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
@@ -43,7 +43,7 @@ export class RecordComponent implements OnInit {
     ngOnInit() {
         this.searchContext = this.staffCat.searchContext;
 
-        this.defaultTab = 
+        this.defaultTab =
             this.store.getLocalItem('eg.cat.default_record_tab')
             || 'catalog';
 
@@ -96,12 +96,12 @@ export class RecordComponent implements OnInit {
         if (this.recordTab in ANGJS_TABS) {
             const angjsBase = '/eg/staff/cat/catalog/record';
 
-            window.location.href = 
+            window.location.href =
                 `${angjsBase}/${this.recordId}/${this.recordTab}`;
             return;
         }
 
-        const url = 
+        const url =
             `/staff/catalog/record/${this.recordId}/${this.recordTab}`;
 
         // Retain search parameters
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 02b44c90df..7dde4b4635 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
@@ -39,14 +39,14 @@ export class CatalogResolver implements Resolve<Promise<any[]>> {
     fetchSettings(): Promise<any> {
 
         return this.store.getItemBatch([
-            'eg.search.search_lib', 
+            'eg.search.search_lib',
             'eg.search.pref_lib'
         ]).then(settings => {
-            this.staffCat.defaultSearchOrg = 
+            this.staffCat.defaultSearchOrg =
                 this.org.get(settings['eg.search.search_lib']);
-            this.staffCat.prefOrg = 
+            this.staffCat.prefOrg =
                 this.org.get(settings['eg.search.pref_lib']);
-        })
+        });
     }
 }
 
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 7510b3d108..dd13b9d8f6 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
@@ -1,5 +1,5 @@
 import {Component, OnInit, OnDestroy, Input} from '@angular/core';
-import {Subscription} from 'rxjs/Subscription';
+import {Subscription} from 'rxjs';
 import {Router} from '@angular/router';
 import {OrgService} from '@eg/core/org.service';
 import {NetService} from '@eg/core/net.service';
@@ -67,7 +67,7 @@ export class ResultRecordComponent implements OnInit, OnDestroy {
             holdTarget = this.summary.metabibId;
         }
 
-        this.router.navigate([`/staff/catalog/hold/${holdType}`], 
+        this.router.navigate([`/staff/catalog/hold/${holdType}`],
             {queryParams: {target: holdTarget}});
     }
 
@@ -88,7 +88,7 @@ export class ResultRecordComponent implements OnInit, OnDestroy {
     navigateToRecord(summary: BibRecordSummary) {
         const params = this.catUrl.toUrlParams(this.searchContext);
 
-        // Jump to metarecord constituent records page when a 
+        // Jump to metarecord constituent records page when a
         // MR has more than 1 constituents.
         if (summary.metabibId && summary.metabibRecords.length > 1) {
             this.searchContext.termSearch.fromMetarecord = summary.metabibId;
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 6a03b9bdd2..869eff2e79 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
@@ -49,7 +49,7 @@ export class ResultsComponent implements OnInit, OnDestroy {
         // searches.
         //
         // This will also fire on page load.
-        this.routeSub = 
+        this.routeSub =
             this.route.queryParamMap.subscribe((params: ParamMap) => {
 
               // TODO: Angular docs suggest using switchMap(), but
@@ -82,8 +82,8 @@ export class ResultsComponent implements OnInit, OnDestroy {
         const ids = this.searchContext.currentResultIds();
         let allChecked = true;
         ids.forEach(id => {
-            if (!this.basket.hasRecordId(id)) { 
-                allChecked = false; 
+            if (!this.basket.hasRecordId(id)) {
+                allChecked = false;
             }
         });
         this.allRecsSelected = allChecked;
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 711ff90ac7..5b16f71816 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
@@ -29,7 +29,6 @@ export class SearchFormComponent implements OnInit, AfterViewInit {
         private staffCat: StaffCatalogService
     ) {
         this.copyLocations = [];
-        //this.searchTab = 'term';
     }
 
     ngOnInit() {
@@ -140,9 +139,9 @@ export class SearchFormComponent implements OnInit, AfterViewInit {
 
         // TODO: is this how we avoid displaying too many locations?
         const org = this.context.searchOrg;
-        if (org.id() === this.org.root().id()) { 
+        if (org.id() === this.org.root().id()) {
             this.copyLocations = [];
-            return; 
+            return;
         }
 
         this.cat.fetchCopyLocations(org).then(() =>
@@ -184,7 +183,7 @@ export class SearchFormComponent implements OnInit, AfterViewInit {
         this.context.pager.offset = 0; // New search
 
         // Form search overrides basket display
-        this.context.showBasket = false; 
+        this.context.showBasket = false;
 
         switch (this.searchTab) {
 
@@ -199,7 +198,7 @@ export class SearchFormComponent implements OnInit, AfterViewInit {
                 this.staffCat.search();
                 break;
 
-            case 'ident': 
+            case 'ident':
                 this.context.marcSearch.reset();
                 this.context.browseSearch.reset();
                 this.context.termSearch.reset();
diff --git a/Open-ILS/src/eg2/src/app/staff/nav.component.ts b/Open-ILS/src/eg2/src/app/staff/nav.component.ts
index 54bbcbe511..852562c8f1 100644
--- a/Open-ILS/src/eg2/src/app/staff/nav.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/nav.component.ts
@@ -45,11 +45,11 @@ export class StaffNavComponent implements OnInit {
         );
 
         // NOTE: this can eventually go away.
-        // Avoid attempts to fetch org settings if the user has not yet 
+        // Avoid attempts to fetch org settings if the user has not yet
         // logged in (e.g. this is the login page).
         if (this.user()) {
             this.org.settings('ui.staff.angular_catalog.enabled')
-            .then(settings => this.showAngularCatalog = 
+            .then(settings => this.showAngularCatalog =
                 Boolean(settings['ui.staff.angular_catalog.enabled']));
         }
     }
diff --git a/Open-ILS/src/eg2/src/app/staff/share/hold.service.ts b/Open-ILS/src/eg2/src/app/staff/share/hold.service.ts
index 3d89c20523..00e7374943 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/hold.service.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/hold.service.ts
@@ -2,23 +2,22 @@
  * Common code for mananging holdings
  */
 import {Injectable, EventEmitter} from '@angular/core';
-import {Observable} from 'rxjs/Observable';
-import {map} from 'rxjs/operators/map';
-import {mergeMap} from 'rxjs/operators/mergeMap';
+import {Observable} from 'rxjs';
+import {map, mergeMap} from 'rxjs/operators';
 import {IdlObject} from '@eg/core/idl.service';
 import {NetService} from '@eg/core/net.service';
 import {PcrudService} from '@eg/core/pcrud.service';
 import {EventService, EgEvent} from '@eg/core/event.service';
 import {AuthService} from '@eg/core/auth.service';
-import {BibRecordService, BibRecordSummary} 
-    from '@eg/share/catalog/bib-record.service';
+import {BibRecordService,
+    BibRecordSummary} from '@eg/share/catalog/bib-record.service';
 
 // Response from a place-holds API call.
 export interface HoldRequestResult {
     success: boolean;
     holdId?: number;
     evt?: EgEvent;
-};
+}
 
 // Values passed to the place-holds API call.
 export interface HoldRequest {
@@ -35,8 +34,8 @@ export interface HoldRequest {
     thawDate?: string; // ISO date
     frozen?: boolean;
     holdableFormats?: {[target: number]: string};
-    result?: HoldRequestResult
-};
+    result?: HoldRequestResult;
+}
 
 // A fleshed hold request target object containing whatever data is
 // available for each hold type / target.  E.g. a TITLE hold will
@@ -69,7 +68,7 @@ export class HoldService {
     ) {}
 
     placeHold(request: HoldRequest): Observable<HoldRequest> {
-        
+
         let method = 'open-ils.circ.holds.test_and_create.batch';
         if (request.override) { method = method + '.override'; }
 
@@ -119,7 +118,7 @@ export class HoldService {
         ));
     }
 
-    getHoldTargetMeta(holdType: string, holdTarget: number | number[], 
+    getHoldTargetMeta(holdType: string, holdTarget: number | number[],
         orgId?: number): Observable<HoldRequestTarget> {
 
         const targetIds = [].concat(holdTarget);

commit 3cf69812344e55e0a647d3c344e3c7665efc9493
Author: Bill Erickson <berickxx at gmail.com>
Date:   Thu Jan 10 13:33:19 2019 -0500

    LP1806087 Experimental Angular catalog release notes
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Dan Wells <dbw2 at calvin.edu>

diff --git a/docs/RELEASE_NOTES_NEXT/Client/angular-catalog-exp.adoc b/docs/RELEASE_NOTES_NEXT/Client/angular-catalog-exp.adoc
new file mode 100644
index 0000000000..8f6a3c2dc7
--- /dev/null
+++ b/docs/RELEASE_NOTES_NEXT/Client/angular-catalog-exp.adoc
@@ -0,0 +1,26 @@
+Option to Enable Experimental Angular Staff Catalog
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+A new org unit setting labeled 'GUI: Enable Experimental Angular 
+Staff Catalog' (ui.staff.angular_catalog.enabled) has been added, allowing
+sites to enable a menu option in the browser client for accessing
+the experimental Angular staff catalog.
+
+When set to true, a new entry in the navigation bar appears in the
+Cataloging menu labled "Staff Catalog (Experimental)".
+
+New Features (Since 3.2)
+++++++++++++++++++++++++
+
+ * Pub date filter
+ * Copy location filter
+ * Group formats and editions
+ * Identifier search
+ * MARC search
+ * Browse search
+ * Place holds
+ * Record baskets and actions
+ * Record detail tabs/actions point to AngularJS versions where needed.
+ * Record detail View In Catalog button
+
+

commit 38feacd1a79ab733e3ae8cfb356fba18383795f8
Author: Bill Erickson <berickxx at gmail.com>
Date:   Thu Jan 10 12:11:41 2019 -0500

    LP1806087 Org setting to enable experimental staff catalog
    
    Adds a new org unit setting "GUI: Enable Experimental Angular Staff
    Catalog" (ui.staff.angular_catalog.enabled).  When set to true, a new
    navbar menu item will appear in both the Angular and AngJS client menus
    for "Staff Catalog (Experimental)".  This action directs the user to the
    Angular staff catalog.
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Dan Wells <dbw2 at calvin.edu>

diff --git a/Open-ILS/src/eg2/src/app/core/org.service.ts b/Open-ILS/src/eg2/src/app/core/org.service.ts
index 71dba933ed..0acb2279e9 100644
--- a/Open-ILS/src/eg2/src/app/core/org.service.ts
+++ b/Open-ILS/src/eg2/src/app/core/org.service.ts
@@ -253,8 +253,9 @@ export class OrgService {
             }
 
         } else if (!anonymous) {
-            return Promise.reject(
-                'Use "anonymous" To retrieve org settings without an authtoken');
+            console.warn('Attempt to fetch org setting(s)', 
+                name, 'in non-anonymous mode without an authtoken');
+            return Promise.resolve({});
         }
 
         if (useCache) {
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 0642f493e1..d5ce844f53 100644
--- a/Open-ILS/src/eg2/src/app/staff/nav.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/nav.component.html
@@ -136,12 +136,9 @@
             <span class="material-icons">search</span>
             <span i18n>Search the Catalog</span>
           </a>
-          <!--
-            Link to experimental Angular staff catalog.
-            Leaving disabled until more functionality can be fleshed out.
-          -->
-          <a class="dropdown-item"
-              routerLink="/staff/catalog/search">
+          <a *ngIf="showAngularCatalog"
+            class="dropdown-item"
+            routerLink="/staff/catalog/search">
             <span class="material-icons">search</span>
             <span i18n>Staff Catalog (Experimental)</span>
           </a>
diff --git a/Open-ILS/src/eg2/src/app/staff/nav.component.ts b/Open-ILS/src/eg2/src/app/staff/nav.component.ts
index c477c116b9..54bbcbe511 100644
--- a/Open-ILS/src/eg2/src/app/staff/nav.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/nav.component.ts
@@ -1,6 +1,7 @@
 import {Component, OnInit, ViewChild} from '@angular/core';
 import {ActivatedRoute, Router} from '@angular/router';
 import {Location} from '@angular/common';
+import {OrgService} from '@eg/core/org.service';
 import {AuthService} from '@eg/core/auth.service';
 import {PcrudService} from '@eg/core/pcrud.service';
 import {LocaleService} from '@eg/core/locale.service';
@@ -18,8 +19,12 @@ export class StaffNavComponent implements OnInit {
     locales: any[];
     currentLocale: any;
 
+    // When active, show a link to the experimental Angular staff catalog
+    showAngularCatalog: boolean;
+
     constructor(
         private router: Router,
+        private org: OrgService,
         private auth: AuthService,
         private pcrud: PcrudService,
         private locale: LocaleService,
@@ -38,6 +43,15 @@ export class StaffNavComponent implements OnInit {
                     l => l.code() === this.locale.currentLocaleCode())[0];
             }
         );
+
+        // NOTE: this can eventually go away.
+        // Avoid attempts to fetch org settings if the user has not yet 
+        // logged in (e.g. this is the login page).
+        if (this.user()) {
+            this.org.settings('ui.staff.angular_catalog.enabled')
+            .then(settings => this.showAngularCatalog = 
+                Boolean(settings['ui.staff.angular_catalog.enabled']));
+        }
     }
 
     user() {
diff --git a/Open-ILS/src/eg2/src/app/staff/resolver.service.ts b/Open-ILS/src/eg2/src/app/staff/resolver.service.ts
index 748b701e5c..2c94ec76d3 100644
--- a/Open-ILS/src/eg2/src/app/staff/resolver.service.ts
+++ b/Open-ILS/src/eg2/src/app/staff/resolver.service.ts
@@ -130,7 +130,8 @@ export class StaffResolver implements Resolve<Observable<any>> {
             'lib.timezone',
             'webstaff.format.dates',
             'webstaff.format.date_and_time',
-            'ui.staff.max_recent_patrons'
+            'ui.staff.max_recent_patrons',
+            'ui.staff.angular_catalog.enabled' // navbar
         ]).then(settings => {
             this.format.wsOrgTimezone = settings['lib.timezone'];
             this.format.dateFormat = settings['webstaff.format.dates'];
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 ab3568eaee..5047e77f5e 100644
--- a/Open-ILS/src/sql/Pg/950.data.seed-values.sql
+++ b/Open-ILS/src/sql/Pg/950.data.seed-values.sql
@@ -19840,7 +19840,24 @@ VALUES (
     )
 );
 
-
+-- NOTE: This setting should be removed once the Angular catalog
+-- becomes the default.
+INSERT into config.org_unit_setting_type 
+    (name, datatype, grp, label, description)
+VALUES ( 
+    'ui.staff.angular_catalog.enabled', 'bool', 'gui',
+    oils_i18n_gettext(
+        'ui.staff.angular_catalog.enabled',
+        'GUI: Enable Experimental Angular Staff Catalog',
+        'coust', 'label'
+    ),
+    oils_i18n_gettext(
+        'ui.staff.angular_catalog.enabled',
+        'Display an entry point in the browser client for the ' ||
+        'experimental Angular staff catalog.',
+        'coust', 'description'
+    )
+);
 
 INSERT INTO config.org_unit_setting_type
     (name, label, description, grp, datatype)
diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.data.ang-catalog-preview-setting.sql b/Open-ILS/src/sql/Pg/upgrade/XXXX.data.ang-catalog-preview-setting.sql
new file mode 100644
index 0000000000..1bc4888045
--- /dev/null
+++ b/Open-ILS/src/sql/Pg/upgrade/XXXX.data.ang-catalog-preview-setting.sql
@@ -0,0 +1,23 @@
+BEGIN;
+
+-- SELECT evergreen.upgrade_deps_block_check('XXXX', :eg_version);
+
+INSERT into config.org_unit_setting_type 
+    (name, datatype, grp, label, description)
+VALUES ( 
+    'ui.staff.angular_catalog.enabled', 'bool', 'gui',
+    oils_i18n_gettext(
+        'ui.staff.angular_catalog.enabled',
+        'GUI: Enable Experimental Angular Staff Catalog',
+        'coust', 'label'
+    ),
+    oils_i18n_gettext(
+        'ui.staff.angular_catalog.enabled',
+        'Display an entry point in the browser client for the ' ||
+        'experimental Angular staff catalog.',
+        'coust', 'description'
+    )
+);
+
+COMMIT;
+
diff --git a/Open-ILS/src/templates/staff/navbar.tt2 b/Open-ILS/src/templates/staff/navbar.tt2
index eb1d4719c6..2b423470ae 100644
--- a/Open-ILS/src/templates/staff/navbar.tt2
+++ b/Open-ILS/src/templates/staff/navbar.tt2
@@ -253,16 +253,13 @@
           </li>
           <!--
             Link to experimental Angular staff catalog.
-            Leaving disabled until more functionality can be fleshed out.
           -->
-          <!--
-          <li>
-            <a href="/eg2/staff/catalog/search" target="_self">
+          <li ng-if="showAngularCatalog">
+            <a href="/eg2/staff/catalog/search">
               <span class="glyphicon glyphicon-search"></span>
               <span>[% l('Staff Catalog (Experimental)') %]</span>
             </a>
           </li>
-          -->
           <li>
             <a href="./cat/bucket/record/view" target="_self">
               <span class="glyphicon glyphicon-list-alt"></span>
diff --git a/Open-ILS/web/js/ui/default/staff/services/navbar.js b/Open-ILS/web/js/ui/default/staff/services/navbar.js
index dcc72deaa4..2702fc05f0 100644
--- a/Open-ILS/web/js/ui/default/staff/services/navbar.js
+++ b/Open-ILS/web/js/ui/default/staff/services/navbar.js
@@ -113,11 +113,16 @@ angular.module('egCoreMod')
                             $scope.username = egCore.auth.user().usrname();
                             $scope.workstation = egCore.auth.workstation();
 
-                            egCore.org.settings('ui.staff.max_recent_patrons')
-                            .then(function(s) {
+                            egCore.org.settings([
+                                'ui.staff.max_recent_patrons',
+                                'ui.staff.angular_catalog.enabled'
+                            ]).then(function(s) {
                                 var val = s['ui.staff.max_recent_patrons'];
                                 $scope.showRecentPatron = val > 0;
                                 $scope.showRecentPatrons = val > 1;
+
+                                $scope.showAngularCatalog = 
+                                    s['ui.staff.angular_catalog.enabled'];
                             });
                         }
                         // need to defer initialization of hotkeys to this point
diff --git a/Open-ILS/web/js/ui/default/staff/services/startup.js b/Open-ILS/web/js/ui/default/staff/services/startup.js
index 41c69775d0..706bd6ffdf 100644
--- a/Open-ILS/web/js/ui/default/staff/services/startup.js
+++ b/Open-ILS/web/js/ui/default/staff/services/startup.js
@@ -36,6 +36,7 @@ function($q,  $rootScope,  $location,  $window,  egIDL,  egAuth,  egEnv , egOrg
                 'webstaff.format.dates',
                 'webstaff.format.date_and_time',
                 'ui.staff.max_recent_patrons', // affects navbar
+                'ui.staff.angular_catalog.enabled', // affects navbar
                 'lib.timezone'
             ]).then(
                 function(set) {

commit 7075905d588919bce7e6a520b3a061e142e16e19
Author: Bill Erickson <berickxx at gmail.com>
Date:   Wed Nov 7 10:18:31 2018 -0500

    LP1806087 Angular staff catalog phase II.
    
    * Record detail tabs redirect to AngJS catalog where needed.
    * Initial holds placement UI.
    * Record baskets, actions, and UI.
    * Ported MonographParts tab to Angular
    * Set default catalog tab
    * Browse
    * MARC search
    * Identifier search
    * pub date filter
    * Record detail 'View in Catalog' button
    * Group formats and editions
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Dan Wells <dbw2 at calvin.edu>

diff --git a/Open-ILS/examples/fm_IDL.xml b/Open-ILS/examples/fm_IDL.xml
index b8d0efff60..db651878bd 100644
--- a/Open-ILS/examples/fm_IDL.xml
+++ b/Open-ILS/examples/fm_IDL.xml
@@ -3931,18 +3931,25 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA
 			<link field="record" reltype="has_a" key="id" map="" class="bre"/>
 		</links>
 	</class>
-	<class id="mmr" controller="open-ils.cstore" oils_obj:fieldmapper="metabib::metarecord" oils_persist:tablename="metabib.metarecord" reporter:label="Metarecord">
+	<class id="mmr" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="metabib::metarecord" oils_persist:tablename="metabib.metarecord" reporter:label="Metarecord">
 		<fields oils_persist:primary="id" oils_persist:sequence="metabib.metarecord_id_seq">
 			<field name="fingerprint"  reporter:datatype="text"/>
 			<field name="id" reporter:datatype="id" />
 			<field name="master_record" reporter:datatype="link"/>
 			<field name="mods"  reporter:datatype="text"/>
 			<field name="source_records" oils_persist:virtual="true" reporter:datatype="link"/>
+			<field name="source_maps" oils_persist:virtual="true" reporter:datatype="link"/>
 		</fields>
 		<links>
 			<link field="master_record" reltype="has_a" key="id" map="" class="bre"/>
 			<link field="source_records" reltype="has_many" key="metarecord" map="source" class="mmrsm"/>
+			<link field="source_maps" reltype="has_many" key="metarecord" class="mmrsm"/>
 		</links>
+		<permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+			<actions>
+				<retrieve/>
+			</actions>
+		</permacrud>
 	</class>
 	<class id="cnal" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="config::net_access_level" oils_persist:tablename="config.net_access_level" reporter:label="Net Access Level">
 		<fields oils_persist:primary="id" oils_persist:sequence="config.net_access_level_id_seq">
@@ -3975,7 +3982,7 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA
             </actions>
         </permacrud>
 	</class>
-	<class id="mmrsm" controller="open-ils.cstore" oils_obj:fieldmapper="metabib::metarecord_source_map" oils_persist:tablename="metabib.metarecord_source_map" oils_persist:field_safe="true" reporter:label="Metarecord Source Map">
+	<class id="mmrsm" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="metabib::metarecord_source_map" oils_persist:tablename="metabib.metarecord_source_map" oils_persist:field_safe="true" reporter:label="Metarecord Source Map">
 		<fields oils_persist:primary="id" oils_persist:sequence="metabib.metarecord_source_map_id_seq">
 			<field name="id" reporter:datatype="id" />
 			<field name="metarecord" reporter:datatype="link"/>
@@ -3985,6 +3992,11 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA
 			<link field="source" reltype="has_a" key="id" map="" class="bre"/>
 			<link field="metarecord" reltype="has_a" key="id" map="" class="mmr"/>
 		</links>
+		<permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+			<actions>
+				<retrieve/>
+			</actions>
+		</permacrud>
 	</class>
 	<class id="mde" controller="open-ils.cstore open-ils.pcrud" 
 			oils_obj:fieldmapper="metabib::display_entry" 
@@ -4140,6 +4152,11 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA
 		<links>
 			<link field="def_maps" reltype="has_many" key="entry" map="" class="mbedm"/>
 		</links>
+		<permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+			<actions>
+				<retrieve/>
+			</actions>
+		</permacrud>
 	</class>
 	<class id="mbedm" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="metabib::browse_entry_def_map" oils_persist:tablename="metabib.browse_entry_def_map" reporter:label="Combined Browse Entry Definition Map" oils_persist:readonly="true">
 		<fields oils_persist:primary="id" oils_persist:sequence="metabib.browse_entry_def_map_id_seq">
diff --git a/Open-ILS/src/eg2/src/app/core/org.service.ts b/Open-ILS/src/eg2/src/app/core/org.service.ts
index 530e3cb5ad..71dba933ed 100644
--- a/Open-ILS/src/eg2/src/app/core/org.service.ts
+++ b/Open-ILS/src/eg2/src/app/core/org.service.ts
@@ -231,9 +231,10 @@ export class OrgService {
     /**
      *
      */
-    settings(names: string[],
+    settings(name: string | string[],
         orgId?: number, anonymous?: boolean): Promise<OrgSettingsBatch> {
 
+        let names = [].concat(name);
         const settings = {};
         let auth: string = null;
         let useCache = false;
diff --git a/Open-ILS/src/eg2/src/app/core/perm.service.ts b/Open-ILS/src/eg2/src/app/core/perm.service.ts
index 44d3c635fb..2b3a471ad2 100644
--- a/Open-ILS/src/eg2/src/app/core/perm.service.ts
+++ b/Open-ILS/src/eg2/src/app/core/perm.service.ts
@@ -41,7 +41,8 @@ export class PermService {
     }
 
     // workstation required
-    hasWorkPermHere(permNames: string[]): Promise<HasPermHereResult> {
+    hasWorkPermHere(permNames: string | string[]): Promise<HasPermHereResult> {
+        permNames = [].concat(permNames);
         const wsId: number = +this.auth.user().wsid();
 
         if (!wsId) {
diff --git a/Open-ILS/src/eg2/src/app/core/server-store.service.ts b/Open-ILS/src/eg2/src/app/core/server-store.service.ts
index 43415c1951..ea2d93da36 100644
--- a/Open-ILS/src/eg2/src/app/core/server-store.service.ts
+++ b/Open-ILS/src/eg2/src/app/core/server-store.service.ts
@@ -65,7 +65,7 @@ export class ServerStoreService {
 
         const values: any = {};
         keys.forEach(key => {
-            if (this.cache[key]) {
+            if (key in this.cache) {
                 values[key] = this.cache[key];
             }
         });
diff --git a/Open-ILS/src/eg2/src/app/share/catalog/basket.service.ts b/Open-ILS/src/eg2/src/app/share/catalog/basket.service.ts
new file mode 100644
index 0000000000..99c8c24ca0
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/share/catalog/basket.service.ts
@@ -0,0 +1,103 @@
+import {Injectable, EventEmitter} from '@angular/core';
+import {Observable} from 'rxjs/Observable';
+import {StoreService} from '@eg/core/store.service';
+import {NetService} from '@eg/core/net.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {AnonCacheService} from '@eg/share/util/anon-cache.service';
+
+// Baskets are stored in an anonymous cache using the cache key stored
+// in a LoginSessionItem (i.e. cookie) at name BASKET_CACHE_KEY_COOKIE.
+// The list is stored under attribute BASKET_CACHE_ATTR.
+// Avoid conflicts with the AngularJS embedded catalog basket by
+// using a different value for the cookie name, since our version
+// stores all cookies as JSON, unlike the TPAC.
+const BASKET_CACHE_KEY_COOKIE = 'basket';
+const BASKET_CACHE_ATTR = 'recordIds';
+
+ at Injectable()
+export class BasketService {
+
+    idList: number[];
+
+    // Fired every time our list of ID's are updated.
+    onChange: EventEmitter<number[]>;
+
+    constructor(
+        private net: NetService,
+        private pcrud: PcrudService,
+        private store: StoreService,
+        private anonCache: AnonCacheService
+    ) { 
+        this.idList = []; 
+        this.onChange = new EventEmitter<number[]>();
+    }
+
+    hasRecordId(id: number): boolean {
+        return this.idList.indexOf(Number(id)) > -1;
+    }
+
+    recordCount(): number {
+        return this.idList.length;
+    }
+
+    // TODO: Add server-side API for sorting a set of bibs by ID.
+    // See EGCatLoader/Container::fetch_mylist
+    getRecordIds(): Promise<number[]> {
+        const cacheKey = this.store.getLoginSessionItem(BASKET_CACHE_KEY_COOKIE);
+        this.idList = [];
+
+        if (!cacheKey) { return Promise.resolve(this.idList); }
+
+        return this.anonCache.getItem(cacheKey, BASKET_CACHE_ATTR).then(
+            list => {
+                if (!list) {return this.idList};
+                this.idList = list.map(id => Number(id));
+                return this.idList;
+            }
+        );
+    }
+
+    setRecordIds(ids: number[]): Promise<number[]> {
+        this.idList = ids;
+
+        // If we have no cache key, that's OK, assume this is the first
+        // attempt at adding a value and let the server create the cache
+        // key for us, then store the value in our cookie.
+        const cacheKey = this.store.getLoginSessionItem(BASKET_CACHE_KEY_COOKIE);
+
+        return this.anonCache.setItem(cacheKey, BASKET_CACHE_ATTR, this.idList)
+        .then(cacheKey => {
+            this.store.setLoginSessionItem(BASKET_CACHE_KEY_COOKIE, cacheKey);
+            this.onChange.emit(this.idList);
+            return this.idList;
+        });
+    }
+
+    addRecordIds(ids: number[]): Promise<number[]> {
+        ids = ids.filter(id => !this.hasRecordId(id)); // avoid dupes
+
+        if (ids.length === 0) { 
+            return Promise.resolve(this.idList); 
+        }
+        return this.setRecordIds(
+            this.idList.concat(ids.map(id => Number(id))));
+    }
+
+    removeRecordIds(ids: number[]): Promise<number[]> {
+
+        if (this.idList.length === 0) {
+            return Promise.resolve(this.idList);
+        }
+
+        const wantedIds = this.idList.filter(
+            id => ids.indexOf(Number(id)) < 0);
+
+        return this.setRecordIds(wantedIds); // OK if empty
+    }
+
+    removeAllRecordIds(): Promise<number[]> {
+        return this.setRecordIds([]);
+    }
+}
+
+
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 e9fbb610ff..5602bbb192 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
@@ -1,6 +1,6 @@
 import {Injectable} from '@angular/core';
 import {Observable, from} from 'rxjs';
-import {mergeMap, map} from 'rxjs/operators';
+import {mergeMap, map, tap} from 'rxjs/operators';
 import {OrgService} from '@eg/core/org.service';
 import {UnapiService} from '@eg/share/catalog/unapi.service';
 import {IdlService, IdlObject} from '@eg/core/idl.service';
@@ -20,6 +20,8 @@ export const HOLDINGS_XPATH =
 
 export class BibRecordSummary {
     id: number; // == record.id() for convenience
+    metabibId: number; // If present, this is a metabib summary
+    metabibRecords: number[]; // all constituent bib records
     orgId: number;
     orgDepth: number;
     record: IdlObject;
@@ -38,6 +40,7 @@ export class BibRecordSummary {
         this.display = {};
         this.attributes = {};
         this.bibCallNumber = null;
+        this.metabibRecords = [];
     }
 
     ingest() {
@@ -67,7 +70,10 @@ export class BibRecordSummary {
         // Any attr can be multi-valued.
         this.record.mattrs().forEach(attr => {
             if (this.attributes[attr.attr()]) {
-                this.attributes[attr.attr()].push(attr.value());
+                // Avoid dupes
+                if (this.attributes[attr.attr()].indexOf(attr.value()) < 0) {
+                    this.attributes[attr.attr()].push(attr.value());
+                }
             } else {
                 this.attributes[attr.attr()] = [attr.value()];
             }
@@ -81,9 +87,16 @@ export class BibRecordSummary {
             return Promise.resolve(this.holdCount);
         }
 
+        let method = 'open-ils.circ.bre.holds.count';
+        let target = this.id;
+
+        if (this.metabibId) {
+            method = 'open-ils.circ.mmr.holds.count';
+            target = this.metabibId;
+        }
+
         return this.net.request(
-            'open-ils.circ',
-            'open-ils.circ.bre.holds.count', this.id
+            'open-ils.circ', method, target
         ).toPromise().then(count => this.holdCount = count);
     }
 
@@ -131,7 +144,7 @@ export class BibRecordService {
     }
 
     // Avoid fetching the MARC blob by specifying which fields on the
-    // bre to select.  Note that fleshed fields are explicitly selected.
+    // bre to select.  Note that fleshed fields are implicitly selected.
     fetchableBreFields(): string[] {
         return this.idl.classes.bre.fields
             .filter(f => !f.virtual && f.name !== 'marc')
@@ -167,6 +180,83 @@ export class BibRecordService {
         }));
     }
 
+    // A Metabib Summary is a BibRecordSummary with the lead record as 
+    // its core bib record plus attributes (e.g. formats) from related 
+    // records.
+    getMetabibSummary(metabibIds: number | number[], 
+        orgId?: number, orgDepth?: number): Observable<BibRecordSummary> {
+
+        const ids = [].concat(metabibIds);
+
+        if (ids.length === 0) {
+            return from([]);
+        }
+
+        return this.pcrud.search('mmr', {id: ids}, 
+            {flesh: 1, flesh_fields: {mmr: ['source_maps']}}, 
+            {anonymous: true}
+        ).pipe(mergeMap(mmr => this.compileMetabib(mmr, orgId, orgDepth)));
+    }
+
+    // 'metabib' must have its "source_maps" field fleshed.
+    // Get bib summaries for all related bib records so we can 
+    // extract data that must be appended to the master record summary.
+    compileMetabib(metabib: IdlObject, 
+        orgId?: number, orgDepth?: number): Observable<BibRecordSummary> {
+
+        // TODO: Create an API similar to the one that builds a combined
+        // mods blob for metarecords, except using display fields, etc.
+        // For now, this seems to get the job done.
+
+        // Non-master records
+        const relatedBibIds = metabib.source_maps()
+            .map(map => map.source())
+            .filter(id => id !== metabib.master_record());
+
+        let observer;
+        const observable = new Observable<BibRecordSummary>(o => observer = o);
+
+        // NOTE: getBibSummary calls getHoldingsSummary against
+        // the bib record unnecessarily.  It's called again below.
+        // Reconsider this approach (see also note above about API).
+        this.getBibSummary(metabib.master_record(), orgId, orgDepth)
+        .subscribe(summary => {
+            summary.metabibId = metabib.id();
+            summary.metabibRecords = 
+                metabib.source_maps().map(map => Number(map.source()))
+
+            let promise;
+
+            if (relatedBibIds.length > 0) {
+
+                // Grab data for MR bib summary augmentation
+                promise = this.pcrud.search('mraf', {id: relatedBibIds})
+                    .pipe(tap(attr => summary.record.mattrs().push(attr)))
+                    .toPromise();
+            } else {
+
+                // Metarecord has only one constituent bib.
+                promise = Promise.resolve();
+            }
+
+            promise.then(() => {
+
+                // Re-compile with augmented data
+                summary.compileRecordAttrs();
+
+                // Fetch holdings data for the metarecord
+                this.getHoldingsSummary(metabib.id(), orgId, orgDepth, true)
+                .then(holdingsSummary => {
+                    summary.holdingsSummary = holdingsSummary;
+                    observer.next(summary);
+                    observer.complete();
+                });
+            });
+        });
+
+        return observable;
+    }
+
     // Flesh the creator and editor fields.
     // Handling this separately lets us pull from the cache and
     // avoids the requirement that the main bib query use a staff
@@ -207,12 +297,12 @@ export class BibRecordService {
     }
 
     getHoldingsSummary(recordId: number,
-        orgId: number, orgDepth: number): Promise<any> {
+        orgId: number, orgDepth: number, isMetarecord?: boolean): Promise<any> {
 
         const holdingsSummary = [];
 
         return this.unapi.getAsXmlDocument({
-            target: 'bre',
+            target: isMetarecord ? 'mmr' : 'bre',
             id: recordId,
             extras: '{holdings_xml}',
             format: 'holdings_xml',
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 c370b300c9..eeaf38af29 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,6 +1,8 @@
 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';
 import {UnapiService} from './unapi.service';
@@ -18,10 +20,12 @@ import {MarcHtmlComponent} from './marc-html.component';
         MarcHtmlComponent
     ],
     providers: [
+        AnonCacheService,
         CatalogService,
         CatalogUrlService,
         UnapiService,
-        BibRecordService
+        BibRecordService,
+        BasketService,
     ]
 })
 
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 253e3aacdd..0f07070656 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
@@ -1,8 +1,9 @@
 import {Injectable} from '@angular/core';
 import {ParamMap} from '@angular/router';
 import {OrgService} from '@eg/core/org.service';
-import {CatalogSearchContext, FacetFilter} from './search-context';
-import {CATALOG_CCVM_FILTERS} from './catalog.service';
+import {CatalogSearchContext, CatalogBrowseContext, CatalogMarcContext, 
+   CatalogTermContext, FacetFilter} from './search-context';
+import {CATALOG_CCVM_FILTERS} from './search-context';
 
 @Injectable()
 export class CatalogUrlService {
@@ -19,28 +20,22 @@ export class CatalogUrlService {
     toUrlParams(context: CatalogSearchContext):
             {[key: string]: string | string[]} {
 
-        const params = {
-            query: [],
-            fieldClass: [],
-            joinOp: [],
-            matchOp: [],
-            facets: [],
-            identQuery: null,
-            identQueryType: null,
-            org: null,
-            limit: null,
-            offset: null
-        };
-
-        params.org = context.searchOrg.id();
-
-        params.limit = context.pager.limit;
+        const params: any = {};
+
+        if (context.searchOrg) {
+            params.org = context.searchOrg.id();
+        }
+
+        if (context.pager.limit) {
+            params.limit = context.pager.limit;
+        }
+
         if (context.pager.offset) {
             params.offset = context.pager.offset;
         }
 
         // These fields can be copied directly into place
-        ['format', 'sort', 'available', 'global', 'identQuery', 'identQueryType']
+        ['limit', 'offset', 'sort', 'global', 'showBasket', 'sort']
         .forEach(field => {
             if (context[field]) {
                 // Only propagate applied values to the URL.
@@ -48,36 +43,84 @@ export class CatalogUrlService {
             }
         });
 
-        if (params.identQuery) {
-            // Ident queries (e.g. tcn search) discards all remaining filters
-            return params;
+        if (context.marcSearch.isSearchable()) {
+            const ms = context.marcSearch;
+            params.marcTag = [];
+            params.marcSubfield = [];
+            params.marcValue = [];
+
+            ms.values.forEach((val, idx) => {
+                if (val !== '') {
+                    params.marcTag.push(ms.tags[idx]);
+                    params.marcSubfield.push(ms.subfields[idx]);
+                    params.marcValue.push(ms.values[idx]);
+                }
+            });
         }
 
-        context.query.forEach((q, idx) => {
-            ['query', 'fieldClass', 'joinOp', 'matchOp'].forEach(field => {
-                // Propagate all array-based fields regardless of
-                // whether a value is applied to ensure correct
-                // correlation between values.
-                params[field][idx] = context[field][idx];
-            });
-        });
+        if (context.identSearch.isSearchable()) {
+            params.identQuery = context.identSearch.value;
+            params.identQueryType = context.identSearch.queryType;
+        }
 
-        // CCVM filters are encoded as comma-separated lists
-        Object.keys(context.ccvmFilters).forEach(code => {
-            if (context.ccvmFilters[code] &&
-                context.ccvmFilters[code][0] !== '') {
-                params[code] = context.ccvmFilters[code].join(',');
+        if (context.browseSearch.isSearchable()) {
+            params.browseTerm = context.browseSearch.value;
+            params.browseClass = context.browseSearch.fieldClass;
+            if (context.browseSearch.pivot) {
+                params.browsePivot = context.browseSearch.pivot;
             }
-        });
+        }
 
-        // Each facet is a JSON encoded blob of class, name, and value
-        context.facetFilters.forEach(facet => {
-            params.facets.push(JSON.stringify({
-                c : facet.facetClass,
-                n : facet.facetName,
-                v : facet.facetValue
-            }));
-        });
+        if (context.termSearch.isSearchable()) {
+
+            const ts = context.termSearch;
+
+            params.query = [];
+            params.fieldClass = [];
+            params.joinOp = [];
+            params.matchOp = [];
+
+            ['format', 'available', 'hasBrowseEntry', 'date1', 
+                'date2', 'dateOp', 'groupByMetarecord', 'fromMetarecord']
+            .forEach(field => {
+                if (ts[field]) {
+                    params[field] = ts[field];
+                }
+            });
+
+            ts.query.forEach((val, idx) => {
+                if (val !== '') {
+                    params.query.push(ts.query[idx]);
+                    params.fieldClass.push(ts.fieldClass[idx]);
+                    params.joinOp.push(ts.joinOp[idx]);
+                    params.matchOp.push(ts.matchOp[idx]);
+                }
+            });
+
+            // CCVM filters are encoded as comma-separated lists
+            Object.keys(ts.ccvmFilters).forEach(code => {
+                if (ts.ccvmFilters[code] &&
+                    ts.ccvmFilters[code][0] !== '') {
+                    params[code] = ts.ccvmFilters[code].join(',');
+                }
+            });
+
+            // Each facet is a JSON encoded blob of class, name, and value
+            if (ts.facetFilters.length) {
+                params.facets = [];
+                ts.facetFilters.forEach(facet => {
+                    params.facets.push(JSON.stringify({
+                        c : facet.facetClass,
+                        n : facet.facetName,
+                        v : facet.facetValue
+                    }));
+                });
+            }
+        
+            if (ts.copyLocations.length && ts.copyLocations[0] !== '') {
+                params.copyLocations = ts.copyLocations.join(',');
+            }
+        }
 
         return params;
     }
@@ -97,47 +140,96 @@ export class CatalogUrlService {
 
         // Reset query/filter args.  The will be reconstructed below.
         context.reset();
+        let val;
 
-        // These fields can be copied directly into place
-        ['format', 'sort', 'available', 'global', 'identQuery', 'identQueryType']
-        .forEach(field => {
-            const val = params.get(field);
-            if (val !== null) {
-                context[field] = val;
-            }
-        });
+        if (params.get('org')) {
+            context.searchOrg = this.org.get(+params.get('org'));
+        }
 
-        if (params.get('limit')) {
-            context.pager.limit = +params.get('limit');
+        if (val = params.get('limit')) {
+            context.pager.limit = +val;
         }
 
-        if (params.get('offset')) {
-            context.pager.offset = +params.get('offset');
+        if (val = params.get('offset')) {
+            context.pager.offset = +val;
         }
 
-        ['query', 'fieldClass', 'joinOp', 'matchOp'].forEach(field => {
-            const arr = params.getAll(field);
-            if (arr && arr.length) {
-                context[field] = arr;
-            }
-        });
+        if (val = params.get('sort')) {
+            context.sort = val;
+        }
+
+        if (val = params.get('global')) {
+            context.global = val;
+        }
+
+        if (val = params.get('showBasket')) {
+            context.showBasket = val;
+        }
 
-        CATALOG_CCVM_FILTERS.forEach(code => {
-            const val = params.get(code);
-            if (val) {
-                context.ccvmFilters[code] = val.split(/,/);
-            } else {
-                context.ccvmFilters[code] = [''];
+        if (params.get('marcValue')) {
+            context.marcSearch.tags = params.getAll('marcTag');
+            context.marcSearch.subfields = params.getAll('marcSubfield');
+            context.marcSearch.values = params.getAll('marcValue');
+        }
+
+        if (params.get('identQuery')) {
+            context.identSearch.value = params.get('identQuery');
+            context.identSearch.queryType = params.get('identQueryType');
+        }
+
+        if (params.get('browseTerm')) {
+            context.browseSearch.value = params.get('browseTerm');
+            context.browseSearch.fieldClass = params.get('browseClass');
+            if (params.has('browsePivot')) {
+                context.browseSearch.pivot = +params.get('browsePivot');
             }
-        });
+        }
+
+        const ts = context.termSearch;
 
+        // browseEntry and query searches may be facet-limited
         params.getAll('facets').forEach(blob => {
             const facet = JSON.parse(blob);
-            context.addFacet(new FacetFilter(facet.c, facet.n, facet.v));
+            ts.addFacet(new FacetFilter(facet.c, facet.n, facet.v));
         });
 
-        if (params.get('org')) {
-            context.searchOrg = this.org.get(+params.get('org'));
+        if (params.has('hasBrowseEntry')) {
+
+            ts.hasBrowseEntry = params.get('hasBrowseEntry');
+
+        } else if (params.has('query')) {
+
+            // Scalars
+            ['format', 'available', 'date1', 'date2', 
+                'dateOp', 'groupByMetarecord', 'fromMetarecord']
+            .forEach(field => {
+                if (params.has(field)) {
+                    ts[field] = params.get(field);
+                }
+            });
+
+            // Arrays
+            ['query', 'fieldClass', 'joinOp', 'matchOp'].forEach(field => {
+                const arr = params.getAll(field);
+                if (params.has(field)) {
+                    ts[field] = params.getAll(field); 
+                }
+            });
+
+            CATALOG_CCVM_FILTERS.forEach(code => {
+                const val = params.get(code);
+                if (val) {
+                    ts.ccvmFilters[code] = val.split(/,/);
+                } else {
+                    ts.ccvmFilters[code] = [''];
+                }
+            });
+
+            if (params.get('copyLocations')) {
+                ts.copyLocations = params.get('copyLocations').split(/,/);
+            }
         }
     }
 }
+
+
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 7c3a365b60..b8ffb857d4 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
@@ -1,6 +1,6 @@
-import {Injectable} from '@angular/core';
+import {Injectable, EventEmitter} from '@angular/core';
 import {Observable} from 'rxjs';
-import {mergeMap, map} from 'rxjs/operators';
+import {mergeMap, map, tap} from 'rxjs/operators';
 import {OrgService} from '@eg/core/org.service';
 import {UnapiService} from '@eg/share/catalog/unapi.service';
 import {IdlService, IdlObject} from '@eg/core/idl.service';
@@ -8,27 +8,15 @@ import {NetService} from '@eg/core/net.service';
 import {PcrudService} from '@eg/core/pcrud.service';
 import {CatalogSearchContext, CatalogSearchState} from './search-context';
 import {BibRecordService, BibRecordSummary} from './bib-record.service';
-
-// CCVM's we care about in a catalog context
-// Don't fetch them all because there are a lot.
-export const CATALOG_CCVM_FILTERS = [
-    'item_type',
-    'item_form',
-    'item_lang',
-    'audience',
-    'audience_group',
-    'vr_format',
-    'bib_level',
-    'lit_form',
-    'search_format',
-    'icon_format'
-];
+import {BasketService} from './basket.service';
+import {CATALOG_CCVM_FILTERS} from './search-context';
 
 @Injectable()
 export class CatalogService {
 
     ccvmMap: {[ccvm: string]: IdlObject[]} = {};
     cmfMap: {[cmf: string]: IdlObject} = {};
+    copyLocations: IdlObject[];
 
     // Keep a reference to the most recently retrieved facet data,
     // since facet data is consistent across a given search.
@@ -36,27 +24,117 @@ export class CatalogService {
     lastFacetData: any;
     lastFacetKey: string;
 
+    // Allow anyone to watch for completed searches.
+    onSearchComplete: EventEmitter<CatalogSearchContext>;
+
     constructor(
         private idl: IdlService,
         private net: NetService,
         private org: OrgService,
         private unapi: UnapiService,
         private pcrud: PcrudService,
-        private bibService: BibRecordService
-    ) {}
+        private bibService: BibRecordService,
+        private basket: BasketService
+    ) {
+        this.onSearchComplete = new EventEmitter<CatalogSearchContext>();
+        
+    }
 
     search(ctx: CatalogSearchContext): Promise<void> {
         ctx.searchState = CatalogSearchState.SEARCHING;
 
-        const fullQuery = ctx.compileSearch();
+        if (ctx.showBasket) {
+            return this.basketSearch(ctx);
+        } else if (ctx.marcSearch.isSearchable()) {
+            return this.marcSearch(ctx);
+        } else if (ctx.identSearch.isSearchable() && 
+            ctx.identSearch.queryType === 'item_barcode') {
+            return this.barcodeSearch(ctx);
+        } else {
+            return this.termSearch(ctx);
+        }
+    }
 
-        console.debug(`search query: ${fullQuery}`);
+    barcodeSearch(ctx: CatalogSearchContext): Promise<void> {
+        return this.net.request(
+            'open-ils.search',
+            'open-ils.search.multi_home.bib_ids.by_barcode',
+            ctx.identSearch.value
+        ).toPromise().then(ids => {
+            const result = {
+                count: ids.length,
+                ids: ids.map(id => [id])
+            };
+
+            this.applyResultData(ctx, result);
+            ctx.searchState = CatalogSearchState.COMPLETE;
+            this.onSearchComplete.emit(ctx);
+        });
+    }
+
+    // "Search" the basket by loading the IDs and treating
+    // them like a standard query search results set.
+    basketSearch(ctx: CatalogSearchContext): Promise<void> {
+
+        return this.basket.getRecordIds().then(ids => {
+
+            // Map our list of IDs into a search results object
+            // the search context can understand.
+            const result = {
+                count: ids.length,
+                ids: ids.map(id => [id])
+            };
+
+            this.applyResultData(ctx, result);
+            ctx.searchState = CatalogSearchState.COMPLETE;
+            this.onSearchComplete.emit(ctx);
+        });
+    }
+
+    marcSearch(ctx: CatalogSearchContext): Promise<void> {
+        let method = 'open-ils.search.biblio.marc';
+        if (ctx.isStaff) { method += '.staff'; }
+
+        const queryStruct = ctx.compileMarcSearchArgs();
+
+        return this.net.request('open-ils.search', method, queryStruct)
+        .toPromise().then(result => {
+            // Match the query search return format
+            result.ids = result.ids.map(id => [id]);
+
+            this.applyResultData(ctx, result);
+            ctx.searchState = CatalogSearchState.COMPLETE;
+            this.onSearchComplete.emit(ctx);
+        });
+    }
+
+    termSearch(ctx: CatalogSearchContext): Promise<void> {
 
         let method = 'open-ils.search.biblio.multiclass.query';
+        let fullQuery;
+
+        if (ctx.identSearch.isSearchable()) {
+            fullQuery = ctx.compileIdentSearchQuery();
+
+        } else {
+            fullQuery = ctx.compileTermSearchQuery();
+
+            if (ctx.termSearch.groupByMetarecord 
+                && !ctx.termSearch.fromMetarecord) {
+                method = 'open-ils.search.metabib.multiclass.query';
+            }
+
+            if (ctx.termSearch.hasBrowseEntry) {
+                this.fetchBrowseEntry(ctx);
+            }
+        }
+
+        console.debug(`search query: ${fullQuery}`);
+
         if (ctx.isStaff) {
             method += '.staff';
         }
-
+        
         return new Promise((resolve, reject) => {
             this.net.request(
                 'open-ils.search', method, {
@@ -66,9 +144,24 @@ export class CatalogService {
             ).subscribe(result => {
                 this.applyResultData(ctx, result);
                 ctx.searchState = CatalogSearchState.COMPLETE;
+                this.onSearchComplete.emit(ctx);
                 resolve();
             });
         });
+
+    }
+
+    // When showing titles linked to a browse entry, fetch 
+    // the entry data as well so the UI can display it.
+    fetchBrowseEntry(ctx: CatalogSearchContext) {
+        const ts = ctx.termSearch;
+
+        const parts = ts.hasBrowseEntry.split(',');
+        const mbeId = parts[0];
+        const cmfId = parts[1];
+
+        this.pcrud.retrieve('mbe', mbeId)
+        .subscribe(mbe => ctx.termSearch.browseEntry = mbe);
     }
 
     applyResultData(ctx: CatalogSearchContext, result: any): void {
@@ -94,11 +187,27 @@ export class CatalogService {
             ctx.org.root().ou_type().depth() :
             ctx.searchOrg.ou_type().depth();
 
-        return this.bibService.getBibSummary(
-            ctx.currentResultIds(), ctx.searchOrg.id(), depth)
-        .pipe(map(summary => {
+        const isMeta = ctx.termSearch.isMetarecordSearch();
+
+        let observable: Observable<BibRecordSummary>;
+        
+        if (isMeta) {
+            observable = this.bibService.getMetabibSummary(
+                ctx.currentResultIds(), ctx.searchOrg.id(), depth);
+        } else {
+            observable = this.bibService.getBibSummary(
+                ctx.currentResultIds(), ctx.searchOrg.id(), depth);
+        }
+
+        return observable.pipe(map(summary => {
             // Responses are not necessarily returned in request-ID order.
-            const idx = ctx.currentResultIds().indexOf(summary.record.id());
+            let idx;
+            if (isMeta) {
+                idx = ctx.currentResultIds().indexOf(summary.metabibId);
+            } else {
+                idx = ctx.currentResultIds().indexOf(summary.id);
+            }
+
             if (ctx.result.records) {
                 // May be reset when quickly navigating results.
                 ctx.result.records[idx] = summary;
@@ -112,6 +221,10 @@ export class CatalogService {
             return Promise.reject('Cannot fetch facets without results');
         }
 
+        if (!ctx.result.facet_key) {
+            return Promise.resolve();
+        }
+
         if (this.lastFacetKey === ctx.result.facet_key) {
             ctx.result.facetData = this.lastFacetData;
             return Promise.resolve();
@@ -188,6 +301,15 @@ export class CatalogService {
         });
     }
 
+    iconFormatLabel(code: string): string {
+        if (this.ccvmMap) {
+            const ccvm = this.ccvmMap.icon_format.filter(
+                format => format.code() === code)[0];
+            if (ccvm) {
+                return ccvm.search_label();
+            }                                                                  
+        }                                                                      
+    }      
 
     fetchCmfs(): Promise<void> {
         // At the moment, we only need facet CMFs.
@@ -206,4 +328,38 @@ export class CatalogService {
             );
         });
     }
+
+    fetchCopyLocations(contextOrg: number | IdlObject): Promise<any> {
+        const orgIds = this.org.fullPath(contextOrg, true);
+        this.copyLocations = [];
+
+        return this.pcrud.search('acpl', 
+            {deleted: 'f', opac_visible: 't', owning_lib: orgIds},
+            {order_by: {acpl: 'name'}},
+            {anonymous: true}
+        ).pipe(tap(loc => this.copyLocations.push(loc))).toPromise()
+    }
+
+    browse(ctx: CatalogSearchContext): Observable<any> {
+        ctx.searchState = CatalogSearchState.SEARCHING;
+        const bs = ctx.browseSearch;
+
+        let method = 'open-ils.search.browse';
+        if (ctx.isStaff) {
+            method += '.staff';
+        }
+
+        return this.net.request(
+            'open-ils.search',
+            'open-ils.search.browse.staff', {
+                browse_class: bs.fieldClass,
+                term: bs.value,
+                limit : ctx.pager.limit,
+                pivot: bs.pivot,
+                org_unit: ctx.searchOrg.id()
+            }
+        ).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 e4e64b2d0e..d34d71105d 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
@@ -3,6 +3,21 @@ import {IdlObject} from '@eg/core/idl.service';
 import {Pager} from '@eg/share/util/pager';
 import {Params} from '@angular/router';
 
+// CCVM's we care about in a catalog context
+// Don't fetch them all because there are a lot.
+export const CATALOG_CCVM_FILTERS = [
+    'item_type',
+    'item_form',
+    'item_lang',
+    'audience',
+    'audience_group',
+    'vr_format',
+    'bib_level',
+    'lit_form',
+    'search_format',
+    'icon_format'
+];
+
 export enum CatalogSearchState {
     PENDING,
     SEARCHING,
@@ -29,32 +44,183 @@ export class FacetFilter {
     }
 }
 
-// Not an angular service.
-// It's conceviable there could be multiple contexts.
-export class CatalogSearchContext {
+export class CatalogSearchResults {
+    ids: number[];
+    count: number;
+    [misc: string]: any;
 
-    // Search options and filters
-    available = false;
-    global = false;
-    sort: string;
+    constructor() {
+        this.ids = [];
+        this.count = 0;
+    }
+}
+
+export class CatalogBrowseContext {
+    value: string;
+    pivot: number;
+    fieldClass: string;
+
+    reset() {
+        this.value = '';
+        this.pivot = null;
+        this.fieldClass = 'title';
+    }
+
+    isSearchable(): boolean {
+        return (
+            this.value !== '' &&
+            this.fieldClass !== ''
+        );
+    }
+}
+
+export class CatalogMarcContext {
+    tags: string[];
+    subfields: string[];
+    values: string[];
+
+    reset() {
+        this.tags = [''];
+        this.values = [''];
+        this.subfields = [''];
+    }
+
+    isSearchable() {
+        return (
+            this.tags[0] !== '' &&
+            this.values[0] !== ''
+        );
+    }
+
+}
+
+export class CatalogIdentContext {
+    value: string;
+    queryType: string; 
+
+    reset() {
+        this.value = '';
+        this.queryType = '';
+    }
+
+    isSearchable() {
+        return (
+            this.value !== '' 
+            && this.queryType !== ''
+        );
+    }
+
+}
+
+export class CatalogTermContext {
     fieldClass: string[];
     query: string[];
-    identQuery: string;
-    identQueryType: string; // isbn, issn, etc.
     joinOp: string[];
     matchOp: string[];
     format: string;
-    searchOrg: IdlObject;
+    available = false;
     ccvmFilters: {[ccvmCode: string]: string[]};
     facetFilters: FacetFilter[];
+    copyLocations: string[]; // ID's, but treated as strings in the UI.
+
+    // True when searching for metarecords
+    groupByMetarecord: boolean;
+
+    // Filter results by records which link to this metarecord ID.
+    fromMetarecord: number;
+
+    hasBrowseEntry: string; // "entryId,fieldId"
+    browseEntry: IdlObject;
+    date1: number;
+    date2: number;
+    dateOp: string; // before, after, between, is
+
+    reset() {
+        this.query = [''];
+        this.fieldClass  = ['keyword'];
+        this.matchOp = ['contains'];
+        this.joinOp = [''];
+        this.facetFilters = [];
+        this.copyLocations = [''];
+        this.format = '';
+        this.hasBrowseEntry = '';
+        this.date1 = null;
+        this.date2 = null;
+        this.dateOp = 'is';
+        this.fromMetarecord = null;
+
+        // Apply empty string values for each ccvm filter
+        this.ccvmFilters = {};
+        CATALOG_CCVM_FILTERS.forEach(code => this.ccvmFilters[code] = ['']);
+    }
+
+    // True when grouping by metarecord but not when displaying the
+    // contents of a metarecord.
+    isMetarecordSearch(): boolean {
+        return (
+            this.isSearchable() && 
+            this.groupByMetarecord && 
+            this.fromMetarecord === null
+        );
+    }
+
+    isSearchable(): boolean {
+        return (
+            this.query[0] !== ''
+            || this.hasBrowseEntry !== ''
+            || this.fromMetarecord !== null
+        );
+    }
+
+    hasFacet(facet: FacetFilter): boolean {
+        return Boolean(
+            this.facetFilters.filter(f => f.equals(facet))[0]
+        );
+    }
+
+    removeFacet(facet: FacetFilter): void {
+        this.facetFilters = this.facetFilters.filter(f => !f.equals(facet));
+    }
+
+    addFacet(facet: FacetFilter): void {
+        if (!this.hasFacet(facet)) {
+            this.facetFilters.push(facet);
+        }
+    }
+
+    toggleFacet(facet: FacetFilter): void {
+        if (this.hasFacet(facet)) {
+            this.removeFacet(facet);
+        } else {
+            this.facetFilters.push(facet);
+        }
+    }
+}
+
+
+
+// Not an angular service.
+// It's conceviable there could be multiple contexts.
+export class CatalogSearchContext {
+
+    // Attributes that are used across different contexts.
+    sort: string;
     isStaff: boolean;
+    showBasket: boolean;
+    searchOrg: IdlObject;
+    global: boolean;
+
+    termSearch: CatalogTermContext;
+    marcSearch: CatalogMarcContext;
+    identSearch: CatalogIdentContext;
+    browseSearch: CatalogBrowseContext;
 
     // Result from most recent search.
-    result: any = {};
+    result: CatalogSearchResults;
     searchState: CatalogSearchState = CatalogSearchState.PENDING;
 
     // List of IDs in page/offset context.
-    resultIds: number[] = [];
+    resultIds: number[];
 
     // Utility stuff
     pager: Pager;
@@ -62,9 +228,40 @@ export class CatalogSearchContext {
 
     constructor() {
         this.pager = new Pager();
+        this.termSearch = new CatalogTermContext();
+        this.marcSearch = new CatalogMarcContext();
+        this.identSearch = new CatalogIdentContext();
+        this.browseSearch = new CatalogBrowseContext();
         this.reset();
     }
 
+    /**
+     * Return search context to its default state, resetting search
+     * parameters and clearing any cached result data.
+     */
+    reset(): void {
+        this.pager.offset = 0;
+        this.sort = '';
+        this.showBasket = false;
+        this.result = new CatalogSearchResults();
+        this.resultIds = [];
+        this.searchState = CatalogSearchState.PENDING;
+        this.termSearch.reset();
+        this.marcSearch.reset();
+        this.identSearch.reset();
+        this.browseSearch.reset();
+    }
+
+    isSearchable(): boolean {
+        return (
+            this.showBasket ||
+            this.termSearch.isSearchable() ||
+            this.marcSearch.isSearchable() ||
+            this.identSearch.isSearchable() ||
+            this.browseSearch.isSearchable()
+        );
+    }
+
     // List of result IDs for the current page of data.
     currentResultIds(): number[] {
         const ids = [];
@@ -97,119 +294,53 @@ export class CatalogSearchContext {
         return null;
     }
 
-    /**
-     * Return search context to its default state, resetting search
-     * parameters and clearing any cached result data.
-     * This does not reset global filters like limit-to-available
-     * search-global, or search-org.
-     */
-    reset(): void {
-        this.pager.offset = 0;
-        this.format = '';
-        this.sort = '';
-        this.query = [''];
-        this.identQuery = null;
-        this.identQueryType = 'identifier|isbn';
-        this.fieldClass  = ['keyword'];
-        this.matchOp = ['contains'];
-        this.joinOp = [''];
-        this.ccvmFilters = {};
-        this.facetFilters = [];
-        this.result = {};
-        this.resultIds = [];
-        this.searchState = CatalogSearchState.PENDING;
-    }
-
-    isSearchable(): boolean {
-
-        if (this.identQuery && this.identQueryType) {
-            return true;
-        }
-
-        return this.query.length
-            && this.query[0] !== ''
-            && this.searchOrg !== null;
-    }
-
-    compileSearch(): string {
-        let str = '';
+    compileMarcSearchArgs(): any {
+        const searches: any = [];
+        const ms = this.marcSearch;
+
+        ms.values.forEach((val, idx) => {
+            if (val !== '') {
+                searches.push({
+                    restrict: [{
+                        // "_" is the wildcard subfield for the API.
+                        subfield: ms.subfields[idx] ? ms.subfields[idx] : '_',
+                        tag: ms.tags[idx]
+                    }],
+                    term: ms.values[idx]
+                });
+            }
+        });
 
-        if (this.available) {
-            str += '#available';
-        }
+        const args: any = {
+            searches: searches,
+            limit : this.pager.limit,
+            offset : this.pager.offset,
+            org_unit: this.searchOrg.id()
+        };
 
         if (this.sort) {
-            // e.g. title, title.descending
             const parts = this.sort.split(/\./);
-            if (parts[1]) { str += ' #descending'; }
-            str += ' sort(' + parts[0] + ')';
-        }
-
-        if (this.identQuery && this.identQueryType) {
-            if (str) { str += ' '; }
-            str += this.identQueryType + ':' + this.identQuery;
-
-        } else {
-
-            // -------
-            // Compile boolean sub-query components
-            if (str.length) { str += ' '; }
-            const qcount = this.query.length;
-
-            // if we multiple boolean query components, wrap them in parens.
-            if (qcount > 1) { str += '('; }
-            this.query.forEach((q, idx) => {
-                str += this.compileBoolQuerySet(idx);
-            });
-            if (qcount > 1) { str += ')'; }
-            // -------
-        }
-
-        if (this.format) {
-            str += ' format(' + this.format + ')';
-        }
-
-        if (this.global) {
-            str += ' depth(' +
-                this.org.root().ou_type().depth() + ')';
+            args.sort = parts[0]; // title, author, etc.
+            if (parts[1]) { args.sort_dir = 'descending' };
         }
 
-        str += ' site(' + this.searchOrg.shortname() + ')';
-
-        Object.keys(this.ccvmFilters).forEach(field => {
-            if (this.ccvmFilters[field][0] !== '') {
-                str += ' ' + field + '(' + this.ccvmFilters[field] + ')';
-            }
-        });
-
-        this.facetFilters.forEach(f => {
-            str += ' ' + f.facetClass + '|'
-                + f.facetName + '[' + f.facetValue + ']';
-        });
-
-        return str;
+        return args;
     }
 
-    stripQuotes(query: string): string {
-        return query.replace(/"/g, '');
-    }
+    compileIdentSearchQuery(): string {
 
-    stripAnchors(query: string): string {
-        return query.replace(/[\^\$]/g, '');
+        let str = ' site(' + this.searchOrg.shortname() + ')';
+        return str + ' ' + 
+            this.identSearch.queryType + ':' + this.identSearch.value;
     }
 
-    addQuotes(query: string): string {
-        if (query.match(/ /)) {
-            return '"' + query + '"';
-        }
-        return query;
-    }
 
     compileBoolQuerySet(idx: number): string {
-        let query = this.query[idx];
-        const joinOp = this.joinOp[idx];
-        const matchOp = this.matchOp[idx];
-        const fieldClass = this.fieldClass[idx];
+        const ts = this.termSearch;
+        let query = ts.query[idx];
+        const joinOp = ts.joinOp[idx];
+        const matchOp = ts.matchOp[idx];
+        const fieldClass = ts.fieldClass[idx];
 
         let str = '';
         if (!query) { return str; }
@@ -238,29 +369,103 @@ export class CatalogSearchContext {
         return str + query + ')';
     }
 
-    hasFacet(facet: FacetFilter): boolean {
-        return Boolean(
-            this.facetFilters.filter(f => f.equals(facet))[0]
-        );
+    stripQuotes(query: string): string {
+        return query.replace(/"/g, '');
     }
 
-    removeFacet(facet: FacetFilter): void {
-        this.facetFilters = this.facetFilters.filter(f => !f.equals(facet));
+    stripAnchors(query: string): string {
+        return query.replace(/[\^\$]/g, '');
     }
 
-    addFacet(facet: FacetFilter): void {
-        if (!this.hasFacet(facet)) {
-            this.facetFilters.push(facet);
+    addQuotes(query: string): string {
+        if (query.match(/ /)) {
+            return '"' + query + '"';
         }
+        return query;
     }
 
-    toggleFacet(facet: FacetFilter): void {
-        if (this.hasFacet(facet)) {
-            this.removeFacet(facet);
-        } else {
-            this.facetFilters.push(facet);
+    compileTermSearchQuery(): string {
+        const ts = this.termSearch;
+        let str = '';
+
+        if (ts.available) {
+            str += '#available';
+        }
+
+        if (this.sort) {
+            // e.g. title, title.descending
+            const parts = this.sort.split(/\./);
+            if (parts[1]) { str += ' #descending'; }
+            str += ' sort(' + parts[0] + ')';
+        }
+
+        if (ts.date1 && ts.dateOp) {
+            switch (ts.dateOp) {
+                case 'is':
+                    str += ` date1(${ts.date1})`;
+                    break;
+                case 'before':
+                    str += ` before(${ts.date1})`;
+                    break;
+                case 'after':
+                    str += ` after(${ts.date1})`;
+                    break;
+                case 'between':
+                    if (ts.date2) {
+                        str += ` between(${ts.date1},${ts.date2})`;
+                    }
+            }
+        }
+
+        // -------
+        // Compile boolean sub-query components
+        if (str.length) { str += ' '; }
+        const qcount = ts.query.length;
+
+        // if we multiple boolean query components, wrap them in parens.
+        if (qcount > 1) { str += '('; }
+        ts.query.forEach((q, idx) => {
+            str += this.compileBoolQuerySet(idx);
+        });
+        if (qcount > 1) { str += ')'; }
+        // -------
+
+        if (ts.hasBrowseEntry) { 
+            // stored as a comma-separated string of "entryId,fieldId"
+            str += ` has_browse_entry(${ts.hasBrowseEntry})`;
+        }
+
+        if (ts.fromMetarecord) {
+            str += ` from_metarecord(${ts.fromMetarecord})`;
+        }
+
+        if (ts.format) {
+            str += ' format(' + ts.format + ')';
+        }
+
+        if (this.global) {
+            str += ' depth(' +
+                this.org.root().ou_type().depth() + ')';
+        }
+
+        if (ts.copyLocations[0] !== '') {
+            str += ' locations(' + ts.copyLocations + ')';
         }
+
+        str += ' site(' + this.searchOrg.shortname() + ')';
+
+        Object.keys(ts.ccvmFilters).forEach(field => {
+            if (ts.ccvmFilters[field][0] !== '') {
+                str += ' ' + field + '(' + ts.ccvmFilters[field] + ')';
+            }
+        });
+
+        ts.facetFilters.forEach(f => {
+            str += ' ' + f.facetClass + '|'
+                + f.facetName + '[' + f.facetValue + ']';
+        });
+
+        return str;
     }
 }
 
-
diff --git a/Open-ILS/src/eg2/src/app/share/date-select/date-select.component.html b/Open-ILS/src/eg2/src/app/share/date-select/date-select.component.html
index a1558b1ce9..575bbde5c8 100644
--- a/Open-ILS/src/eg2/src/app/share/date-select/date-select.component.html
+++ b/Open-ILS/src/eg2/src/app/share/date-select/date-select.component.html
@@ -8,11 +8,12 @@
     placeholder="yyyy-mm-dd"
     class="form-control"
     name="{{fieldName}}"
+    [disabled]="_disabled"
     [required]="required"
     [(ngModel)]="current"
     (dateSelect)="onDateSelect($event)">
   <div class="input-group-append">
-    <button class="btn btn-outline-secondary" 
+    <button class="btn btn-outline-secondary" [disabled]="_disabled"
       (click)="datePicker.toggle()" type="button">
       <span title="Select Date" i18n-title                       
         class="material-icons mat-icon-in-button">calendar_today</span>
diff --git a/Open-ILS/src/eg2/src/app/share/date-select/date-select.component.ts b/Open-ILS/src/eg2/src/app/share/date-select/date-select.component.ts
index 2f8837d967..625629026f 100644
--- a/Open-ILS/src/eg2/src/app/share/date-select/date-select.component.ts
+++ b/Open-ILS/src/eg2/src/app/share/date-select/date-select.component.ts
@@ -18,9 +18,13 @@ export class DateSelectComponent implements OnInit {
     @Input() initialDate: Date;  // Date object
     @Input() required: boolean;
     @Input() fieldName: string;
-
     @Input() domId = '';
 
+    _disabled: boolean;
+    @Input() set disabled(d: boolean) {
+        this._disabled = d;
+    }
+
     current: NgbDateStruct;
 
     @Output() onChangeAsDate: EventEmitter<Date>;
diff --git a/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.ts b/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.ts
index 25d05525bd..17c0e46dd1 100644
--- a/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.ts
+++ b/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.ts
@@ -28,7 +28,9 @@ interface CustomFieldContext {
 
 @Component({
   selector: 'eg-fm-record-editor',
-  templateUrl: './fm-editor.component.html'
+  templateUrl: './fm-editor.component.html',
+  /* align checkboxes when not using class="form-check" */
+  styles: ['input[type="checkbox"] {margin-left: 0px;}']
 })
 export class FmRecordEditorComponent
     extends DialogComponent implements OnInit {
@@ -181,9 +183,12 @@ export class FmRecordEditorComponent
             });
         }
 
-        // create a new record from scratch
+        // create a new record from scratch or from a stub record
+        // provided by the caller.
         this.pkeyIsEditable = !('pkey_sequence' in this.idlDef);
-        this.record = this.idl.create(this.idlClass);
+        if (!this.record) {
+            this.record = this.idl.create(this.idlClass);
+        }
         return this.getFieldList();
     }
 
diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid-body.component.html b/Open-ILS/src/eg2/src/app/share/grid/grid-body.component.html
index b7284fe6f0..8d495aa84d 100644
--- a/Open-ILS/src/eg2/src/app/share/grid/grid-body.component.html
+++ b/Open-ILS/src/eg2/src/app/share/grid/grid-body.component.html
@@ -6,9 +6,11 @@
     [ngClass]="{'selected': context.rowSelector.contains(context.getRowIndex(row))}"
     *ngFor="let row of context.dataSource.getPageOfRows(context.pager); let idx = index">
 
-    <div class="eg-grid-cell eg-grid-checkbox-cell eg-grid-cell-skinny">
-      <input type='checkbox' [(ngModel)]="context.rowSelector.indexes[context.getRowIndex(row)]">
-    </div>
+    <ng-container *ngIf="!context.disableSelect">
+      <div class="eg-grid-cell eg-grid-checkbox-cell eg-grid-cell-skinny">
+        <input type='checkbox' [(ngModel)]="context.rowSelector.indexes[context.getRowIndex(row)]">
+      </div>
+    </ng-container>
     <div class="eg-grid-cell eg-grid-number-cell eg-grid-cell-skinny">
       {{context.pager.rowNumber(idx)}}
     </div>
diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid-body.component.ts b/Open-ILS/src/eg2/src/app/share/grid/grid-body.component.ts
index e4829cee01..15aa2b7038 100644
--- a/Open-ILS/src/eg2/src/app/share/grid/grid-body.component.ts
+++ b/Open-ILS/src/eg2/src/app/share/grid/grid-body.component.ts
@@ -50,6 +50,13 @@ export class GridBodyComponent implements OnInit {
     }
 
     onRowClick($event: any, row: any, idx: number) {
+
+        if (this.context.disableSelect) {
+            // Avoid any appearance or click behavior when row
+            // selection is disabled.
+            return;
+        }
+
         const index = this.context.getRowIndex(row);
 
         if (this.context.disableMultiSelect) {
diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid-header.component.html b/Open-ILS/src/eg2/src/app/share/grid/grid-header.component.html
index 58e0c66774..0662f54b0b 100644
--- a/Open-ILS/src/eg2/src/app/share/grid/grid-header.component.html
+++ b/Open-ILS/src/eg2/src/app/share/grid/grid-header.component.html
@@ -1,8 +1,10 @@
 
 <div class="eg-grid-row eg-grid-header-row">
-  <div class="eg-grid-cell eg-grid-header-cell eg-grid-checkbox-cell eg-grid-cell-skinny">
-    <input type='checkbox' (click)="handleBatchSelect($event)">
-  </div>
+  <ng-container *ngIf="!context.disableSelect">
+    <div class="eg-grid-cell eg-grid-header-cell eg-grid-checkbox-cell eg-grid-cell-skinny">
+      <input type='checkbox' (click)="handleBatchSelect($event)">
+    </div>
+  </ng-container>
   <div class="eg-grid-cell eg-grid-header-cell eg-grid-number-cell eg-grid-cell-skinny">
     <span i18n="number|Row Number Header">#</span>
   </div>
diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid.component.ts b/Open-ILS/src/eg2/src/app/share/grid/grid.component.ts
index 3bcc2cbe78..d48028b938 100644
--- a/Open-ILS/src/eg2/src/app/share/grid/grid.component.ts
+++ b/Open-ILS/src/eg2/src/app/share/grid/grid.component.ts
@@ -44,6 +44,8 @@ export class GridComponent implements OnInit, AfterViewInit, OnDestroy {
     // The value is prefixed with 'eg.grid.'
     @Input() persistKey: string;
 
+    @Input() disableSelect: boolean;
+
     // Prevent selection of multiple rows
     @Input() disableMultiSelect: boolean;
 
@@ -109,6 +111,7 @@ export class GridComponent implements OnInit, AfterViewInit, OnDestroy {
         this.context.isSortable = this.sortable === true;
         this.context.isMultiSortable = this.multiSortable === true;
         this.context.useLocalSort = this.useLocalSort === true;
+        this.context.disableSelect = this.disableSelect === true;
         this.context.disableMultiSelect = this.disableMultiSelect === true;
         this.context.rowFlairIsEnabled = this.rowFlairIsEnabled  === true;
         this.context.rowFlairCallback = this.rowFlairCallback;
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 37bb188c72..dcffc95143 100644
--- a/Open-ILS/src/eg2/src/app/share/grid/grid.ts
+++ b/Open-ILS/src/eg2/src/app/share/grid/grid.ts
@@ -421,6 +421,7 @@ export class GridContext {
     useLocalSort: boolean;
     persistKey: string;
     disableMultiSelect: boolean;
+    disableSelect: boolean;
     dataSource: GridDataSource;
     columnSet: GridColumnSet;
     rowSelector: GridRowSelector;
diff --git a/Open-ILS/src/eg2/src/app/share/util/anon-cache.service.ts b/Open-ILS/src/eg2/src/app/share/util/anon-cache.service.ts
new file mode 100644
index 0000000000..29c168dc71
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/share/util/anon-cache.service.ts
@@ -0,0 +1,59 @@
+/**
+ * Service for communicating with the server-side "anonymous" cache.
+ */
+import {Injectable} from '@angular/core';
+import {Observable} from 'rxjs/Observable';
+import {StoreService} from '@eg/core/store.service';
+import {NetService} from '@eg/core/net.service';
+
+// All anon-cache data is stored in a single blob per user session.
+// Value is generated on the server with the first call to set_value
+// and stored locally as a LoginSession item (cookie).
+
+ at Injectable()
+export class AnonCacheService {
+
+    constructor(private store: StoreService, private net: NetService) {}
+
+    getItem(cacheKey: string, attr: string): Promise<any> {
+        return this.net.request(
+            'open-ils.actor',
+            'open-ils.actor.anon_cache.get_value', cacheKey, attr
+        ).toPromise();
+    }
+
+    // Apply 'value' to field 'attr' in the object cached at 'cacheKey'.
+    // If no cacheKey is provided, the server will generate one.
+    // Returns a promised resolved with the cache key.
+    setItem(cacheKey: string, attr: string, value: any): Promise<string> {
+        return this.net.request(
+            'open-ils.actor',
+            'open-ils.actor.anon_cache.set_value',
+            cacheKey, attr, value
+        ).toPromise().then(cacheKey => {
+            if (cacheKey) {
+                return cacheKey;
+            } else {
+                return Promise.reject(
+                    `Could not apply a value for attr=${attr} cacheKey=${cacheKey}`);
+            }
+        })
+    }
+
+    removeItem(cacheKey: string, attr: string): Promise<string> {
+        return this.net.request(
+            'open-ils.actor',
+            'open-ils.actor.anon_cache.set_value',
+            cacheKey, attr, null
+        ).toPromise();
+    }
+
+    clear(cacheKey: string): Promise<string> {
+        return this.net.request(
+            'open-ils.actor',
+            'open-ils.actor.anon_cache.delete_session', cacheKey
+        ).toPromise();
+    }
+}
+
+
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
new file mode 100644
index 0000000000..5837acee84
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/basket-actions.component.html
@@ -0,0 +1,28 @@
+<eg-record-bucket-dialog #addBasketToBucketDialog>
+</eg-record-bucket-dialog>
+
+<div class="row">
+  <div class="col-lg-4 pr-1">
+    <div class="float-right">
+      <!-- note basket view link does not propagate search params -->
+      <a routerLink="/staff/catalog/search" [queryParams]="{showBasket: true}" 
+        class="label-with-material-icon">
+        <span class="material-icons">shopping_basket</span>
+        <span i18n>({{basketCount()}})</span>
+      </a>
+    </div>
+  </div>
+  <div class="col-lg-8 pl-1">
+    <select class="form-control" 
+        [disabled]="!basketCount()"
+        [(ngModel)]="basketAction" (change)="applyAction()">
+      <option value='' [disabled]="true" i18n>Basket Actions...</option>
+      <option value="view"   i18n>View Basket</option>
+      <option value="hold"   i18n>Place Hold</option>
+      <option value="print"  i18n>Print Title Details</option>
+      <option value="email"  i18n>Email Title Details</option>
+      <option value="bucket" i18n>Add Basket to Bucket</option>
+      <option value="clear"  i18n>Clear Basket</option>
+    </select>
+  </div>
+</div>
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/basket-actions.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/basket-actions.component.ts
new file mode 100644
index 0000000000..08d02bcc63
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/basket-actions.component.ts
@@ -0,0 +1,106 @@
+import {Component, OnInit, ViewChild} from '@angular/core';
+import {BasketService} from '@eg/share/catalog/basket.service';
+import {Subscription} from 'rxjs/Subscription';
+import {Router} from '@angular/router';
+import {NetService} from '@eg/core/net.service';
+import {AuthService} from '@eg/core/auth.service';
+import {PrintService} from '@eg/share/print/print.service';
+import {RecordBucketDialogComponent} 
+    from '@eg/staff/share/buckets/record-bucket-dialog.component';
+
+ at Component({
+  selector: 'eg-catalog-basket-actions',
+  templateUrl: 'basket-actions.component.html'
+})
+export class BasketActionsComponent implements OnInit {
+
+    basketAction: string;
+
+    @ViewChild('addBasketToBucketDialog')
+        addToBucketDialog: RecordBucketDialogComponent;
+
+    constructor(
+        private router: Router,
+        private net: NetService,
+        private auth: AuthService,
+        private printer: PrintService,
+        private basket: BasketService
+    ) {
+        this.basketAction = '';
+    }
+
+    ngOnInit() {
+    }
+
+    basketCount(): number {
+        return this.basket.recordCount();
+    }
+
+    // TODO: confirmation dialogs?
+
+    applyAction() {
+        console.debug('Performing basket action', this.basketAction);
+
+        switch(this.basketAction) {
+            case 'view':
+                // This does not propagate search params -- unclear if needed.
+                this.router.navigate(['/staff/catalog/search'], 
+                    {queryParams: {showBasket: true}});
+                break;
+
+            case 'clear':
+                this.basket.removeAllRecordIds();
+                break;
+
+            case 'hold':
+                this.basket.getRecordIds().then(ids => {
+                    this.router.navigate(['/staff/catalog/hold/T'],
+                        {queryParams: {target: ids}});
+                });
+                break; 
+
+            case 'print':
+                this.basket.getRecordIds().then(ids => {
+                    this.net.request(
+                        'open-ils.search',
+                        'open-ils.search.biblio.record.print', ids
+                    ).subscribe(
+                        at_event => {
+                            // check for event..
+                            const html = at_event.template_output().data();
+                            this.printer.print({
+                                text: html,
+                                printContext: 'default'
+                            });
+                        }
+                    );
+                });
+                break;
+
+            case 'email':
+                this.basket.getRecordIds().then(ids => {
+                    this.net.request(
+                        'open-ils.search',
+                        'open-ils.search.biblio.record.email',
+                        this.auth.token(), ids
+                    ).toPromise(); // fire-and-forget
+                });
+                break;
+
+            case 'bucket':
+                this.basket.getRecordIds().then(ids => {
+                    this.addToBucketDialog.recordId = ids;
+                    this.addToBucketDialog.open({size: 'lg'});
+                });
+                break;
+
+        }
+
+        // Resetting basketAction inside its onchange handler
+        // prevents the new value from propagating to Angular
+        // Reset after the current thread.
+        setTimeout(() => this.basketAction = ''); // reset
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/browse.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/browse.component.html
new file mode 100644
index 0000000000..b50a415b4b
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/browse.component.html
@@ -0,0 +1,5 @@
+
+<eg-catalog-search-form #searchForm></eg-catalog-search-form>
+
+<eg-catalog-browse-results><eg-catalog-browse-results>
+
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/browse.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/browse.component.ts
new file mode 100644
index 0000000000..67e5eed1f1
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/browse.component.ts
@@ -0,0 +1,28 @@
+import {Component, OnInit, ViewChild} from '@angular/core';
+import {StaffCatalogService} from './catalog.service';
+import {BasketService} from '@eg/share/catalog/basket.service';
+import {SearchFormComponent} from './search-form.component';
+
+ at Component({
+  templateUrl: 'browse.component.html'
+})
+export class BrowseComponent implements OnInit {
+
+    @ViewChild('searchForm') searchForm: SearchFormComponent;
+
+    constructor(
+        private staffCat: StaffCatalogService,
+        private basket: BasketService
+    ) {}
+
+    ngOnInit() {
+        // A SearchContext provides all the data needed for browse.
+        this.staffCat.createContext();
+
+        // Cache the basket on page load.
+        this.basket.getRecordIds();
+
+        this.searchForm.searchTab = 'browse';
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/browse/results.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/browse/results.component.html
new file mode 100644
index 0000000000..fdbb05408c
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/browse/results.component.html
@@ -0,0 +1,84 @@
+
+<!-- search results progress bar -->
+<div class="row" *ngIf="browseIsActive()">
+  <div class="col-lg-6 offset-lg-3 pt-3">
+    <div class="progress">
+      <div class="progress-bar progress-bar-striped active w-100"
+        role="progressbar" aria-valuenow="100" 
+        aria-valuemin="0" aria-valuemax="100">
+        <span class="sr-only" i18n>Searching..</span>
+      </div>
+    </div>
+  </div>
+</div>
+
+<!-- no items found -->
+<div *ngIf="browseIsDone() && !browseHasResults()">
+  <div class="row pt-3">
+    <div class="col-lg-6 offset-lg-3">
+      <div class="alert alert-warning">
+        <span i18n>No Maching Items Were Found</span>
+      </div>
+    </div>
+  </div>
+</div>
+
+<!-- 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">
+      <button class="btn btn-primary" (click)="prevPage()">Back</button>
+      <button class="btn btn-primary ml-3" (click)="nextPage()">Next</button>
+    </div>
+  </div>
+
+  <div class="row" *ngFor="let result of results">
+    <div *ngIf="result.value" 
+      class="col-lg-12 card tight-card mb-2 bg-light">
+      <div class="col-lg-8">
+        <div class="card-body">
+          <ng-container *ngIf="result.sources > 0">
+            <a (click)="searchByBrowseEntry(result)" href="javascript:void(0)">
+                {{result.value}} ({{result.sources}})
+            </a>
+          </ng-container>
+          <ng-container *ngIf="result.sources == 0">
+            <span>{{result.value}}</span>
+          </ng-container>
+          <div class="row" *ngFor="let heading of result.compiledHeadings">
+            <div class="col-lg-10 offset-lg-1" i18n>
+              <span class="font-italic">
+                <ng-container *ngIf="!heading.type || heading.type == 'variant'">
+                    See
+                </ng-container>
+                <ng-container *ngIf="heading.type == 'broader'">
+                    Broader term
+                </ng-container>
+                <ng-container *ngIf="heading.type == 'narrower'">
+                    Narrower term
+                </ng-container>
+                <ng-container *ngIf="heading.type == 'other'">
+                    Related term
+                </ng-container>
+              </span>
+              <a (click)="newBrowseFromHeading(heading)" href="javascript:void(0)">
+                {{heading.heading}} ({{heading.target_count}})
+              </a>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+
+  <div class="row mb-2">
+    <div class="col-lg-3">
+      <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/browse/results.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/browse/results.component.ts
new file mode 100644
index 0000000000..8fcbce1e65
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/browse/results.component.ts
@@ -0,0 +1,140 @@
+import {Component, OnInit, Input} from '@angular/core';
+import {Observable} from 'rxjs/Observable';
+import {Subscription} from 'rxjs/Subscription';
+import {map, switchMap, distinctUntilChanged} from 'rxjs/operators';
+import {ActivatedRoute, ParamMap} from '@angular/router';
+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';
+import {CatalogSearchContext, CatalogSearchState} from '@eg/share/catalog/search-context';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {StaffCatalogService} from '../catalog.service';
+import {IdlObject} from '@eg/core/idl.service';
+
+ at Component({
+  selector: 'eg-catalog-browse-results',
+  templateUrl: 'results.component.html'
+})
+export class BrowseResultsComponent implements OnInit {
+
+    searchContext: CatalogSearchContext;
+    results: any[];
+
+    constructor(
+        private route: ActivatedRoute,
+        private pcrud: PcrudService,
+        private cat: CatalogService,
+        private bib: BibRecordService,
+        private catUrl: CatalogUrlService,
+        private staffCat: StaffCatalogService
+    ) {}
+
+    ngOnInit() {
+        this.searchContext = this.staffCat.searchContext;
+        this.route.queryParamMap.subscribe((params: ParamMap) => {
+            this.browseByUrl(params);
+        });
+    }
+
+    browseByUrl(params: ParamMap): void {
+        this.catUrl.applyUrlParams(this.searchContext, params);
+        const bs = this.searchContext.browseSearch;
+
+        // SearchContext applies a default fieldClass value of 'keyword'.
+        // Replace with 'title', since there is no 'keyword' browse.
+        if (bs.fieldClass === 'keyword') {
+            bs.fieldClass = 'title';
+        }
+
+        if (bs.isSearchable()) {
+            this.results = [];
+            this.cat.browse(this.searchContext)
+                .subscribe(result => this.addResult(result))
+        }
+    }
+
+    addResult(result: any) {
+
+        result.compiledHeadings = [];
+
+        // Avoi dupe headings per see
+        const seen: any = {};
+
+        result.sees.forEach(sees => {
+            if (!sees.control_set) { return; }
+
+            sees.headings.forEach(headingStruct => {
+                const fieldId = Object.keys(headingStruct)[0];
+                const heading = headingStruct[fieldId][0];
+
+                const inList = result.list_authorities.filter(
+                    id => Number(id) === Number(heading.target))[0]
+
+                if (   heading.target 
+                    && heading.main_entry
+                    && heading.target_count 
+                    && !inList
+                    && !seen[heading.target]) {
+
+                    seen[heading.target] = true;
+
+                    result.compiledHeadings.push({
+                        heading: heading.heading,
+                        target: heading.target,
+                        target_count: heading.target_count,
+                        type: heading.type
+                    });
+                }
+            });
+        });
+
+        this.results.push(result);
+    }
+
+    browseIsDone(): boolean {
+        return this.searchContext.searchState === CatalogSearchState.COMPLETE;
+    }
+
+    browseIsActive(): boolean {
+        return this.searchContext.searchState === CatalogSearchState.SEARCHING;
+    }
+
+    browseHasResults(): boolean {
+        return this.browseIsDone() && this.results.length > 0;
+    }
+
+    prevPage() {
+        const firstResult = this.results[0];
+        if (firstResult) {
+            this.searchContext.browseSearch.pivot = firstResult.pivot_point;
+            this.staffCat.browse();
+        }
+    }
+
+    nextPage() {
+        const lastResult = this.results[this.results.length - 1];
+        if (lastResult) {
+            this.searchContext.browseSearch.pivot = lastResult.pivot_point;
+            this.staffCat.browse();
+        }
+    }
+
+    searchByBrowseEntry(result) { 
+
+        // Avoid propagating browse values to term search.
+        this.searchContext.browseSearch.reset();
+
+        this.searchContext.termSearch.hasBrowseEntry = 
+            result.browse_entry + ',' + result.fields;
+        this.staffCat.search();
+    }
+
+    // NOTE: to test unauthorized heading display in concerto
+    // browse for author = kab
+    newBrowseFromHeading(heading) {
+        this.searchContext.browseSearch.value = heading.heading;
+        this.staffCat.browse();
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/catalog.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/catalog.component.ts
index 8b2206c2f5..0e2fc98884 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/catalog.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/catalog.component.ts
@@ -1,18 +1,25 @@
 import {Component, OnInit} from '@angular/core';
 import {StaffCatalogService} from './catalog.service';
+import {BasketService} from '@eg/share/catalog/basket.service';
 
 @Component({
   templateUrl: 'catalog.component.html'
 })
 export class CatalogComponent implements OnInit {
 
-    constructor(private staffCat: StaffCatalogService) {}
+    constructor(
+        private basket: BasketService,
+        private staffCat: StaffCatalogService
+    ) {}
 
     ngOnInit() {
         // Create the search context that will be used by all of my
         // child components.  After initial creation, the context is
         // reset and updated as needed to apply new search parameters.
         this.staffCat.createContext();
+
+        // Cache the basket on page load.
+        this.basket.getRecordIds();
     }
 }
 
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 20e17a091c..2d30199441 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
@@ -14,6 +14,13 @@ import {StaffCatalogService} from './catalog.service';
 import {RecordPaginationComponent} from './record/pagination.component';
 import {RecordActionsComponent} from './record/actions.component';
 import {HoldingsService} from '@eg/staff/share/holdings.service';
+import {BasketActionsComponent} from './basket-actions.component';
+import {HoldComponent} from './hold/hold.component';
+import {HoldService} from '@eg/staff/share/hold.service';
+import {PartsComponent} from './record/parts.component';
+import {PartMergeDialogComponent} from './record/part-merge-dialog.component';
+import {BrowseComponent} from './browse.component';
+import {BrowseResultsComponent} from './browse/results.component';
 
 @NgModule({
   declarations: [
@@ -26,7 +33,13 @@ import {HoldingsService} from '@eg/staff/share/holdings.service';
     ResultFacetsComponent,
     ResultPaginationComponent,
     RecordPaginationComponent,
-    RecordActionsComponent
+    RecordActionsComponent,
+    BasketActionsComponent,
+    HoldComponent,
+    PartsComponent,
+    PartMergeDialogComponent,
+    BrowseComponent,
+    BrowseResultsComponent
   ],
   imports: [
     StaffCommonModule,
@@ -35,7 +48,8 @@ import {HoldingsService} from '@eg/staff/share/holdings.service';
   ],
   providers: [
     StaffCatalogService,
-    HoldingsService
+    HoldingsService,
+    HoldService
   ]
 })
 
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 1e50d9ba88..cf0a36c97f 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
@@ -82,6 +82,28 @@ export class StaffCatalogService {
           ['/staff/catalog/search'], {queryParams: params});
     }
 
+    /**
+     * Redirect to the browse results page while propagating the current
+     * browse paramters into the URL.  Let the browse results component
+     * execute the actual browse.
+     */
+    browse(): void {
+        if (!this.searchContext.browseSearch.isSearchable()) { return; }
+
+        const params = this.catUrl.toUrlParams(this.searchContext);
+
+        // Force a new browse every time this method is called, even if
+        // it's the same as the active browse.  Since router navigation
+        // exits early when the route + params is identical, add a
+        // random token to the route params to force a full navigation.
+        // This also resolves a problem where only removing secondary+
+        // versions of a query param fail to cause a route navigation.
+        // (E.g. going from two query= params to one).
+        params.ridx = '' + this.routeIndex++;
+
+        this.router.navigate(
+          ['/staff/catalog/browse'], {queryParams: params});
+    }
 }
 
 
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
new file mode 100644
index 0000000000..1ef096c495
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.html
@@ -0,0 +1,293 @@
+
+<h3 i18n>Place Hold 
+  <small *ngIf="user"> 
+   ({{user.family_name()}}, {{user.first_given_name()}})
+  </small>
+</h3>
+
+<form class="form form-validated common-form" 
+  autocomplete="off" (keydown.enter)="$event.preventDefault()">
+  <div class="row">
+    <div class="col-lg-6 common-form striped-odd">
+      <div class="row mt-2">
+        <div class="col-lg-6">
+          <div class="form-check">
+            <input class="form-check-input" type="radio" 
+              (change)="holdForChanged()"
+              name="holdFor" value="patron" [(ngModel)]="holdFor"/>
+            <label class="form-check-label" i18n>
+              Place hold for patron by barcode:
+            </label>
+          </div>
+        </div>
+        <div class="col-lg-6">
+          <div class="input-group">
+            <input type='text' class="form-control" name="userBarcode"
+              [disabled]="holdFor!='patron'" id='patron-barcode' 
+              (keyup.enter)="userBarcodeChanged()"
+              [(ngModel)]="userBarcode" (change)="userBarcodeChanged()"/>
+            <div class="input-group-append">
+              <button class="btn btn-outline-dark" 
+                [disabled]="true" i18n>Search</button>
+            </div>
+          </div>
+        </div>
+      </div>
+      <div class="row mt-2">
+        <div class="col-lg-6">
+          <div class="form-check">
+            <input class="form-check-input" type="radio" 
+              (change)="holdForChanged()"
+              name="holdFor" value="staff" [(ngModel)]="holdFor"/>
+            <label class="form-check-label" i18n>
+              Place hold for this staff account:
+            </label>
+          </div>
+        </div>
+        <div class="col-lg-6 font-weight-bold">{{requestor.usrname()}}</div>
+      </div>
+      <div class="row mt-2">
+        <div class="col-lg-6">
+          <label i18n>Pickup Location: </label>
+        </div>
+        <div class="col-lg-6">
+          <eg-org-select [applyOrgId]="pickupLib"></eg-org-select>
+        </div>
+      </div>
+      <div class="row mt-2">
+        <div class="col-lg-6">
+          <div class="form-check">
+            <input class="form-check-input" type="checkbox" 
+              name="suspend" [(ngModel)]="suspend"/>
+            <label class="form-check-label" i18n>Suspend Hold</label>
+          </div>
+        </div>
+        <div class="col-lg-6">
+          <eg-date-select (onChangeAsISO)="activeDateSelected($event)"
+            [disabled]="!suspend">
+          </eg-date-select>
+        </div>
+      </div>
+    </div><!-- left column -->
+    <div class="col-lg-6">
+      <div class="card">
+        <div class="card-header">
+          <h4 i18n>Notifications</h4>
+        </div>
+        <ul class="list-group list-group-flush">
+          <li class="list-group-item d-flex">
+            <div class="flex-1">
+              <div class="form-check">
+                <input class="form-check-input" type="checkbox" name="notifyEmail"
+                  [disabled]="!user || !user.email()" [(ngModel)]="notifyEmail"/>
+                <label class="form-check-label" i18n>Notify by Email</label>
+              </div>
+            </div>
+            <div class="flex-1">
+              <div class="input-group">
+                <div class="input-group-prepend">
+                  <span class="input-group-text" i18n>Email Address</span>
+                </div>
+                <input type="text" class="form-control" name="userEmail"
+                  [disabled]="true" value="{{user ? user.email() : ''}}"/>
+              </div>
+            </div>
+          </li>
+          <li class="list-group-item d-flex">
+            <div class="flex-1">
+              <div class="form-check">
+                <input class="form-check-input" type="checkbox" 
+                  name="notifyPhone" [(ngModel)]="notifyPhone"/>
+                <label class="form-check-label" i18n>Notify by Phone</label>
+              </div>
+            </div>
+            <div class="flex-1">
+              <div class="input-group">
+                <div class="input-group-prepend">
+                  <span class="input-group-text" i18n>Phone Number</span>
+                </div>
+                <input type="text" class="form-control" [disabled]="!notifyPhone"
+                  name="phoneValue" [(ngModel)]="phoneValue"/>
+              </div>
+            </div>
+          </li>
+          <li *ngIf="smsEnabled" class="list-group-item d-flex">
+            <div class="flex-1">
+              <div class="form-check">
+                <input class="form-check-input" type="checkbox" 
+                  name="notifySms" [(ngModel)]="notifySms"/>
+                <label class="form-check-label" i18n>Notify by SMS</label>
+              </div>
+            </div>
+            <div class="flex-1">
+              <div class="input-group">
+                <div class="input-group-prepend">
+                  <span class="input-group-text" i18n>SMS Number</span>
+                </div>
+                <input type="text" class="form-control" [disabled]="!notifySms"
+                  name="smsValue" [(ngModel)]="smsValue"/>
+              </div>
+            </div>
+          </li>
+          <li *ngIf="smsEnabled" class="list-group-item d-flex">
+            <div class="flex-1">
+              <label i18n>SMS Carrier</label>
+            </div>
+            <div class="flex-1">
+              <eg-combobox
+                placeholder="SMS Carriers" i18n-placeholder
+                [entries]="smsCarriers">
+              </eg-combobox>
+            </div>
+          </li>
+        </ul><!-- col -->
+      </div><!-- row -->
+    </div><!--card -->
+  </div><!-- col -->
+  <div class="row mt-2">
+    <div class="col-lg-3">
+      <button class="btn btn-success" (click)="placeHolds()" 
+        [disabled]="!user || placeHoldsClicked" i18n>Place Hold(s)</button>
+    </div>
+  </div>
+</form>
+
+<div class="row"><div class="col-lg-12"><hr/></div></div>
+
+<div class="row font-weight-bold pt-3 ml-1 mr-1">
+  <div class="col-lg-12" i18n>Placing 
+    <ng-container *ngIf="holdType == 'M'">METARECORD</ng-container> 
+    <ng-container *ngIf="holdType == 'T'">TITLE</ng-container> 
+    <ng-container *ngIf="holdType == 'V'">VOLUME</ng-container> 
+    <ng-container *ngIf="holdType == 'F'">FORCE COPY</ng-container> 
+    <ng-container *ngIf="holdType == 'C'">COPY</ng-container> 
+    <ng-container *ngIf="holdType == 'R'">RECALL</ng-container> 
+    <ng-container *ngIf="holdType == 'I'">ISSUANCE</ng-container> 
+    <ng-container *ngIf="holdType == 'P'">PARTS</ng-container> 
+    hold on record(s)</div>
+</div>
+
+<ng-template #anyValue>
+  <span class="font-italic" i18n>ANY</span>
+</ng-template>
+
+<!--
+    TODO: add a section per hold context for metarecord holds
+    listing the possible formats and languages.
+
+    TODO: add a secion per hold context for T holds providing a 
+    link to the metarecord hold equivalent (AKA "Advanced Hold 
+    Options") for each record that has selectable filters (and
+    only when metarecord holds are enabled).
+-->
+
+<div class="hold-records-list common-form striped-even">
+
+  <div class="row mt-2 ml-1 mr-1 font-weight-bold">
+    <div class="col-lg-1" i18n>Format</div>
+    <div class="col-lg-3" i18n>Title</div>
+    <div class="col-lg-2" i18n>Author</div>
+    <div class="col-lg-2" i18n>Call Number</div>
+    <div class="col-lg-1" i18n>Barcode</div>
+    <div class="col-lg-2" i18n>Holds Status</div>
+    <div class="col-lg-1" i18n>Override</div>
+  </div>
+  <div class="row mt-1 ml-1 mr-1" *ngFor="let ctx of holdContexts">
+    <div class="col-lg-12" *ngIf="ctx.holdMeta">
+      <div class="row">
+        <div class="col-lg-1">
+          <ng-container 
+            *ngFor="let code of ctx.holdMeta.bibSummary.attributes.icon_format">
+            <img class="pr-1" 
+              alt="{{iconFormatLabel(code)}}"
+              title="{{iconFormatLabel(code)}}"
+              src="/images/format_icons/icon_format/{{code}}.png"/>
+          </ng-container>
+        </div>
+        <!-- TODO: link for a metarecord should 
+            jump to constituent bib list search page? -->
+        <div class="col-lg-3">
+          <a routerLink="/staff/catalog/record/{{ctx.holdMeta.bibId}}">
+            {{ctx.holdMeta.bibSummary.display.title}}
+          </a>
+        </div>
+        <div class="col-lg-2">{{ctx.holdMeta.bibSummary.display.author}}</div>
+        <div class="col-lg-2">
+          <ng-container *ngIf="ctx.holdMeta.volume; else anyValue">
+            {{ctx.holdMeta.volume.label()}}
+          </ng-container>
+        </div>
+        <div class="col-lg-1">
+          <ng-container *ngIf="ctx.holdMeta.copy; else anyValue">
+            {{ctx.holdMeta.copy.barcode()}}
+          </ng-container>
+        </div>
+        <div class="col-lg-2">
+          <ng-container *ngIf="!ctx.lastRequest && !ctx.processing">
+            <div class="alert alert-info" i18n>Hold Pending</div>
+          </ng-container>
+          <ng-container *ngIf="ctx.processing">
+            <div class="alert alert-primary" i18n>Hold Processing...</div>
+          </ng-container>
+          <ng-container *ngIf="ctx.lastRequest">
+            <ng-container *ngIf="ctx.lastRequest.result.success">
+              <div class="alert alert-success" i18n>Hold Succeeded</div>
+            </ng-container>
+            <ng-container *ngIf="!ctx.lastRequest.result.success">
+              <div class="alert alert-danger">
+                {{ctx.lastRequest.result.evt.textcode}}
+              </div>
+            </ng-container>
+          </ng-container>
+        </div>
+        <div class="col-lg-1">
+          <ng-container *ngIf="canOverride(ctx)">
+            <button class="btn btn-info" (click)="override(ctx)">Override</button>
+          </ng-container>
+        </div>
+      </div>
+      <!-- note: using inline style since class-level styling for rows
+          is superseded by the striped-even styling of the container -->
+      <div class="row" *ngIf="hasMetaFilters(ctx)" 
+        style="background-color:inherit; border:none">
+        <div class="col-lg-1"><label i18n>Formats: </label></div>
+        <div class="col-lg-11 d-flex">
+          <ng-container 
+            *ngFor="let ccvm of ctx.holdMeta.metarecord_filters.formats">
+            <div class="form-check ml-3">
+              <input class="form-check-input" type="checkbox" 
+                [disabled]="ctx.holdMeta.metarecord_filters.formats.length == 1"
+                [(ngModel)]="ctx.selectedFormats.formats[ccvm.code()]"/>
+              <img class="ml-1" 
+                alt="{{iconFormatLabel(ccvm.code())}}"
+                title="{{iconFormatLabel(ccvm.code())}}"
+                src="/images/format_icons/icon_format/{{ccvm.code()}}.png"/>
+              <label class="form-check-label ml-1">
+                {{ccvm.search_label() || ccvm.value()}}
+              </label>
+            </div>
+          </ng-container>
+        </div>
+      </div>
+      <div class="row" *ngIf="hasMetaFilters(ctx)" 
+        style="background-color:inherit; border:none">
+        <div class="col-lg-1"><label i18n>Languages: </label></div>
+        <div class="col-lg-11 d-flex">
+          <ng-container 
+            *ngFor="let ccvm of ctx.holdMeta.metarecord_filters.langs">
+            <div class="form-check ml-3">
+              <input class="form-check-input" type="checkbox" 
+                [disabled]="ctx.holdMeta.metarecord_filters.langs.length == 1"
+                [(ngModel)]="ctx.selectedFormats.langs[ccvm.code()]"/>
+              <label class="form-check-label ml-1">
+                {{ccvm.search_label() || ccvm.value()}}
+              </label>
+            </div>
+          </ng-container>
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
+
+
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
new file mode 100644
index 0000000000..a0a0dc24f4
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.ts
@@ -0,0 +1,401 @@
+import {Component, OnInit, Input, ViewChild, Renderer2} from '@angular/core';
+import {Observable} from 'rxjs/Observable';
+import {Router, ActivatedRoute, ParamMap} from '@angular/router';
+import {tap} from 'rxjs/operators/tap';
+import {EventService} from '@eg/core/event.service';
+import {NetService} from '@eg/core/net.service';
+import {AuthService} from '@eg/core/auth.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {PermService} from '@eg/core/perm.service';
+import {IdlObject} from '@eg/core/idl.service';
+import {OrgService} from '@eg/core/org.service';
+import {BibRecordService, BibRecordSummary} from '@eg/share/catalog/bib-record.service';
+import {CatalogSearchContext, CatalogSearchState} from '@eg/share/catalog/search-context';
+import {CatalogService} from '@eg/share/catalog/catalog.service';
+import {StaffCatalogService} from '../catalog.service';
+import {HoldService, HoldRequest, HoldRequestTarget} 
+    from '@eg/staff/share/hold.service';
+import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
+
+class HoldContext {
+    holdMeta: HoldRequestTarget;
+    holdTarget: number;
+    lastRequest: HoldRequest;
+    canOverride?: boolean;
+    processing: boolean;
+    selectedFormats: any;
+
+    constructor(target: number) {
+        this.holdTarget = target;
+        this.processing = false;
+        this.selectedFormats = {
+           // code => selected-boolean
+           formats: {},
+           langs: {}
+        }
+    }
+}
+
+ at Component({
+  templateUrl: 'hold.component.html'
+})
+export class HoldComponent implements OnInit {
+    
+    holdType: string;
+    holdTargets: number[];
+    user: IdlObject; //
+    userBarcode: string;
+    requestor: IdlObject;
+    holdFor: string;
+    pickupLib: number;
+    notifyEmail: boolean;
+    notifyPhone: boolean;
+    phoneValue: string;
+    notifySms: boolean;
+    smsValue: string;
+    smsCarrier: string;
+    suspend: boolean;
+    activeDate: string;
+
+    holdContexts: HoldContext[];
+    recordSummaries: BibRecordSummary[];
+
+    currentUserBarcode: string;
+    smsCarriers: ComboboxEntry[];
+
+    smsEnabled: boolean;
+    placeHoldsClicked: boolean;
+
+    constructor(
+        private router: Router,
+        private route: ActivatedRoute,
+        private renderer: Renderer2,
+        private evt: EventService,
+        private net: NetService,
+        private org: OrgService,
+        private auth: AuthService,
+        private pcrud: PcrudService,
+        private bib: BibRecordService,
+        private cat: CatalogService,
+        private staffCat: StaffCatalogService,
+        private holds: HoldService,
+        private perm: PermService
+    ) {
+        this.holdContexts = [];
+        this.smsCarriers = [];
+    }
+
+    ngOnInit() {
+
+        this.holdType = this.route.snapshot.params['type'];
+        this.holdTargets = this.route.snapshot.queryParams['target'];
+
+        if (!Array.isArray(this.holdTargets)) {
+            this.holdTargets = [this.holdTargets];
+        }
+
+        this.holdTargets = this.holdTargets.map(t => Number(t));
+        this.holdFor = 'patron';
+        this.requestor = this.auth.user();
+        this.pickupLib = this.auth.user().ws_ou();
+
+        this.holdContexts = this.holdTargets.map(target => {
+            const ctx = new HoldContext(target);
+            return ctx;
+        });
+
+        this.getTargetMeta();
+
+        this.org.settings('sms.enable').then(sets => {
+            this.smsEnabled = sets['sms.enable']
+            if (!this.smsEnabled) { return; }
+
+            this.pcrud.search('csc', {active: 't'}, {order_by: {csc: 'name'}})
+            .subscribe(carrier => {
+                this.smsCarriers.push({
+                    id: carrier.id(), 
+                    label: carrier.name()
+                })
+            });
+        });
+
+        setTimeout(() => // Focus barcode input
+            this.renderer.selectRootElement('#patron-barcode').focus());
+    }
+
+    // Load the bib, call number, copy, etc. data associated with each target.
+    getTargetMeta() {
+        this.holds.getHoldTargetMeta(this.holdType, this.holdTargets)
+        .subscribe(meta => {
+            this.holdContexts.filter(ctx => ctx.holdTarget === meta.target)
+            .forEach(ctx => {
+                ctx.holdMeta = meta;
+                this.mrFiltersToSelectors(ctx);
+            });
+        });
+    }
+
+    // By default, all metarecord filters options are enabled.
+    mrFiltersToSelectors(ctx: HoldContext) {
+        if (this.holdType !== 'M') { return; }
+
+        const meta = ctx.holdMeta;
+        if (meta.metarecord_filters) {
+            if (meta.metarecord_filters.formats) {
+                meta.metarecord_filters.formats.forEach(
+                    ccvm => ctx.selectedFormats.formats[ccvm.code()] = true);
+            }
+            if (meta.metarecord_filters.langs) {
+                meta.metarecord_filters.langs.forEach(
+                    ccvm => ctx.selectedFormats.langs[ccvm.code()] = true);
+            }
+        }
+    }
+
+    // Map the selected metarecord filters optoins to a JSON-encoded
+    // list of attr filters as required by the API.
+    // Compiles a blob of 
+    // {target: JSON({"0": [{_attr: ctype, _val: code}, ...], "1": [...]})}
+    // TODO: this should live in the hold service, not in the UI code.
+    mrSelectorsToFilters(ctx: HoldContext): {[target: number]: string} {
+
+        const meta = ctx.holdMeta;
+        const slf = ctx.selectedFormats;
+        const result: any = {};
+
+        const formats = Object.keys(slf.formats)
+            .filter(code => Boolean(slf.formats[code])); // user-selected
+
+        const langs = Object.keys(slf.langs)
+            .filter(code => Boolean(slf.langs[code])); // user-selected
+
+        const compiled: any = {};
+
+        if (formats.length > 0) {
+            compiled['0'] = [];
+            formats.forEach(code => {
+                const ccvm = meta.metarecord_filters.formats.filter(
+                    format => format.code() === code)[0];
+                compiled['0'].push({
+                    _attr: ccvm.ctype(),
+                    _val: ccvm.code()
+                });
+            });
+        }
+
+        if (langs.length > 0) {
+            compiled['1'] = [];
+            langs.forEach(code => {
+                const ccvm = meta.metarecord_filters.langs.filter(
+                    format => format.code() === code)[0];
+                compiled['1'].push({
+                    _attr: ccvm.ctype(),
+                    _val: ccvm.code()
+                });
+            });
+        }
+
+        if (Object.keys(compiled).length > 0) {
+            const result = {};
+            result[ctx.holdTarget] = JSON.stringify(compiled);
+            return result;
+        }
+
+        return null;
+    }
+
+    holdForChanged() {
+        this.user = null;
+
+        if (this.holdFor === 'patron') {
+            if (this.userBarcode) {
+                this.userBarcodeChanged();
+            }
+        } else {
+            // To bypass the dupe check.
+            this.currentUserBarcode = '_' + this.requestor.id();
+            this.getUser(this.requestor.id());
+        }
+    }
+
+    activeDateSelected(dateStr: string) {
+        this.activeDate = dateStr;
+    }
+
+    userBarcodeChanged() {
+
+        // Avoid simultaneous or duplicate lookups
+        if (this.userBarcode === this.currentUserBarcode) { 
+            return; 
+        }
+
+        this.resetForm();
+
+        if (!this.userBarcode) { 
+            this.user = null;
+            return; 
+        }
+
+        this.user = null;
+        this.currentUserBarcode = this.userBarcode;
+
+        this.net.request(
+            'open-ils.actor',
+            'open-ils.actor.get_barcodes',
+            this.auth.token(), this.auth.user().ws_ou(), 
+            'actor', this.userBarcode
+        ).subscribe(barcodes => {
+
+            // Use the first successful barcode response.
+            // TODO: What happens when there are multiple responses?
+            // Use for-loop for early exit since we have async
+            // action within the loop.
+            for (let i = 0; i < barcodes.length; i++) {
+                const bc = barcodes[i];
+                if (!this.evt.parse(bc)) {
+                    this.getUser(bc.id);
+                    break;
+                }
+            }
+        });
+    }
+
+    resetForm() {
+        this.notifyEmail = true;
+        this.notifyPhone = true;
+        this.phoneValue = '';
+        this.pickupLib = this.requestor.ws_ou();
+    }
+
+    getUser(id: number) {
+        this.pcrud.retrieve('au', id, {flesh: 1, flesh_fields: {au: ['settings']}})
+        .subscribe(user => {
+            this.user = user;
+            this.applyUserSettings();
+        });
+    }
+
+    applyUserSettings() {
+        if (!this.user || !this.user.settings()) { return; }
+
+        // Start with defaults.
+        this.phoneValue = this.user.day_phone() || this.user.evening_phone();
+
+        // Default to work org if placing holds for staff.
+        if (this.user.id() !== this.requestor.id()) {
+            this.pickupLib = this.user.home_ou();
+        }
+
+        this.user.settings().forEach(setting => {
+            const name = setting.name();
+            const value = setting.value();
+
+            if (value === '' || value === null) { return; }
+
+            switch(name) {
+                case 'opac.hold_notify':
+                    this.notifyPhone = Boolean(value.match(/phone/));
+                    this.notifyEmail = Boolean(value.match(/email/));
+                    this.notifySms = Boolean(value.match(/sms/));
+                    break;
+
+                case 'opac.default_pickup_location':
+                    this.pickupLib = value; 
+                    break;
+            }
+        });
+
+        if (!this.user.email()) {
+            this.notifyEmail = false;
+        }
+
+        if (!this.phoneValue) {
+            this.notifyPhone = false;
+        }
+    }
+
+    // Attempt hold placement on all targets
+    placeHolds(idx?: number) {
+        if (!idx) { idx = 0; }
+        if (!this.holdTargets[idx]) { return; }
+        this.placeHoldsClicked = true;
+
+        const target = this.holdTargets[idx];
+        const ctx = this.holdContexts.filter(
+            ctx => ctx.holdTarget === target)[0];
+
+        this.placeOneHold(ctx).then(() => this.placeHolds(idx + 1));
+    }
+
+    placeOneHold(ctx: HoldContext, override?: boolean): Promise<any> {
+
+        ctx.processing = true;
+        const selectedFormats = this.mrSelectorsToFilters(ctx);
+
+        return this.holds.placeHold({
+            holdTarget: ctx.holdTarget,
+            holdType: this.holdType,
+            recipient: this.user.id(),
+            requestor: this.requestor.id(),
+            pickupLib: this.pickupLib,
+            override: override,
+            notifyEmail: this.notifyEmail, // bool
+            notifyPhone: this.notifyPhone ? this.phoneValue : null,
+            notifySms: this.notifySms ? this.smsValue : null,
+            smsCarrier: this.notifySms ? this.smsCarrier : null,
+            thawDate: this.suspend ? this.activeDate : null,
+            frozen: this.suspend,
+            holdableFormats: selectedFormats
+
+        }).toPromise().then(
+            request => {
+                console.log('hold returned: ', request);
+                ctx.lastRequest = request;
+                ctx.processing = false;
+    
+                // If this request failed and was not already an override,
+                // see of this user has permission to override.
+                if (!request.override && 
+                    !request.result.success && request.result.evt) {
+    
+                    const txtcode = request.result.evt.textcode;
+                    const perm = txtcode + '.override';
+    
+                    return this.perm.hasWorkPermHere(perm).then(
+                        permResult => ctx.canOverride = permResult[perm]);
+                }
+            },
+            error => {
+                ctx.processing = false;
+                console.error(error);
+            }
+        );
+    }
+
+    override(ctx: HoldContext) {
+        this.placeOneHold(ctx, true);
+    }
+
+    canOverride(ctx: HoldContext): boolean {
+        return ctx.lastRequest && 
+                !ctx.lastRequest.result.success && ctx.canOverride;
+    }
+
+    iconFormatLabel(code: string): string {
+        return this.cat.iconFormatLabel(code);
+    }
+
+    // TODO: for now, only show meta filters for meta holds.
+    // Add an "advanced holds" option to display these for T hold.
+    hasMetaFilters(ctx: HoldContext): boolean {
+        return (
+            this.holdType === 'M' && // TODO
+            ctx.holdMeta.metarecord_filters && (
+                ctx.holdMeta.metarecord_filters.langs.length > 1 ||
+                ctx.holdMeta.metarecord_filters.formats.length > 1
+            )
+        );
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/actions.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/record/actions.component.html
index 6fd945414c..1a76b282f7 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/record/actions.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/actions.component.html
@@ -15,8 +15,12 @@
 
 <div class="row ml-0 mr-0">
 
+  <a target="_blank" href="/eg/opac/record/{{recId}}">
+    <button class="btn btn-info ml-1" i18n>View in Catalog</button>
+  </a>
+
   <button class="btn btn-info ml-1" (click)="addVolumes()" i18n>
-    Add Volumes
+    Add Holdings
   </button>
 
   <div ngbDropdown placement="bottom-right" class="ml-1">
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/copies.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/record/copies.component.html
index e7d82491b5..e60fb24847 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/record/copies.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/copies.component.html
@@ -20,6 +20,7 @@
 
 <div class='eg-copies w-100 mt-3'>
   <eg-grid #copyGrid [dataSource]="gridDataSource" 
+    [disableSelect]="true"
     [sortable]="false" persistKey="catalog.record.copies">
     <eg-grid-column i18n-label label="Copy ID" path="id" 
       [hidden]="true" [index]="true">
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/part-merge-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/record/part-merge-dialog.component.html
new file mode 100644
index 0000000000..ef702ebfea
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/part-merge-dialog.component.html
@@ -0,0 +1,28 @@
+<ng-template #dialogContent>
+  <div class="modal-header bg-info">
+    <h4 class="modal-title" i18n>Merge Monograph Parts</h4>
+    <button type="button" class="close" 
+      i18n-aria-label aria-label="Close" 
+      (click)="dismiss('cross_click')">
+      <span aria-hidden="true">×</span>
+    </button>
+  </div>
+  <div class="modal-body">
+    <h5 i18n>Select a Lead Part</h5>
+    <div class="row" *ngFor="let part of parts">
+      <div class="col-lg-10 offset-lg-1">
+        <div class="form-check">
+          <input class="form-check-input" type="radio" name="lead"
+            value="{{part.id()}}" [(ngModel)]="leadPart">
+          <label class="form-check-label">{{part.label()}}</label>
+        </div>
+      </div>
+    </div>
+  </div>
+  <div class="modal-footer">
+    <button type="button" class="btn btn-success" 
+      (click)="mergeParts()" i18n>Merge</button>
+    <button type="button" class="btn btn-warning" 
+      (click)="dismiss('canceled')" i18n>Cancel</button>
+  </div>
+</ng-template>
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/part-merge-dialog.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/record/part-merge-dialog.component.ts
new file mode 100644
index 0000000000..27c4d3045f
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/part-merge-dialog.component.ts
@@ -0,0 +1,70 @@
+import {Component, Input, ViewChild, TemplateRef} from '@angular/core';
+import {DialogComponent} from '@eg/share/dialog/dialog.component';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {NgbModal, NgbModalOptions} from '@ng-bootstrap/ng-bootstrap';
+
+ at Component({
+  selector: 'eg-catalog-part-merge-dialog',
+  templateUrl: './part-merge-dialog.component.html'
+})
+
+/**
+ * Ask the user which part is the lead part then merge others parts in.
+ */
+export class PartMergeDialogComponent extends DialogComponent {
+
+    // What parts are we merging
+    parts: IdlObject[];
+    copyPartMaps: IdlObject[];
+    leadPart: number;
+
+    constructor(
+        private idl: IdlService,
+        private pcrud: PcrudService,
+        private modal: NgbModal) {
+        super(modal);
+    }
+
+    mergeParts() {
+        console.log('Merging parts into lead part ', this.leadPart);
+
+        if (!this.leadPart) { return; }
+
+        this.leadPart = Number(this.leadPart);
+
+        // 1. Migrate copy maps to the lead part.
+        const partIds = this.parts
+            .filter(p => Number(p.id()) !== this.leadPart)
+               .map(p => Number(p.id()));
+
+        const maps = [];
+        this.pcrud.search('acpm', {part: partIds})
+        .subscribe(
+            map => {
+                map.part(this.leadPart);
+                map.ischanged(true);
+                maps.push(map);
+            },
+            err => {},
+            ()  => {
+                // 2. Delete the now-empty subordinate parts.  Note the
+                // delete must come after the part map changes are committed.
+                if (maps.length > 0) {
+                    this.pcrud.autoApply(maps)
+                        .toPromise().then(() => this.deleteParts());
+                } else {
+                    this.deleteParts();
+                }
+            }
+        );
+    }
+
+    deleteParts() {
+        const parts = this.parts.filter(p => Number(p.id()) !== this.leadPart);
+        parts.forEach(p => p.isdeleted(true));
+        this.pcrud.autoApply(parts).toPromise().then(res => this.close(res));
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/parts.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/record/parts.component.html
new file mode 100644
index 0000000000..f5693c18e5
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/parts.component.html
@@ -0,0 +1,22 @@
+<eg-catalog-part-merge-dialog #mergeDialog>
+</eg-catalog-part-merge-dialog>
+
+<div class="mt-3">
+
+  <eg-grid #partsGrid idlClass="bmp" [dataSource]="gridDataSource"
+      [sortable]="true" persistKey="catalog.record.parts"
+      showFields="id,label" class="mt-3">
+    <eg-grid-toolbar-button [disabled]="!permissions.CREATE_MONOGRAPH_PART"
+      label="New Monograph Part" i18n-label [action]="createNew">
+    </eg-grid-toolbar-button>
+    <eg-grid-toolbar-action label="Merge Selected" i18n-label [action]="mergeSelected">
+    </eg-grid-toolbar-action>
+    <eg-grid-toolbar-action label="Delete Selected" i18n-label [action]="deleteSelected">
+    </eg-grid-toolbar-action>
+  </eg-grid>
+  
+  <eg-fm-record-editor #editDialog idlClass="bmp"     
+    hiddenFields="record,label_sortkey,deleted">
+  </eg-fm-record-editor>
+
+</div>
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
new file mode 100644
index 0000000000..e74fc12d42
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/parts.component.ts
@@ -0,0 +1,123 @@
+import {Component, OnInit, Input, ViewChild} from '@angular/core';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {Pager} from '@eg/share/util/pager';
+import {OrgService} from '@eg/core/org.service';
+import {PermService} from '@eg/core/perm.service';
+import {GridDataSource} from '@eg/share/grid/grid';
+import {GridComponent} from '@eg/share/grid/grid.component';
+import {FmRecordEditorComponent} from '@eg/share/fm-editor/fm-editor.component';
+import {PartMergeDialogComponent} from './part-merge-dialog.component';
+
+ at Component({
+  selector: 'eg-catalog-record-parts',
+  templateUrl: 'parts.component.html'
+})
+export class PartsComponent implements OnInit {
+
+    recId: number;
+    gridDataSource: GridDataSource;
+    initDone: boolean;
+    @ViewChild('partsGrid') partsGrid: GridComponent;
+    @ViewChild('editDialog') editDialog: FmRecordEditorComponent;
+    @ViewChild('mergeDialog') mergeDialog: PartMergeDialogComponent;
+
+    canCreate: boolean;
+    canDelete: boolean;
+    createNew: () => void;
+    deleteSelected: (rows: IdlObject[]) => void;
+    mergeSelected: (rows: IdlObject[]) => void;
+    permissions: {[name: string]: boolean};
+
+    @Input() set recordId(id: number) {
+        this.recId = id;
+        // Only force new data collection when recordId()
+        // is invoked after ngInit() has already run.
+        if (this.initDone) {
+            this.partsGrid.reload();
+        }
+    }
+
+    constructor(
+        private idl: IdlService,
+        private org: OrgService,
+        private pcrud: PcrudService,
+        private perm: PermService
+    ) {
+        this.permissions = {};
+        this.gridDataSource = new GridDataSource();
+    }
+
+    ngOnInit() {
+        this.initDone = true;
+
+        // Load edit perms
+        this.perm.hasWorkPermHere([
+            'CREATE_MONOGRAPH_PART',
+            'UPDATE_MONOGRAPH_PART',
+            'DELETE_MONOGRAPH_PART'
+        ]).then(perms => this.permissions = perms);
+
+        this.gridDataSource.getRows = (pager: Pager, sort: any[]) => {
+            const orderBy: any = {};
+
+            if (sort.length) { // Sort provided by grid.
+                orderBy.bmp = sort[0].name + ' ' + sort[0].dir;
+            } else {
+                orderBy.bmp = 'label';
+            }
+
+            const searchOps = {
+                offset: pager.offset,
+                limit: pager.limit,
+                order_by: orderBy
+            };
+
+            return this.pcrud.search('bmp', 
+                {record: this.recId, deleted: 'f'}, searchOps);
+        };
+
+        this.partsGrid.onRowActivate.subscribe(
+            (part: IdlObject) => {
+                this.editDialog.mode = 'update';
+                this.editDialog.recId = part.id();
+                this.editDialog.open().then(
+                    ok => this.partsGrid.reload(),
+                    err => {}
+                );
+            }
+        );
+
+        this.createNew = () => {
+
+            const part = this.idl.create('bmp');
+            part.record(this.recId);
+            this.editDialog.record = part;
+
+            this.editDialog.mode = 'create';
+            this.editDialog.open().then(
+                ok => this.partsGrid.reload(),
+                err => {}
+            );
+        };
+
+        this.deleteSelected = (parts: IdlObject[]) => {
+            parts.forEach(part => part.isdeleted(true));
+            this.pcrud.autoApply(parts).subscribe(
+                val => console.debug('deleted: ' + val),
+                err => {},
+                ()  => this.partsGrid.reload()
+            );
+        };
+
+        this.mergeSelected = (parts: IdlObject[]) => {
+            if (parts.length < 2) { return; }
+            this.mergeDialog.parts = parts;
+            this.mergeDialog.open().then(
+                ok => this.partsGrid.reload(),
+                err => console.debug('Dialog dismissed')
+            );
+        };
+    }
+}
+
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 4c74316c4e..d1385230f3 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
@@ -19,17 +19,39 @@
     </eg-bib-summary>
   </div>
   <div id='staff-catalog-bib-tabs-container' class='mt-3'>
+    <div class="row">
+      <div class="col-lg-12 text-right">
+        <button class="btn btn-secondary btn-sm" 
+            [disabled]="recordTab == defaultTab"
+            (click)="setDefaultTab()" i18n>Set Default View</button>
+      </div>
+    </div>
     <ngb-tabset #recordTabs [activeId]="recordTab" (tabChange)="onTabChange($event)">
-      <ngb-tab title="Copy Table" i18n-title id="copy_table">
+      <ngb-tab title="Copy Table" i18n-title id="catalog">
         <ng-template ngbTabContent>
           <eg-catalog-copies [recordId]="recordId"></eg-catalog-copies>
         </ng-template>
       </ngb-tab>
-      <ngb-tab title="MARC View" i18n-title id="marc_view">
+      <!-- NOTE some tabs send the user over to the AngJS app -->
+      <ngb-tab title="MARC Edit" i18n-title id="marc_edit">
+      </ngb-tab>
+      <ngb-tab title="MARC View" i18n-title id="marc_html">
         <ng-template ngbTabContent>
           <eg-marc-html [recordId]="recordId" recordType="bib"></eg-marc-html>
         </ng-template>
       </ngb-tab>
+      <ngb-tab title="View Holds" i18n-title id="holds">
+      </ngb-tab>
+      <ngb-tab title="Monograph Parts" i18n-title id="monoparts">
+        <ng-template ngbTabContent>
+          <eg-catalog-record-parts [recordId]="recordId">
+          </eg-catalog-record-parts>
+        </ng-template>
+      </ngb-tab>
+      <ngb-tab title="Holdings View" i18n-title id="holdings">
+      </ngb-tab>
+      <ngb-tab title="Conjoined Items" i18n-title id="conjoined">
+      </ngb-tab>
     </ngb-tabset>
   </div>
 </div>
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 b217e5c9b6..0414a076b4 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
@@ -8,6 +8,14 @@ import {CatalogService} from '@eg/share/catalog/catalog.service';
 import {BibRecordService, BibRecordSummary} from '@eg/share/catalog/bib-record.service';
 import {StaffCatalogService} from '../catalog.service';
 import {BibSummaryComponent} from '@eg/staff/share/bib-summary/bib-summary.component';
+import {StoreService} from '@eg/core/store.service';
+
+const ANGJS_TABS: any = {
+    marc_edit: true,
+    holds: true,
+    holdings: true,
+    conjoined: true
+};
 
 @Component({
   selector: 'eg-catalog-record',
@@ -20,6 +28,7 @@ export class RecordComponent implements OnInit {
     summary: BibRecordSummary;
     searchContext: CatalogSearchContext;
     @ViewChild('recordTabs') recordTabs: NgbTabset;
+    defaultTab: string; // eg.cat.default_record_tab
 
     constructor(
         private router: Router,
@@ -27,21 +36,49 @@ export class RecordComponent implements OnInit {
         private pcrud: PcrudService,
         private bib: BibRecordService,
         private cat: CatalogService,
-        private staffCat: StaffCatalogService
+        private staffCat: StaffCatalogService,
+        private store: StoreService
     ) {}
 
     ngOnInit() {
         this.searchContext = this.staffCat.searchContext;
 
+        this.defaultTab = 
+            this.store.getLocalItem('eg.cat.default_record_tab')
+            || 'catalog';
+
+        // TODO: Implement default tab handling for tabs that require
+        // and AngJS redirect.
+
         // Watch for URL record ID changes
+        // This includes the initial route.
+        // When applying the default configured tab, no navigation occurs
+        // to apply the tab name to the URL, it displays as the default.
+        // This is done so no intermediate redirect is required, which
+        // messes with browser back/forward navigation.
         this.route.paramMap.subscribe((params: ParamMap) => {
-            this.recordTab = params.get('tab') || 'copy_table';
+            this.recordTab = params.get('tab');
             this.recordId = +params.get('id');
             this.searchContext = this.staffCat.searchContext;
+
+            if (!this.recordTab) {
+                this.recordTab = this.defaultTab || 'catalog';
+                // On initial load, if the default tab is set to one of
+                // the AngularJS tabs, redirect the user there.
+                if (this.recordTab in ANGJS_TABS) {
+                    return this.routeToTab();
+                }
+            }
+
             this.loadRecord();
         });
     }
 
+    setDefaultTab() {
+        this.defaultTab = this.recordTab;
+        this.store.setLocalItem('eg.cat.default_record_tab', this.recordTab);
+    }
+
     // Changing a tab in the UI means changing the route.
     // Changing the route ultimately results in changing the tab.
     onTabChange(evt: NgbTabChangeEvent) {
@@ -50,11 +87,23 @@ export class RecordComponent implements OnInit {
         // prevent tab changing until after route navigation
         evt.preventDefault();
 
-        let url = '/staff/catalog/record/' + this.recordId;
-        if (this.recordTab !== 'copy_table') {
-            url += '/' + this.recordTab;
+        this.routeToTab();
+    }
+
+    routeToTab() {
+
+        // Route to the AngularJS catalog tab
+        if (this.recordTab in ANGJS_TABS) {
+            const angjsBase = '/eg/staff/cat/catalog/record';
+
+            window.location.href = 
+                `${angjsBase}/${this.recordId}/${this.recordTab}`;
+            return;
         }
 
+        const url = 
+            `/staff/catalog/record/${this.recordId}/${this.recordTab}`;
+
         // Retain search parameters
         this.router.navigate([url], {queryParamsHandling: 'merge'});
     }
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 8761c58924..02b44c90df 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
@@ -37,22 +37,16 @@ export class CatalogResolver implements Resolve<Promise<any[]>> {
     }
 
     fetchSettings(): Promise<any> {
-        const promises = [];
 
-        promises.push(
-            this.store.getItem('eg.search.search_lib').then(
-                id => this.staffCat.defaultSearchOrg = this.org.get(id)
-            )
-        );
-
-        promises.push(
-            this.store.getItem('eg.search.pref_lib').then(
-                id => this.staffCat.prefOrg = this.org.get(id)
-            )
-        );
-
-        return Promise.all(promises);
+        return this.store.getItemBatch([
+            'eg.search.search_lib', 
+            'eg.search.pref_lib'
+        ]).then(settings => {
+            this.staffCat.defaultSearchOrg = 
+                this.org.get(settings['eg.search.search_lib']);
+            this.staffCat.prefOrg = 
+                this.org.get(settings['eg.search.pref_lib']);
+        })
     }
-
 }
 
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/result/facets.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/result/facets.component.ts
index 44583b8780..f16215a65f 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/result/facets.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/result/facets.component.ts
@@ -35,11 +35,11 @@ export class ResultFacetsComponent implements OnInit {
     }
 
     facetIsApplied(cls: string, name: string, value: string): boolean {
-        return this.searchContext.hasFacet(new FacetFilter(cls, name, value));
+        return this.searchContext.termSearch.hasFacet(new FacetFilter(cls, name, value));
     }
 
     applyFacet(cls: string, name: string, value: string): void {
-        this.searchContext.toggleFacet(new FacetFilter(cls, name, value));
+        this.searchContext.termSearch.toggleFacet(new FacetFilter(cls, name, value));
         this.searchContext.pager.offset = 0;
         this.staffCat.search();
     }
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
new file mode 100644
index 0000000000..3077d9ac93
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.css
@@ -0,0 +1,15 @@
+
+/**
+ * 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 {
+    width: 68px;
+}
+
+.record-jacket-div img {
+    height: 100%; 
+    max-height:80px; 
+    max-width: 54px;
+}
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 54ad3db0ee..90f066b1e9 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
@@ -9,44 +9,57 @@
 <div class="col-lg-12 card tight-card mb-2 bg-light">
   <div class="card-body">
     <div class="row">
-      <div class="col-lg-1">
-        <a href="javascript:void(0)" (click)="navigatToRecord(summary.id)">
-          <img style="height:80px"
-            src="/opac/extras/ac/jacket/small/r/{{summary.id}}"/>
-        </a>
-      </div>
-      <div class="col-lg-5">
-        <div class="row">
-          <div class="col-lg-12 font-weight-bold">
-            <!-- nbsp allows the column to take shape when no value exists -->
-            <span class="font-weight-light font-italic">
-              #{{index + 1 + searchContext.pager.offset}}
-            </span>
-            <a href="javascript:void(0)"
-              (click)="navigatToRecord(summary.id)">
-              {{summary.display.title || ' '}}
-            </a>
-          </div>
+      <!-- Checkbox, jacket image, and title blob live in a flex row
+           because there's no way to give them col-lg-* columns that
+           don't waste a lot of space. -->
+      <div class="col-lg-6 d-flex">
+        <label class="checkbox">
+          <span class="font-weight-bold font-italic">
+            {{index + 1 + searchContext.pager.offset}}.
+          </span>
+          <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" >
+          <a href="javascript:void(0)" (click)="navigateToRecord(summary)">
+            <img src="/opac/extras/ac/jacket/small/r/{{summary.id}}"/>
+          </a>
         </div>
-        <div class="row pt-2">
-          <div class="col-lg-12">
-            <!-- nbsp allows the column to take shape when no value exists -->
-            <a href="javascript:void(0)"
-              (click)="searchAuthor(summary)">
-              {{summary.display.author || ' '}}
-            </a>
+        <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 -->
+              <a href="javascript:void(0)"
+                (click)="navigateToRecord(summary)">
+                {{summary.display.title || ' '}}
+              </a>
+            </div>
           </div>
-        </div>
-        <div class="row pt-2">
-          <div class="col-lg-12">
-            <!-- only shows the first icon format -->
-            <span *ngIf="summary.attributes.icon_format && summary.attributes.icon_format[0]">
-              <img class="pr-1"
-                src="/images/format_icons/icon_format/{{summary.attributes.icon_format[0]}}.png"/>
-              <span>{{iconFormatLabel(summary.attributes.icon_format[0])}}</span>
-            </span>
-            <span class='pl-1'>{{summary.display.edition}}</span>
-            <span class='pl-1'>{{summary.display.pubdate}}</span>
+          <div class="row pt-2">
+            <div class="col-lg-12">
+              <!-- nbsp allows the column to take shape when no value exists -->
+              <a href="javascript:void(0)"
+                (click)="searchAuthor(summary)">
+                {{summary.display.author || ' '}}
+              </a>
+            </div>
+          </div>
+          <div class="row pt-2">
+            <div class="col-lg-12">
+              <ng-container *ngIf="summary.attributes.icon_format && summary.attributes.icon_format[0]">
+                <ng-container *ngFor="let icon of summary.attributes.icon_format">
+                <span class="pr-1">
+                  <img class="pr-1"
+                    src="/images/format_icons/icon_format/{{icon}}.png"/>
+                  <span>{{iconFormatLabel(icon)}}</span>
+                </span>
+                </ng-container>
+              </ng-container>
+              <span class='pl-1'>{{summary.display.edition}}</span>
+              <span class='pl-1'>{{summary.display.pubdate}}</span>
+            </div>
           </div>
         </div>
       </div>
@@ -114,6 +127,7 @@
                   <span i18n>Place Hold</span>
                 </button>
               </span>
+              <!--
               <span class="pl-1">
                 <button 
                   (click)="addToListDialog.recordId=summary.record.id(); addToListDialog.open({size: 'lg'})"
@@ -122,6 +136,7 @@
                   <span i18n>Add to List</span>
                 </button>
               </span>
+              -->
             </div>
           </div>
         </div>
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 bfcfd4572e..7510b3d108 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
@@ -1,4 +1,5 @@
-import {Component, OnInit, Input} from '@angular/core';
+import {Component, OnInit, OnDestroy, Input} from '@angular/core';
+import {Subscription} from 'rxjs/Subscription';
 import {Router} from '@angular/router';
 import {OrgService} from '@eg/core/org.service';
 import {NetService} from '@eg/core/net.service';
@@ -7,16 +8,20 @@ import {BibRecordService, BibRecordSummary} from '@eg/share/catalog/bib-record.s
 import {CatalogSearchContext} from '@eg/share/catalog/search-context';
 import {CatalogUrlService} from '@eg/share/catalog/catalog-url.service';
 import {StaffCatalogService} from '../catalog.service';
+import {BasketService} from '@eg/share/catalog/basket.service';
 
 @Component({
   selector: 'eg-catalog-result-record',
-  templateUrl: 'record.component.html'
+  templateUrl: 'record.component.html',
+  styleUrls: ['record.component.css']
 })
-export class ResultRecordComponent implements OnInit {
+export class ResultRecordComponent implements OnInit, OnDestroy {
 
     @Input() index: number;  // 0-index display row
     @Input() summary: BibRecordSummary;
     searchContext: CatalogSearchContext;
+    isRecordSelected: boolean;
+    basketSub: Subscription;
 
     constructor(
         private router: Router,
@@ -25,12 +30,23 @@ export class ResultRecordComponent implements OnInit {
         private bib: BibRecordService,
         private cat: CatalogService,
         private catUrl: CatalogUrlService,
-        private staffCat: StaffCatalogService
+        private staffCat: StaffCatalogService,
+        private basket: BasketService
     ) {}
 
     ngOnInit() {
         this.searchContext = this.staffCat.searchContext;
         this.summary.getHoldCount();
+        this.isRecordSelected = this.basket.hasRecordId(this.summary.id);
+
+        // Watch for basket changes caused by other components
+        this.basketSub = this.basket.onChange.subscribe(() => {
+            this.isRecordSelected = this.basket.hasRecordId(this.summary.id);
+        });
+    }
+
+    ngOnDestroy() {
+        this.basketSub.unsubscribe();
     }
 
     orgName(orgId: number): string {
@@ -38,17 +54,21 @@ export class ResultRecordComponent implements OnInit {
     }
 
     iconFormatLabel(code: string): string {
-        if (this.cat.ccvmMap) {
-            const ccvm = this.cat.ccvmMap.icon_format.filter(
-                format => format.code() === code)[0];
-            if (ccvm) {
-                return ccvm.search_label();
-            }
-        }
+        return this.cat.iconFormatLabel(code);
     }
 
     placeHold(): void {
-        alert('Placing hold on bib ' + this.summary.id);
+        let holdType = 'T';
+        let holdTarget = this.summary.id;
+
+        const ts = this.searchContext.termSearch;
+        if (ts.isMetarecordSearch()) {
+            holdType = 'M';
+            holdTarget = this.summary.metabibId;
+        }
+
+        this.router.navigate([`/staff/catalog/hold/${holdType}`], 
+            {queryParams: {target: holdTarget}});
     }
 
     addToList(): void {
@@ -57,21 +77,36 @@ export class ResultRecordComponent implements OnInit {
 
     searchAuthor(summary: any) {
         this.searchContext.reset();
-        this.searchContext.fieldClass = ['author'];
-        this.searchContext.query = [summary.display.author];
+        this.searchContext.termSearch.fieldClass = ['author'];
+        this.searchContext.termSearch.query = [summary.display.author];
         this.staffCat.search();
     }
 
     /**
      * Propagate the search params along when navigating to each record.
      */
-    navigatToRecord(id: number) {
+    navigateToRecord(summary: BibRecordSummary) {
         const params = this.catUrl.toUrlParams(this.searchContext);
 
+        // Jump to metarecord constituent records page when a 
+        // MR has more than 1 constituents.
+        if (summary.metabibId && summary.metabibRecords.length > 1) {
+            this.searchContext.termSearch.fromMetarecord = summary.metabibId;
+            this.staffCat.search();
+            return;
+        }
+
         this.router.navigate(
-          ['/staff/catalog/record/' + id], {queryParams: params});
+            ['/staff/catalog/record/' + summary.id], {queryParams: params});
     }
 
+    toggleBasketEntry() {
+        if (this.isRecordSelected) {
+            return this.basket.addRecordIds([this.summary.id]);
+        } else {
+            return this.basket.removeRecordIds([this.summary.id]);
+        }
+    }
 }
 
 
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/result/results.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/result/results.component.html
index ee9ca8ddf2..902e50baa4 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/result/results.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/result/results.component.html
@@ -1,30 +1,75 @@
 
-<div id="staff-catalog-results-container" *ngIf="searchIsDone()">
+<!-- search results progress bar -->
+<div class="row" *ngIf="searchIsActive()">
+  <div class="col-lg-6 offset-lg-3 pt-3">
+    <div class="progress">
+      <div class="progress-bar progress-bar-striped active w-100"
+        role="progressbar" aria-valuenow="100" 
+        aria-valuemin="0" aria-valuemax="100">
+        <span class="sr-only" i18n>Searching..</span>
+      </div>
+    </div>
+  </div>
+</div>
+
+<!-- no items found -->
+<div *ngIf="searchIsDone() && !searchHasResults()">
+  <div class="row pt-3">
+    <div class="col-lg-6 offset-lg-3">
+      <div class="alert alert-warning">
+        <span i18n>No Maching Items Were Found</span>
+      </div>
+    </div>
+  </div>
+</div>
+
+<!-- header, pager, and list of records -->
+<div id="staff-catalog-results-container" *ngIf="searchHasResults()">
   <div class="row">
-    <div class="col-lg-2"><!--match pagination margin-->
-      <h3 i18n>Search Results ({{searchContext.result.count}})</h3>
+    <div class="col-lg-2" *ngIf="!searchContext.basket">
+      <ng-container *ngIf="searchContext.termSearch.browseEntry">
+        <h3 i18n>Results for browse "{{searchContext.termSearch.browseEntry.value()}}"</h3>
+      </ng-container>
+      <ng-container *ngIf="!searchContext.termSearch.browseEntry">
+        <h3 i18n>Search Results ({{searchContext.result.count}})</h3>
+      </ng-container>
+    </div>
+    <div class="col-lg-2" *ngIf="searchContext.basket">
+      <h3 i18n>Basket View</h3>
     </div>
-    <div class="col-lg-1"></div>
-    <div class="col-lg-9">
+    <div class="col-lg-2">
+      <label class="checkbox" *ngIf="!searchContext.basket">
+        <input type='checkbox' [(ngModel)]="allRecsSelected" 
+            (change)="toggleAllRecsSelected()"/>
+        <span class="pl-1" i18n>Select {{searchContext.pager.rowNumber(0)}} - 
+          {{searchContext.pager.rowNumber(searchContext.currentResultIds().length - 1)}}
+        </span>
+      </label>
+    </div>
+    <div class="col-lg-8">
       <div class="float-right">
-				<eg-catalog-result-pagination></eg-catalog-result-pagination>
+        <eg-catalog-result-pagination></eg-catalog-result-pagination>
       </div>
     </div>
   </div>
-	<div class="row mt-2">
-		<div class="col-lg-2">
-      <eg-catalog-result-facets></eg-catalog-result-facets>
-		</div>
-		<div class="col-lg-10">
-			<div *ngIf="searchContext.result">
-				<div *ngFor="let summary of searchContext.result.records; let idx = index">
-          <div *ngIf="summary">
-					  <eg-catalog-result-record [summary]="summary" [index]="idx">
-					  </eg-catalog-result-record>
+  <div>
+    <div class="row mt-2">
+      <div class="col-lg-2" *ngIf="!searchContext.basket">
+        <eg-catalog-result-facets></eg-catalog-result-facets>
+      </div>
+      <div
+        [ngClass]="{'col-lg-10': !searchContext.basket, 'col-lg-12': searchContext.basket}">
+        <div *ngIf="shouldStartRendering()">
+          <div *ngFor="let summary of searchContext.result.records; let idx = index">
+            <div *ngIf="summary">
+              <eg-catalog-result-record [summary]="summary" [index]="idx">
+              </eg-catalog-result-record>
+            </div>
           </div>
-				</div>
-			</div>
-		</div>
-	</div>
+        </div>
+      </div>
+    </div>
+  </div>
 </div>
 
+
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 121888d3ba..6a03b9bdd2 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
@@ -1,5 +1,5 @@
-import {Component, OnInit, Input} from '@angular/core';
-import {Observable} from 'rxjs';
+import {Component, OnInit, OnDestroy, Input} from '@angular/core';
+import {Observable, Subscription} from 'rxjs';
 import {map, switchMap, distinctUntilChanged} from 'rxjs/operators';
 import {ActivatedRoute, ParamMap} from '@angular/router';
 import {CatalogService} from '@eg/share/catalog/catalog.service';
@@ -9,12 +9,13 @@ import {CatalogSearchContext, CatalogSearchState} from '@eg/share/catalog/search
 import {PcrudService} from '@eg/core/pcrud.service';
 import {StaffCatalogService} from '../catalog.service';
 import {IdlObject} from '@eg/core/idl.service';
+import {BasketService} from '@eg/share/catalog/basket.service';
 
 @Component({
   selector: 'eg-catalog-results',
   templateUrl: 'results.component.html'
 })
-export class ResultsComponent implements OnInit {
+export class ResultsComponent implements OnInit, OnDestroy {
 
     searchContext: CatalogSearchContext;
 
@@ -22,13 +23,20 @@ export class ResultsComponent implements OnInit {
     // reasonably small set of data w/ lots of repitition.
     userCache: {[id: number]: IdlObject} = {};
 
+    allRecsSelected: boolean;
+
+    searchSub: Subscription;
+    routeSub: Subscription;
+    basketSub: Subscription;
+
     constructor(
         private route: ActivatedRoute,
         private pcrud: PcrudService,
         private cat: CatalogService,
         private bib: BibRecordService,
         private catUrl: CatalogUrlService,
-        private staffCat: StaffCatalogService
+        private staffCat: StaffCatalogService,
+        private basket: BasketService
     ) {}
 
     ngOnInit() {
@@ -41,7 +49,8 @@ export class ResultsComponent implements OnInit {
         // searches.
         //
         // This will also fire on page load.
-        this.route.queryParamMap.subscribe((params: ParamMap) => {
+        this.routeSub = 
+            this.route.queryParamMap.subscribe((params: ParamMap) => {
 
               // TODO: Angular docs suggest using switchMap(), but
               // it's not firing for some reason.  Also, could avoid
@@ -51,8 +60,36 @@ export class ResultsComponent implements OnInit {
               // .map() is not firing either.  I'm missing something.
               this.searchByUrl(params);
         });
+
+        // After each completed search, update the record selector.
+        this.searchSub = this.cat.onSearchComplete.subscribe(
+            ctx => this.applyRecordSelection());
+
+        // Watch for basket changes applied by other components.
+        this.basketSub = this.basket.onChange.subscribe(
+            () => this.applyRecordSelection());
+    }
+
+    ngOnDestroy() {
+        this.routeSub.unsubscribe();
+        this.searchSub.unsubscribe();
+        this.basketSub.unsubscribe();
+    }
+
+    // Apply the select-all checkbox when all visible records
+    // are selected.
+    applyRecordSelection() {
+        const ids = this.searchContext.currentResultIds();
+        let allChecked = true;
+        ids.forEach(id => {
+            if (!this.basket.hasRecordId(id)) { 
+                allChecked = false; 
+            }
+        });
+        this.allRecsSelected = allChecked;
     }
 
+    // Pull values from the URL and run the requested search.
     searchByUrl(params: ParamMap): void {
         this.catUrl.applyUrlParams(this.searchContext, params);
 
@@ -67,6 +104,25 @@ export class ResultsComponent implements OnInit {
         }
     }
 
+    // Records file into place randomly as the server returns data.
+    // To reduce page display shuffling, avoid showing the list of
+    // records until the first few are ready to render.
+    shouldStartRendering(): boolean {
+
+        if (this.searchHasResults()) {
+            const pageCount = this.searchContext.currentResultIds().length;
+            switch (pageCount) {
+                case 1:
+                    return this.searchContext.result.records[0];
+                default:
+                    return this.searchContext.result.records[0]
+                        && this.searchContext.result.records[1];
+            }
+        }
+
+        return false;
+    }
+
     fleshSearchResults(): void {
         const records = this.searchContext.result.records;
         if (!records || records.length === 0) { return; }
@@ -79,6 +135,23 @@ export class ResultsComponent implements OnInit {
         return this.searchContext.searchState === CatalogSearchState.COMPLETE;
     }
 
+    searchIsActive(): boolean {
+        return this.searchContext.searchState === CatalogSearchState.SEARCHING;
+    }
+
+    searchHasResults(): boolean {
+        return this.searchIsDone() && this.searchContext.result.count > 0;
+    }
+
+    toggleAllRecsSelected() {
+        const ids = this.searchContext.currentResultIds();
+
+        if (this.allRecsSelected) {
+            this.basket.addRecordIds(ids);
+        } else {
+            this.basket.removeRecordIds(ids);
+        }
+    }
 }
 
 
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 0e3c96fd00..8bcef4f30c 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
@@ -4,6 +4,8 @@ import {CatalogComponent} from './catalog.component';
 import {ResultsComponent} from './result/results.component';
 import {RecordComponent} from './record/record.component';
 import {CatalogResolver} from './resolver.service';
+import {HoldComponent} from './hold/hold.component';
+import {BrowseComponent} from './browse.component';
 
 const routes: Routes = [{
   path: '',
@@ -16,9 +18,16 @@ const routes: Routes = [{
     path: 'record/:id',
     component: RecordComponent
   }, {
+    path: 'hold/:type',
+    component: HoldComponent
+  }, {
     path: 'record/:id/:tab',
     component: RecordComponent
-  }]
+  }]}, {
+  // Browse is a top-level UI
+  path: 'browse',
+  component: BrowseComponent,
+  resolve: {catResolver : CatalogResolver},
 }];
 
 @NgModule({
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.css b/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.css
index 6201dff923..c7d59d19d3 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.css
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.css
@@ -12,5 +12,17 @@ select.form-control:not([size]):not([multiple]) {
 }
 
 #staffcat-search-form {
-  border-bottom: 2px dashed rgba(0,0,0,.225);
+  border-radius: 0px 0px 7px 7px;
+  background-color: rgba(243, 127, 65, .1);
+  box-shadow: 3px 3px 2px rgba(185, 65, 0, .2);
+}
+
+#staffcat-search-form .tab-content {
+  border: 3px;
+}
+
+.tab-content {
+  padding: 5px;
+  margin-top: 25px;
+  font-weight: bold;
 }
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 da54f4a9b9..ee4abc522c 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,243 +1,333 @@
 <!--
 TODO focus search input
 -->
-<div id='staffcat-search-form' class='pb-2 mb-3'>
-  <div class="row"
-    *ngFor="let q of searchContext.query; let idx = index; trackBy:trackByIdx">
-    <div class="col-lg-9 d-flex">
-      <div class="flex-1">
-        <div *ngIf="idx == 0">
-          <select class="form-control" [(ngModel)]="searchContext.format">
-            <option i18n value=''>All Formats</option>
-            <option *ngFor="let fmt of ccvmMap.search_format"
-              value="{{fmt.code()}}">{{fmt.value()}}</option>
-          </select>
-        </div>
-        <div *ngIf="idx > 0">
-          <select class="form-control"
-            [(ngModel)]="searchContext.joinOp[idx]">
-            <option i18n value='&&'>And</option>
-            <option i18n value='||'>Or</option>
-          </select>
-        </div>
-      </div>
-      <div class="flex-1 pl-1">
-        <select class="form-control" 
-          [(ngModel)]="searchContext.fieldClass[idx]">
-          <option i18n value='keyword'>Keyword</option>
-          <option i18n value='title'>Title</option>
-          <option i18n value='jtitle'>Journal Title</option>
-          <option i18n value='author'>Author</option>
-          <option i18n value='subject'>Subject</option>
-          <option i18n value='series'>Series</option>
-        </select>
-      </div>
-      <div class="flex-1 pl-1">
-        <select class="form-control" 
-          [(ngModel)]="searchContext.matchOp[idx]">
-          <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>
-        </select>
-      </div>
-      <div class="flex-2 pl-1">
-        <div class="form-group">
-          <div *ngIf="idx == 0">
-            <input type="text" class="form-control"
-              id='first-query-input'
-              [(ngModel)]="searchContext.query[idx]"
-              (keyup.enter)="formEnter('query')"
-              placeholder="Query..."/>
+<div id='staffcat-search-form' class="row 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">
+        <ng-template ngbTabContent>
+          <div class="row"
+            [ngClass]="{'mt-4': idx == 0, 'mt-1': idx > 0}"
+            *ngFor="let q of context.termSearch.query; let idx = index; trackBy:trackByIdx">
+            <div class="col-lg-2 pr-1">
+              <div *ngIf="idx == 0">
+                <select class="form-control" [(ngModel)]="context.termSearch.format">
+                  <option i18n value=''>All Formats</option>
+                  <option *ngFor="let fmt of ccvmMap.search_format"
+                    value="{{fmt.code()}}">{{fmt.value()}}</option>
+                </select>
+              </div>
+              <div *ngIf="idx > 0">
+                <select class="form-control"
+                  [(ngModel)]="context.termSearch.joinOp[idx]">
+                  <option i18n value='&&'>And</option>
+                  <option i18n value='||'>Or</option>
+                </select>
+              </div>
+            </div>
+            <div class="col-lg-2 pl-0 pr-2">
+              <select class="form-control" 
+                [(ngModel)]="context.termSearch.fieldClass[idx]">
+                <option i18n value='keyword'>Keyword</option>
+                <option i18n value='title'>Title</option>
+                <option i18n value='jtitle'>Journal Title</option>
+                <option i18n value='author'>Author</option>
+                <option i18n value='subject'>Subject</option>
+                <option i18n value='series'>Series</option>
+              </select>
+            </div>
+            <div class="col-lg-2 pl-0 pr-2">
+              <select class="form-control" 
+                [(ngModel)]="context.termSearch.matchOp[idx]">
+                <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>
+              </select>
+            </div>
+            <div class="col-lg-4 pl-0 pr-2">
+              <div class="form-group">
+                <div *ngIf="idx == 0">
+                  <input type="text" class="form-control"
+                    id='first-query-input'
+                    [(ngModel)]="context.termSearch.query[idx]"
+                    (keyup.enter)="searchByForm()"
+                    placeholder="Query..."/>
+                </div>
+                <div *ngIf="idx > 0">
+                  <input type="text" class="form-control"
+                    [(ngModel)]="context.termSearch.query[idx]"
+                    (keyup.enter)="searchByForm()"
+                    placeholder="Query..."/>
+                </div>
+              </div>
+            </div>
+            <div class="col-lg-2 pl-0 pr-1">
+              <button class="btn btn-sm material-icon-button"
+                (click)="addSearchRow(idx + 1)"
+                i18n-title title="Add Search Row">
+                <span class="material-icons">add_circle_outline</span>
+              </button>
+              <button class="btn btn-sm material-icon-button"
+                [disabled]="context.termSearch.query.length < 2"
+                (click)="delSearchRow(idx)"
+                i18n-title title="Remove Search Row">
+                <span class="material-icons">remove_circle_outline</span>
+              </button>
+              <button *ngIf="idx == 0"
+                class="btn btn-sm material-icon-button" 
+                type="button" (click)="toggleFilters()" 
+                title="Toggle Search Filters" i18n-title>
+                <span class="material-icons">more_vert</span>
+              </button>
+            </div>
           </div>
-          <div *ngIf="idx > 0">
-            <input type="text" class="form-control"
-              [(ngModel)]="searchContext.query[idx]"
-              (keyup.enter)="formEnter('query')"
-              placeholder="Query..."/>
+          <div class="row">
+            <div class="col-lg-12 form-inline">
+                <select class="form-control mr-2" [(ngModel)]="context.sort">
+                  <option value='' i18n>Sort by Relevance</option>
+                  <optgroup label="Sort by Title" i18n-label>
+                    <option value='titlesort' i18n>Title: A to Z</option>
+                    <option value='titlesort.descending' i18n>Title: Z to A</option>
+                  </optgroup>
+                  <optgroup label="Sort by Author" i18n-label>
+                    <option value='authorsort' i18n>Author: A to Z</option>
+                    <option value='authorsort.descending' i18n>Author: Z to A</option>
+                  </optgroup>
+                  <optgroup label="Sort by Publication Date" i18n-label>
+                    <option value='pubdate' i18n>Date: A to Z</option>
+                    <option value='pubdate.descending' i18n>Date: Z to A</option>
+                  </optgroup>
+                  <optgroup label="Sort by Popularity" i18n-label>
+                    <option value='popularity' i18n>Most Popular</option>
+                    <option value='poprel' i18n>Popularity Adjusted Relevance</option>
+                  </optgroup>
+                </select>
+                <div class="checkbox pl-2 ml-2">
+                  <label>
+                    <input type="checkbox" [(ngModel)]="context.termSearch.available"/>
+                    <span class="pl-1" i18n>Limit to Available</span>
+                  </label>
+                </div>
+                <div class="checkbox pl-3">
+                  <label>
+                    <input type="checkbox"
+                      [(ngModel)]="context.termSearch.groupByMetarecord"/>
+                    <span class="pl-1" i18n>Group Formats/Editions</span>
+                  </label>
+                </div>
+                <div class="checkbox pl-3">
+                  <label>
+                    <input type="checkbox" [(ngModel)]="context.termSearch.global"/>
+                    <span class="pl-1" i18n>Results from All Libraries</span>
+                  </label>
+                </div>
+              </div>
           </div>
-        </div>
-      </div>
-      <div class="flex-1 pl-1">
-        <button class="btn btn-sm material-icon-button"
-          (click)="addSearchRow(idx + 1)">
-          <span class="material-icons">add_circle_outline</span>
-        </button>
-        <button class="btn btn-sm material-icon-button"
-          [disabled]="searchContext.query.length < 2"
-          (click)="delSearchRow(idx)">
-          <span class="material-icons">remove_circle_outline</span>
-        </button>
-      </div>
-    </div><!-- col -->
-    <div class="col-lg-3">
-      <div *ngIf="idx == 0" class="float-right">
-        <button class="btn btn-success mr-1" type="button"
-          [disabled]="searchIsActive()"
-          (click)="searchContext.pager.offset=0;searchByForm()">
-          Search
-        </button>
-        <button class="btn btn-warning mr-1" type="button"
-          [disabled]="searchIsActive()"
-          (click)="searchContext.reset()">
-          Clear Form
-        </button>
-        <button class="btn btn-outline-secondary" type="button"
-          *ngIf="!showAdvanced()"
-          [disabled]="searchIsActive()"
-          (click)="showAdvancedSearch=true">
-          More Filters
-        </button>
-        <button class="btn btn-outline-secondary" type="button"
-          *ngIf="showAdvanced()"
-          (click)="showAdvancedSearch=false">
-          Hide Filters
-        </button>
-      </div>
-    </div>
-  </div><!-- row -->
-
-  <div class="row">
-    <div class="col-lg-9 d-flex">
-      <div class="flex-1">
-        <eg-org-select 
-          (onChange)="orgOnChange($event)"
-          [initialOrg]="searchContext.searchOrg"
-          [placeholder]="'Library'" >
-        </eg-org-select>
-      </div>
-      <div class="flex-3 pl-1">
-        <select class="form-control" [(ngModel)]="searchContext.sort">
-          <option value='' i18n>Sort by Relevance</option>
-          <optgroup label="Sort by Title" i18n-label>
-            <option value='titlesort' i18n>Title: A to Z</option>
-            <option value='titlesort.descending' i18n>Title: Z to A</option>
-          </optgroup>
-          <optgroup label="Sort by Author" i18n-label>
-            <option value='authorsort' i18n>Author: A to Z</option>
-            <option value='authorsort.descending' i18n>Author: Z to A</option>
-          </optgroup>
-          <optgroup label="Sort by Publication Date" i18n-label>
-            <option value='pubdate' i18n>Date: A to Z</option>
-            <option value='pubdate.descending' i18n>Date: Z to A</option>
-          </optgroup>
-          <optgroup label="Sort by Popularity" i18n-label>
-            <option value='popularity' i18n>Most Popular</option>
-            <option value='poprel' i18n>Popularity Adjusted Relevance</option>
-          </optgroup>
-        </select>
-      </div>
-      <div class="flex-2 pl-2 align-self-end">
-        <div class="checkbox">
-          <label>
-            <input type="checkbox" [(ngModel)]="searchContext.available"/>
-            <span i18n>Limit to Available</span>
-          </label>
-        </div>
-      </div>
-      <div class="flex-4 pl-2 align-self-end">
-        <div class="checkbox">
-          <label>
-            <input type="checkbox" [(ngModel)]="searchContext.global"/>
-            <span i18n>Show Results from All Libraries</span>
-          </label>
-        </div>
-      </div>
-      <div class="flex-2 pl-1">
-        <!-- alignment -->
-      </div>
-    </div>
-    <div class="col-lg-3">
-      <div *ngIf="searchIsActive()">
-        <div class="progress">
-          <div class="progress-bar progress-bar-striped active w-100"
-            role="progressbar" aria-valuenow="100" 
-            aria-valuemin="0" aria-valuemax="100">
-            <span class="sr-only" i18n>Searching..</span>
+          <div class="row mt-3" *ngIf="showFilters()">
+            <div class="col-lg-3">
+              <select class="form-control"  multiple="true"
+                [(ngModel)]="context.termSearch.ccvmFilters.item_type">
+                <option value='' i18n>All Item Types</option>
+                <option *ngFor="let itemType of ccvmMap.item_type"
+                  value="{{itemType.code()}}">{{itemType.value()}}</option>
+              </select>
+            </div>
+            <div class="col-lg-3">
+              <select class="form-control" multiple="true"
+                [(ngModel)]="context.termSearch.ccvmFilters.item_form">
+                <option value='' i18n>All Item Forms</option>
+                <option *ngFor="let itemForm of ccvmMap.item_form"
+                  value="{{itemForm.code()}}">{{itemForm.value()}}</option>
+              </select>
+            </div>
+            <div class="col-lg-3">
+              <select class="form-control" 
+                [(ngModel)]="context.termSearch.ccvmFilters.item_lang" multiple="true">
+                <option value='' i18n>All Languages</option>
+                <option *ngFor="let lang of ccvmMap.item_lang"
+                  value="{{lang.code()}}">{{lang.value()}}</option>
+              </select>
+            </div>
+            <div class="col-lg-3">
+              <select class="form-control" 
+                [(ngModel)]="context.termSearch.ccvmFilters.audience" multiple="true">
+                <option value='' i18n>All Audiences</option>
+                <option *ngFor="let audience of ccvmMap.audience"
+                  value="{{audience.code()}}">{{audience.value()}}</option>
+              </select>
+            </div>
+          </div>
+          <div class="row mt-3" *ngIf="showFilters()">
+            <div class="col-lg-3">
+              <select class="form-control" 
+                [(ngModel)]="context.termSearch.ccvmFilters.vr_format" multiple="true">
+                <option value='' i18n>All Video Formats</option>
+                <option *ngFor="let vrFormat of ccvmMap.vr_format"
+                  value="{{vrFormat.code()}}">{{vrFormat.value()}}</option>
+              </select>
+            </div>
+            <div class="col-lg-3">
+              <select class="form-control" 
+                [(ngModel)]="context.termSearch.ccvmFilters.bib_level" multiple="true">
+                <option value='' i18n>All Bib Levels</option>
+                <option *ngFor="let bibLevel of ccvmMap.bib_level"
+                  value="{{bibLevel.code()}}">{{bibLevel.value()}}</option>
+              </select>
+            </div>
+            <div class="col-lg-3">
+              <select class="form-control" 
+                [(ngModel)]="context.termSearch.ccvmFilters.lit_form" multiple="true">
+                <option value='' i18n>All Literary Forms</option>
+                <option *ngFor="let litForm of ccvmMap.lit_form"
+                  value="{{litForm.code()}}">{{litForm.value()}}</option>
+              </select>
+            </div>
+            <div class="col-lg-3">
+              <select class="form-control" 
+                [(ngModel)]="context.termSearch.copyLocations" multiple="true">
+                <option value='' i18n>All Copy Locations</option>
+                <option *ngFor="let loc of copyLocations" value="{{loc.id()}}" i18n>
+                  {{loc.name()}} ({{orgName(loc.owning_lib())}})
+                </option>
+              </select>
+            </div>
+          </div>
+          <div class="row mt-3" *ngIf="showFilters()">
+            <div class="col-lg-12">
+              <div class="form-inline" i18n>
+                <label for="pub-date1-input">Publication Year is</label>
+                <select class="form-control ml-2" [(ngModel)]="context.termSearch.dateOp">
+                  <option value='is'>Is</option>
+                  <option value='before'>Before</option>
+                  <option value='after'>After</option>
+                  <option value='between'>Between</option>
+                </select>
+                <input class="form-control ml-2" type="number"
+                  [(ngModel)]="context.termSearch.date1"/>
+                <input class="form-control ml-2" type="number"
+                  *ngIf="context.termSearch.dateOp == 'between'"
+                  [(ngModel)]="context.termSearch.date2"/>
+              </div>
+            </div>
+          </div>
+        </ng-template>
+      </ngb-tab>
+      <ngb-tab title="Numeric Search" i18n-title id="ident">
+        <ng-template ngbTabContent>
+          <div class="row mt-4">
+            <div class="col-lg-12">
+              <div class="form-inline">
+                <label for="ident-type" i18n>Query Type</label>
+                <select class="form-control ml-2" name="ident-type"
+                  [(ngModel)]="context.identSearch.queryType">
+                  <option i18n value="identifier|isbn">ISBN</option>
+                  <option i18n value="identifier|issn">ISSN</option>
+                  <option i18n disabled value="cnbrowse">Call Number (Shelf Browse)</option>
+                  <option i18n value="identifier|lccn">LCCN</option>
+                  <option i18n value="identifier|tcn">TCN</option>
+                  <option i18n value="item_barcode">Item Barcode</option>
+                </select>
+                <label for="ident-value" class="ml-2" i18n>Value</label>
+                <input name="ident-value" id='ident-query-input' 
+                  type="text" class="form-control ml-2"
+                  [(ngModel)]="context.identSearch.value"
+                  (keyup.enter)="searchByForm()"
+                  placeholder="Numeric Query..."/>
+              </div>
+            </div>
+          </div>
+        </ng-template>
+      </ngb-tab>
+      <ngb-tab title="MARC Search" i18n-title id="marc">
+        <ng-template ngbTabContent>
+          <div class="row mt-4">
+            <div class="col-lg-12">
+              <div class="form-inline mt-2" 
+                *ngFor="let q of context.marcSearch.values; let idx = index; trackBy:trackByIdx">
+                <label for="marc-tag-{{idx}}" i18n>Tag</label>
+                <input class="form-control ml-2" size="3" type="text" 
+                  name="marc-tag-{{idx}}" id="{{ idx == 0 ? 'first-marc-tag' : '' }}"
+                  [(ngModel)]="context.marcSearch.tags[idx]"
+                  (keyup.enter)="searchByForm()"/>
+                <label for="marc-subfield-{{idx}}" class="ml-2" i18n>Subfield</label>
+                <input class="form-control ml-2" size="1" type="text" 
+                  name="marc-subfield-{{idx}}"
+                  [(ngModel)]="context.marcSearch.subfields[idx]"
+                  (keyup.enter)="searchByForm()"/>
+                <label for="marc-value-{{idx}}" class="ml-2" i18n>Value</label>
+                <input class="form-control ml-2" type="text" name="marc-value-{{idx}}"
+                  [(ngModel)]="context.marcSearch.values[idx]" 
+                  (keyup.enter)="searchByForm()"/>
+                <button class="btn btn-sm material-icon-button ml-2"
+                  (click)="addMarcSearchRow(idx + 1)">
+                  <span class="material-icons">add_circle_outline</span>
+                </button>
+                <button class="btn btn-sm material-icon-button ml-2"
+                  [disabled]="context.marcSearch.values.length < 2"
+                  (click)="delMarcSearchRow(idx)">
+                  <span class="material-icons">remove_circle_outline</span>
+                </button>
+              </div>
+            </div>
+          </div>
+        </ng-template>
+      </ngb-tab>
+      <ngb-tab title="Browse" i18n-title id="browse">
+        <ng-template ngbTabContent>
+          <div class="row mt-4">
+            <div class="col-lg-12 form-inline">
+              <label for="field-class" i18n>Browse for</label>
+              <select class="form-control ml-2" name="field-class"
+                [(ngModel)]="context.browseSearch.fieldClass">
+                <option i18n value='title'>Title</option>
+                <option i18n value='author'>Author</option>
+                <option i18n value='subject'>Subject</option>
+                <option i18n value='series'>Series</option>
+              </select>
+              <label for="query" class="ml-2"> starting with </label>
+              <input type="text" class="form-control ml-2" 
+                id='browse-term-input' name="query"
+                [(ngModel)]="context.browseSearch.value"
+                (keyup.enter)="searchByForm()"
+                placeholder="Browse for..."/>
+            </div>
+          </div>
+        </ng-template>
+      </ngb-tab>
+    </ngb-tabset>
+  </div>
+  <div class="col-lg-4">
+    <div class="row">
+      <div class="col-lg-12">
+        <div class="card">
+          <div class="card-body">
+            <div class="float-right d-flex">
+              <eg-org-select 
+                (onChange)="orgOnChange($event)"
+                [initialOrg]="context.searchOrg"
+                [placeholder]="'Library'" >
+              </eg-org-select>
+              <button class="btn btn-success mr-1 ml-1" type="button"
+                [disabled]="searchIsActive()"
+                (click)="context.pager.offset=0;searchByForm()" i18n>
+                Search
+              </button>
+              <button class="btn btn-warning mr-1" type="button"
+                [disabled]="searchIsActive()"
+                (click)="context.reset()" i18n>
+                Reset
+              </button>
+            </div>
           </div>
         </div>
       </div>
     </div>
-  </div>
-  <div class="row pt-2" *ngIf="showAdvanced()">
-    <div class="col-lg-2">
-      <select class="form-control"  multiple="true"
-        [(ngModel)]="searchContext.ccvmFilters.item_type">
-        <option value='' i18n>All Item Types</option>
-        <option *ngFor="let itemType of ccvmMap.item_type"
-          value="{{itemType.code()}}">{{itemType.value()}}</option>
-      </select>
-    </div>
-    <div class="col-lg-2">
-      <select class="form-control" multiple="true"
-        [(ngModel)]="searchContext.ccvmFilters.item_form">
-        <option value='' i18n>All Item Forms</option>
-        <option *ngFor="let itemForm of ccvmMap.item_form"
-          value="{{itemForm.code()}}">{{itemForm.value()}}</option>
-      </select>
-    </div>
-    <div class="col-lg-2">
-      <select class="form-control" 
-        [(ngModel)]="searchContext.ccvmFilters.item_lang" multiple="true">
-        <option value='' i18n>All Languages</option>
-        <option *ngFor="let lang of ccvmMap.item_lang"
-          value="{{lang.code()}}">{{lang.value()}}</option>
-      </select>
-    </div>
-    <div class="col-lg-2">
-      <select class="form-control" 
-        [(ngModel)]="searchContext.ccvmFilters.audience" multiple="true">
-        <option value='' i18n>All Audiences</option>
-        <option *ngFor="let audience of ccvmMap.audience"
-          value="{{audience.code()}}">{{audience.value()}}</option>
-      </select>
-    </div>
-    <div class="col-lg-2">
-      <select class="form-control"
-        [(ngModel)]="searchContext.identQueryType">
-        <option i18n value="identifier|isbn">ISBN</option>
-        <option i18n value="identifier|issn">ISSN</option>
-        <option i18n disabled value="cnbrowse">Call Number (Shelf Browse)</option>
-        <option i18n value="identifier|lccn">LCCN</option>
-        <option i18n value="identifier|tcn">TCN</option>
-        <option i18n disabled value="item_barcode">Item Barcode</option>
-      </select>
-    </div>
-    <div class="col-lg-2">
-      <input id='ident-query-input' type="text" class="form-control"
-        [(ngModel)]="searchContext.identQuery"
-        (keyup.enter)="formEnter('ident')"
-        placeholder="Numeric Query..."/>
-    </div>
-  </div>
-  <div class="row pt-2" *ngIf="showAdvanced()">
-    <div class="col-lg-2">
-      <select class="form-control" 
-        [(ngModel)]="searchContext.ccvmFilters.vr_format" multiple="true">
-        <option value='' i18n>All Video Formats</option>
-        <option *ngFor="let vrFormat of ccvmMap.vr_format"
-          value="{{vrFormat.code()}}">{{vrFormat.value()}}</option>
-      </select>
-    </div>
-    <div class="col-lg-2">
-      <select class="form-control" 
-        [(ngModel)]="searchContext.ccvmFilters.bib_level" multiple="true">
-        <option value='' i18n>All Bib Levels</option>
-        <option *ngFor="let bibLevel of ccvmMap.bib_level"
-          value="{{bibLevel.code()}}">{{bibLevel.value()}}</option>
-      </select>
-    </div>
-    <div class="col-lg-2">
-      <select class="form-control" 
-        [(ngModel)]="searchContext.ccvmFilters.lit_form" multiple="true">
-        <option value='' i18n>All Literary Forms</option>
-        <option *ngFor="let litForm of ccvmMap.lit_form"
-          value="{{litForm.code()}}">{{litForm.value()}}</option>
-      </select>
-    </div>
-    <div class="col-lg-2">
-      <i>Copy location filter goes here...</i>
+    <div class="row mt-2">
+      <div class="col-lg-12">
+        <eg-catalog-basket-actions></eg-catalog-basket-actions>
+      </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 52a26f2b2b..711ff90ac7 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,9 +1,11 @@
 import {Component, OnInit, AfterViewInit, Renderer2} from '@angular/core';
+import {Router} 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';
 import {CatalogSearchContext, CatalogSearchState} from '@eg/share/catalog/search-context';
 import {StaffCatalogService} from './catalog.service';
+import {NgbTabset, NgbTabChangeEvent} from '@ng-bootstrap/ng-bootstrap';
 
 @Component({
   selector: 'eg-catalog-search-form',
@@ -12,27 +14,32 @@ import {StaffCatalogService} from './catalog.service';
 })
 export class SearchFormComponent implements OnInit, AfterViewInit {
 
-    searchContext: CatalogSearchContext;
+    context: CatalogSearchContext;
     ccvmMap: {[ccvm: string]: IdlObject[]} = {};
     cmfMap: {[cmf: string]: IdlObject} = {};
-    showAdvancedSearch = false;
+    showSearchFilters = false;
+    copyLocations: IdlObject[];
+    searchTab: string;
 
     constructor(
         private renderer: Renderer2,
+        private router: Router,
         private org: OrgService,
         private cat: CatalogService,
         private staffCat: StaffCatalogService
-    ) {}
+    ) {
+        this.copyLocations = [];
+        //this.searchTab = 'term';
+    }
 
     ngOnInit() {
         this.ccvmMap = this.cat.ccvmMap;
         this.cmfMap = this.cat.cmfMap;
-        this.searchContext = this.staffCat.searchContext;
+        this.context = this.staffCat.searchContext;
 
         // Start with advanced search options open
         // if any filters are active.
-        this.showAdvancedSearch = this.hasAdvancedOptions();
-
+        this.showSearchFilters = this.filtersActive();
     }
 
     ngAfterViewInit() {
@@ -40,83 +47,180 @@ export class SearchFormComponent implements OnInit, AfterViewInit {
         // so they are not available until after the first render.
         // Search context data is extracted synchronously from the URL.
 
-        if (this.searchContext.identQuery) {
-            // Focus identifier query input if identQuery is in progress
-            this.renderer.selectRootElement('#ident-query-input').focus();
-        } else {
-            // Otherwise focus the main query input
-            this.renderer.selectRootElement('#first-query-input').focus();
+        // Avoid changing the tab in the lifecycle hook thread.
+        setTimeout(() => {
+
+            // Apply a tab if none was already specified
+            if (!this.searchTab) {
+                // Assumes that only one type of search will be searchable
+                // at any given time.
+                if (this.context.marcSearch.isSearchable()) {
+                    this.searchTab = 'marc';
+                } else if (this.context.identSearch.isSearchable()) {
+                    this.searchTab = 'ident';
+                } else if (this.context.browseSearch.isSearchable()) {
+                    this.searchTab = 'browse';
+                } else {
+                    // Default tab
+                    this.searchTab = 'term';
+                    this.refreshCopyLocations();
+                }
+            }
+
+            this.focusTabInput();
+        });
+    }
+
+    onTabChange(evt: NgbTabChangeEvent) {
+        this.searchTab = evt.nextId;
+
+        // Focus after tab-change event has a chance to complete
+        // or the tab body and its input won't exist yet and no
+        // elements will be focus-able.
+        setTimeout(() => this.focusTabInput());
+    }
+
+    focusTabInput() {
+        // Select a DOM node to focus when the tab changes.
+        let selector;
+        switch (this.searchTab) {
+            case 'ident':
+                selector = '#ident-query-input';
+                break;
+            case 'marc':
+                selector = '#first-marc-tag';
+                break;
+            case 'browse':
+                selector = '#browse-term-input';
+                break;
+            default:
+                this.refreshCopyLocations();
+                selector = '#first-query-input';
         }
+
+        this.renderer.selectRootElement(selector).focus();
     }
 
     /**
      * Display the advanced/extended search options when asked to
      * or if any advanced options are selected.
      */
-    showAdvanced(): boolean {
-        return this.showAdvancedSearch;
+    showFilters(): boolean {
+        return this.showSearchFilters;
     }
 
-    hasAdvancedOptions(): boolean {
+    toggleFilters() {
+        this.showSearchFilters = !this.showSearchFilters;
+        this.refreshCopyLocations();
+    }
+
+    filtersActive(): boolean {
+
+        if (this.context.termSearch.copyLocations[0] !== '') { return true; }
+
         // ccvm filters may be present without any filters applied.
         // e.g. if filters were applied then removed.
         let show = false;
-        Object.keys(this.searchContext.ccvmFilters).forEach(ccvm => {
-            if (this.searchContext.ccvmFilters[ccvm][0] !== '') {
+        Object.keys(this.context.termSearch.ccvmFilters).forEach(ccvm => {
+            if (this.context.termSearch.ccvmFilters[ccvm][0] !== '') {
                 show = true;
             }
         });
 
-        if (this.searchContext.identQuery) {
-            show = true;
-        }
-
         return show;
     }
 
     orgOnChange = (org: IdlObject): void => {
-        this.searchContext.searchOrg = org;
+        this.context.searchOrg = org;
+        this.refreshCopyLocations();
+    }
+
+    refreshCopyLocations() {
+        if (!this.showFilters()) { return; }
+
+        // TODO: is this how we avoid displaying too many locations?
+        const org = this.context.searchOrg;
+        if (org.id() === this.org.root().id()) { 
+            this.copyLocations = [];
+            return; 
+        }
+
+        this.cat.fetchCopyLocations(org).then(() =>
+            this.copyLocations = this.cat.copyLocations
+        );
+    }
+
+    orgName(orgId: number): string {
+        return this.org.get(orgId).shortname();
     }
 
     addSearchRow(index: number): void {
-        this.searchContext.query.splice(index, 0, '');
-        this.searchContext.fieldClass.splice(index, 0, 'keyword');
-        this.searchContext.joinOp.splice(index, 0, '&&');
-        this.searchContext.matchOp.splice(index, 0, 'contains');
+        this.context.termSearch.query.splice(index, 0, '');
+        this.context.termSearch.fieldClass.splice(index, 0, 'keyword');
+        this.context.termSearch.joinOp.splice(index, 0, '&&');
+        this.context.termSearch.matchOp.splice(index, 0, 'contains');
     }
 
     delSearchRow(index: number): void {
-        this.searchContext.query.splice(index, 1);
-        this.searchContext.fieldClass.splice(index, 1);
-        this.searchContext.joinOp.splice(index, 1);
-        this.searchContext.matchOp.splice(index, 1);
+        this.context.termSearch.query.splice(index, 1);
+        this.context.termSearch.fieldClass.splice(index, 1);
+        this.context.termSearch.joinOp.splice(index, 1);
+        this.context.termSearch.matchOp.splice(index, 1);
+    }
+
+    addMarcSearchRow(index: number): void {
+        this.context.marcSearch.tags.splice(index, 0, '');
+        this.context.marcSearch.subfields.splice(index, 0, '');
+        this.context.marcSearch.values.splice(index, 0, '');
     }
 
-    formEnter(source) {
-        this.searchContext.pager.offset = 0;
+    delMarcSearchRow(index: number): void {
+        this.context.marcSearch.tags.splice(index, 1);
+        this.context.marcSearch.subfields.splice(index, 1);
+        this.context.marcSearch.values.splice(index, 1);
+    }
 
-        switch (source) {
+    searchByForm(): void {
+        this.context.pager.offset = 0; // New search
+
+        // Form search overrides basket display
+        this.context.showBasket = false; 
+
+        switch (this.searchTab) {
+
+            case 'term': // AKA keyword search
+                this.context.marcSearch.reset();
+                this.context.browseSearch.reset();
+                this.context.identSearch.reset();
+                this.context.termSearch.hasBrowseEntry = '';
+                this.context.termSearch.browseEntry = null;
+                this.context.termSearch.fromMetarecord = null;
+                this.context.termSearch.facetFilters = [];
+                this.staffCat.search();
+                break;
 
-            case 'query': // main search form query input
+            case 'ident': 
+                this.context.marcSearch.reset();
+                this.context.browseSearch.reset();
+                this.context.termSearch.reset();
+                this.staffCat.search();
+                break;
 
-                // Be sure a previous ident search does not take precedence
-                // over the newly entered/submitted search query
-                this.searchContext.identQuery = null;
+            case 'marc':
+                this.context.browseSearch.reset();
+                this.context.termSearch.reset();
+                this.context.identSearch.reset();
+                this.staffCat.search();
                 break;
 
-            case 'ident': // identifier query input
-                const iq = this.searchContext.identQuery;
-                const qt = this.searchContext.identQueryType;
-                if (iq) {
-                    // Ident queries ignore search-specific filters.
-                    this.searchContext.reset();
-                    this.searchContext.identQuery = iq;
-                    this.searchContext.identQueryType = qt;
-                }
+            case 'browse':
+                this.context.marcSearch.reset();
+                this.context.termSearch.reset();
+                this.context.identSearch.reset();
+                this.context.browseSearch.pivot = null;
+                this.staffCat.browse();
                 break;
         }
-
-        this.searchByForm();
     }
 
     // https://stackoverflow.com/questions/42322968/angular2-dynamic-input-field-lose-focus-when-input-changes
@@ -124,14 +228,13 @@ export class SearchFormComponent implements OnInit, AfterViewInit {
        return index;
     }
 
-    searchByForm(): void {
-        this.staffCat.search();
-    }
-
     searchIsActive(): boolean {
-        return this.searchContext.searchState === CatalogSearchState.SEARCHING;
+        return this.context.searchState === CatalogSearchState.SEARCHING;
     }
 
+    goToBrowse() {
+        this.router.navigate(['/staff/catalog/browse']);
+    }
 }
 
 
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 92209218e6..0642f493e1 100644
--- a/Open-ILS/src/eg2/src/app/staff/nav.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/nav.component.html
@@ -26,10 +26,7 @@
             <span class="material-icons">assignment</span>
             <span i18n>Search for Copies by Barcode</span>
           </a>
-          <a class="dropdown-item" routerLink="/staff/catalog/search"
-            egAccessKey keyCtx="navbar"
-            keySpec="alt+c" i18n-keySpec
-            keyDesc="Navigate To Catalog" i18n-keyDesc>
+          <a href="/eg/staff/cat/catalog/index" class="dropdown-item">
             <span class="material-icons">search</span>
             <span i18n>Search the Catalog</span>
           </a>
@@ -143,13 +140,11 @@
             Link to experimental Angular staff catalog.
             Leaving disabled until more functionality can be fleshed out.
           -->
-          <!--
           <a class="dropdown-item"
               routerLink="/staff/catalog/search">
             <span class="material-icons">search</span>
             <span i18n>Staff Catalog (Experimental)</span>
           </a>
-          -->
           <a href="/eg/staff/cat/bucket/record/view" class="dropdown-item">
             <span class="material-icons">list_alt</span>
             <span i18n>Record Buckets</span>
diff --git a/Open-ILS/src/eg2/src/app/staff/share/bib-summary/bib-summary.component.ts b/Open-ILS/src/eg2/src/app/staff/share/bib-summary/bib-summary.component.ts
index 78d2653c47..645b56cd78 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/bib-summary/bib-summary.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/bib-summary/bib-summary.component.ts
@@ -14,6 +14,9 @@ export class BibSummaryComponent implements OnInit {
 
     initDone = false;
     expandDisplay = true;
+    @Input() set expand(e: boolean) {
+        this.expandDisplay = e;
+    }
 
     // If provided, the record will be fetched by the component.
     @Input() recordId: number;
diff --git a/Open-ILS/src/eg2/src/app/staff/share/buckets/record-bucket-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/share/buckets/record-bucket-dialog.component.html
index 4399111883..a2c88b8e34 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/buckets/record-bucket-dialog.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/share/buckets/record-bucket-dialog.component.html
@@ -1,7 +1,14 @@
 <ng-template #dialogContent>
   <div class="modal-header bg-info">
-    <h4 class="modal-title" *ngIf="recId" i18n>Add To Record #{{recId}} to Bucket</h4>
-    <h4 class="modal-title" *ngIf="qId" i18n>Add Records from queue #{{qId}} to Bucket</h4>
+    <h4 class="modal-title">
+      <ng-container *ngIf="recIds.length > 0">
+        <span *ngIf="recIds.length == 1" i18n>
+          Add Record #{{recIds[0]}} to Bucket</span>
+        <span *ngIf="recIds.length > 1" i18n>
+          Add {{recIds.length}} Record(s) to Bucket</span>
+      </ng-container>
+      <span *ngIf="qId" i18n>Add Records from queue #{{qId}} to Bucket</span>
+    </h4>
     <button type="button" class="close" 
       i18n-aria-label aria-label="Close" 
       (click)="dismiss('cross_click')">
diff --git a/Open-ILS/src/eg2/src/app/staff/share/buckets/record-bucket-dialog.component.ts b/Open-ILS/src/eg2/src/app/staff/share/buckets/record-bucket-dialog.component.ts
index 2270081148..f1f6f19cfa 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/buckets/record-bucket-dialog.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/buckets/record-bucket-dialog.component.ts
@@ -26,9 +26,10 @@ export class RecordBucketDialogComponent
 
     @Input() bucketType: string;
 
-    recId: number;
-    @Input() set recordId(id: number) {
-        this.recId = id;
+    // Add one or more bib records to bucket by ID.
+    recIds: number[];
+    @Input() set recordId(id: number | number[]) {
+        this.recIds = [].concat(id);
     }
 
     // Add items from a (vandelay) bib queue to a bucket
@@ -46,6 +47,7 @@ export class RecordBucketDialogComponent
         private evt: EventService,
         private auth: AuthService) {
         super(modal); // required for subclassing
+        this.recIds = [];
     }
 
     ngOnInit() {
@@ -98,29 +100,33 @@ export class RecordBucketDialogComponent
                 // requires the bucket name.
                 bucket.id(bktId);
                 this.buckets.push(bucket);
-
                 this.addToBucket(bktId);
             }
         });
     }
 
-    // Add the record to the selected existing bucket
     addToBucket(id: number) {
-        if (this.recId) {
+        if (this.recIds.length > 0) {
             this.addRecordToBucket(id);
         } else if (this.qId) {
             this.addQueueToBucket(id);
         }
     }
 
+    // Add the record(s) to the bucket with provided ID.
     addRecordToBucket(bucketId: number) {
-        const item = this.idl.create('cbrebi');
-        item.bucket(bucketId);
-        item.target_biblio_record_entry(this.recId);
+        const items = [];
+        this.recIds.forEach(recId => {
+            const item = this.idl.create('cbrebi');
+            item.bucket(bucketId);
+            item.target_biblio_record_entry(recId);
+            items.push(item);
+        });
+
         this.net.request(
             'open-ils.actor',
             'open-ils.actor.container.item.create',
-            this.auth.token(), 'biblio', item
+            this.auth.token(), 'biblio', items
         ).subscribe(resp => {
             const evt = this.evt.parse(resp);
             if (evt) {
diff --git a/Open-ILS/src/eg2/src/app/staff/share/hold.service.ts b/Open-ILS/src/eg2/src/app/staff/share/hold.service.ts
new file mode 100644
index 0000000000..3d89c20523
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/share/hold.service.ts
@@ -0,0 +1,143 @@
+/**
+ * Common code for mananging holdings
+ */
+import {Injectable, EventEmitter} from '@angular/core';
+import {Observable} from 'rxjs/Observable';
+import {map} from 'rxjs/operators/map';
+import {mergeMap} from 'rxjs/operators/mergeMap';
+import {IdlObject} from '@eg/core/idl.service';
+import {NetService} from '@eg/core/net.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {EventService, EgEvent} from '@eg/core/event.service';
+import {AuthService} from '@eg/core/auth.service';
+import {BibRecordService, BibRecordSummary} 
+    from '@eg/share/catalog/bib-record.service';
+
+// Response from a place-holds API call.
+export interface HoldRequestResult {
+    success: boolean;
+    holdId?: number;
+    evt?: EgEvent;
+};
+
+// Values passed to the place-holds API call.
+export interface HoldRequest {
+    holdType: string;
+    holdTarget: number;
+    recipient: number;
+    requestor: number;
+    pickupLib: number;
+    override?: boolean;
+    notifyEmail?: boolean;
+    notifyPhone?: string;
+    notifySms?: string;
+    smsCarrier?: string;
+    thawDate?: string; // ISO date
+    frozen?: boolean;
+    holdableFormats?: {[target: number]: string};
+    result?: HoldRequestResult
+};
+
+// A fleshed hold request target object containing whatever data is
+// available for each hold type / target.  E.g. a TITLE hold will
+// not have a value for 'volume', but a COPY hold will, since all
+// copies have volumes.  Every HoldRequestTarget will have a bibId and
+// bibSummary.  Some values come directly from the API call, others
+// applied locally.
+export interface HoldRequestTarget {
+    target: number;
+    metarecord?: IdlObject;
+    bibrecord?: IdlObject;
+    bibId?: number;
+    bibSummary?: BibRecordSummary;
+    part?: IdlObject;
+    volume?: IdlObject;
+    copy?: IdlObject;
+    issuance?: IdlObject;
+    metarecord_filters?: any;
+}
+
+ at Injectable()
+export class HoldService {
+
+    constructor(
+        private evt: EventService,
+        private net: NetService,
+        private pcrud: PcrudService,
+        private auth: AuthService,
+        private bib: BibRecordService,
+    ) {}
+
+    placeHold(request: HoldRequest): Observable<HoldRequest> {
+        
+        let method = 'open-ils.circ.holds.test_and_create.batch';
+        if (request.override) { method = method + '.override'; }
+
+        return this.net.request(
+            'open-ils.circ', method, this.auth.token(), {
+                patronid:       request.recipient,
+                pickup_lib:     request.pickupLib,
+                hold_type:      request.holdType,
+                email_notify:   request.notifyEmail,
+                phone_notify:   request.notifyPhone,
+                thaw_date:      request.thawDate,
+                frozen:         request.frozen,
+                sms_notify:     request.notifySms,
+                sms_carrier:    request.smsCarrier,
+                holdable_formats_map: request.holdableFormats
+            },
+            [request.holdTarget]
+        ).pipe(map(
+            resp => {
+                let result = resp.result;
+                const holdResult: HoldRequestResult = {success: true};
+
+                // API can return an ID, an array of events, or a hash
+                // of info.
+
+                if (Number(result) > 0) {
+                    // On success, the API returns the hold ID.
+                    holdResult.holdId = result;
+                    console.debug(`Hold successfully placed ${result}`);
+
+                } else {
+                    holdResult.success = false;
+                    console.info('Hold request failed: ', result);
+
+                    if (Array.isArray(result)) { result = result[0]; }
+
+                    if (this.evt.parse(result)) {
+                        holdResult.evt = this.evt.parse(result);
+                    } else {
+                        holdResult.evt = this.evt.parse(result.last_event);
+                    }
+                }
+
+                request.result = holdResult;
+                return request;
+            }
+        ));
+    }
+
+    getHoldTargetMeta(holdType: string, holdTarget: number | number[], 
+        orgId?: number): Observable<HoldRequestTarget> {
+
+        const targetIds = [].concat(holdTarget);
+
+        return this.net.request(
+            'open-ils.circ',
+            'open-ils.circ.hold.get_metadata',
+            holdType, targetIds, orgId
+        ).pipe(mergeMap(meta => {
+            const target: HoldRequestTarget = meta;
+            target.bibId = target.bibrecord.id();
+
+            return this.bib.getBibSummary(target.bibId)
+            .pipe(map(sum => {
+                target.bibSummary = sum;
+                return target;
+            }));
+        }));
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings.service.ts b/Open-ILS/src/eg2/src/app/staff/share/holdings.service.ts
index d2596b5527..cf58409982 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/holdings.service.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/holdings.service.ts
@@ -3,6 +3,7 @@
  */
 import {Injectable, EventEmitter} from '@angular/core';
 import {NetService} from '@eg/core/net.service';
+import {AnonCacheService} from '@eg/share/util/anon-cache.service';
 
 interface NewVolumeData {
     owner: number;
@@ -12,7 +13,10 @@ interface NewVolumeData {
 @Injectable()
 export class HoldingsService {
 
-    constructor(private net: NetService) {}
+    constructor(
+        private net: NetService,
+        private anonCache: AnonCacheService
+    ) {}
 
     // Open the holdings editor UI in a new browser window/tab.
     spawnAddHoldingsUi(
@@ -30,28 +34,21 @@ export class HoldingsService {
 
         if (raw.length === 0) { raw.push({}); }
 
-        this.net.request(
-            'open-ils.actor',
-            'open-ils.actor.anon_cache.set_value',
-            null, 'edit-these-copies', {
-                record_id: recordId,
-                raw: raw,
-                hide_vols : false,
-                hide_copies : false
+        this.anonCache.setItem(null, 'edit-these-copies', {
+            record_id: recordId,
+            raw: raw,
+            hide_vols : false,
+            hide_copies : false
+        }).then(key => {
+            if (!key) {
+                console.error('Could not create holds cache key!');
+                return;
             }
-        ).subscribe(
-            key => {
-                if (!key) {
-                    console.error('Could not create holds cache key!');
-                    return;
-                }
-                setTimeout(() => {
-                    const url = `/eg/staff/cat/volcopy/${key}`;
-                    window.open(url, '_blank');
-                });
-            }
-        );
+            setTimeout(() => {
+                const url = `/eg/staff/cat/volcopy/${key}`;
+                window.open(url, '_blank');
+            });
+        });
     }
-
 }
 
diff --git a/Open-ILS/src/eg2/src/styles.css b/Open-ILS/src/eg2/src/styles.css
index b87ad78acf..cf10855253 100644
--- a/Open-ILS/src/eg2/src/styles.css
+++ b/Open-ILS/src/eg2/src/styles.css
@@ -128,10 +128,7 @@ h5 {font-size: .95rem}
 .common-form label {
   font-weight: bold;
 }
-.common-form input[type="checkbox"] {
-  /* BS adds a negative left margin */
-  margin-left: 0px;
-}
+
 .common-form.striped-even .row:nth-child(even) {
   background-color: rgba(0,0,0,.03);
   border-top: 1px solid rgba(0,0,0,.125);
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Actor/Container.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Actor/Container.pm
index badb13482d..abeeea2e9b 100644
--- a/Open-ILS/src/perlmods/lib/OpenILS/Application/Actor/Container.pm
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Actor/Container.pm
@@ -350,7 +350,8 @@ sub item_create {
     return $e->die_event unless $e->checkauth;
     my $items = (ref $item eq 'ARRAY') ? $item : [$item];
 
-    my ( $bucket, $evt ) = $apputils->fetch_container_e($e, $item->bucket, $class);
+    my ( $bucket, $evt ) = 
+        $apputils->fetch_container_e($e, $items->[0]->bucket, $class);
     return $evt if $evt;
 
     if( $bucket->owner ne $e->requestor->id ) {
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Holds.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Holds.pm
index dcbe7dda4f..77868503f3 100644
--- a/Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Holds.pm
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Holds.pm
@@ -3502,12 +3502,13 @@ sub find_hold_mvr {
     my $volume;
     my $issuance;
     my $part;
+    my $metarecord;
     my $no_mvr = $args->{suppress_mvr};
 
     if( $hold->hold_type eq OILS_HOLD_TYPE_METARECORD ) {
-        my $mr = $e->retrieve_metabib_metarecord($hold->target)
+        $metarecord = $e->retrieve_metabib_metarecord($hold->target)
             or return $e->event;
-        $tid = $mr->master_record;
+        $tid = $metarecord->master_record;
 
     } elsif( $hold->hold_type eq OILS_HOLD_TYPE_TITLE ) {
         $tid = $hold->target;
@@ -3553,7 +3554,8 @@ sub find_hold_mvr {
 
     # TODO return metarcord mvr for M holds
     my $title = $e->retrieve_biblio_record_entry($tid);
-    return ( ($no_mvr) ? undef : $U->record_to_mvr($title), $volume, $copy, $issuance, $part, $title );
+    return ( ($no_mvr) ? undef : $U->record_to_mvr($title), 
+        $volume, $copy, $issuance, $part, $title, $metarecord);
 }
 
 __PACKAGE__->register_method(
@@ -4505,4 +4507,89 @@ sub copy_has_holds_count {
     return 0;
 }
 
+__PACKAGE__->register_method(
+    method        => "hold_metadata",
+    api_name      => "open-ils.circ.hold.get_metadata",
+    authoritative => 1,
+    stream => 1,
+    signature     => {
+        desc => q/
+            Returns a stream of objects containing whatever bib, 
+            volume, etc. data is available to the specific hold 
+            type and target.
+        /,
+        params => [
+            {desc => 'Hold Type', type => 'string'},
+            {desc => 'Hold Target(s)', type => 'number or array'},
+            {desc => 'Context org unit (optional)', type => 'number'}
+        ],
+        return => {
+            desc => q/
+                Stream of hold metadata objects.
+            /,
+            type => 'object'
+        }
+    }
+);
+
+sub hold_metadata {
+    my ($self, $client, $hold_type, $hold_targets, $org_id) = @_;
+
+    $hold_targets = [$hold_targets] unless ref $hold_targets;
+
+    my $e = new_editor();
+    for my $target (@$hold_targets) {
+
+        # create a dummy hold for find_hold_mvr
+        my $hold = Fieldmapper::action::hold_request->new;
+        $hold->hold_type($hold_type);
+        $hold->target($target);
+
+        my (undef, $volume, $copy, $issuance, $part, $bre, $metarecord) = 
+            find_hold_mvr($e, $hold, {suppress_mvr => 1});
+
+        $bre->clear_marc; # avoid bulk
+
+        my $meta = {
+            target => $target,
+            copy => $copy,
+            volume => $volume,
+            issuance => $issuance,
+            part => $part,
+            bibrecord => $bre,
+            metarecord => $metarecord,
+            metarecord_filters => {}
+        };
+
+        # If this is a bib hold or metarecord hold, also return the
+        # available set of MR filters (AKA "Holdable Formats") for the
+        # hold.  For bib holds these may be used to upgrade the hold
+        # from a bib to metarecord hold.
+        if ($hold_type eq 'T') {
+            my $map = $e->search_metabib_metarecord_source_map(
+                {source => $meta->{bibrecord}->id})->[0];
+
+            if ($map) {
+                $meta->{metarecord} = 
+                    $e->retrieve_metabib_metarecord($map->metarecord);
+            }
+        }
+
+        if ($meta->{metarecord}) {
+
+            my ($filters) = 
+                $self->method_lookup('open-ils.circ.mmr.holds.filters')
+                    ->run($meta->{metarecord}->id, $org_id);
+
+            if ($filters) {
+                $meta->{metarecord_filters} = $filters->{metarecord};
+            }
+        }
+
+        $client->respond($meta);
+    }
+
+    return undef;
+}
+
 1;
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Search.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Search.pm
index 9ebb6da3d4..78d4a4e2db 100644
--- a/Open-ILS/src/perlmods/lib/OpenILS/Application/Search.pm
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Search.pm
@@ -16,6 +16,7 @@ use OpenILS::Application::Search::Z3950;
 use OpenILS::Application::Search::Zips;
 use OpenILS::Application::Search::CNBrowse;
 use OpenILS::Application::Search::Serial;
+use OpenILS::Application::Search::Browse;
 
 
 use OpenILS::Application::AppUtils;
@@ -34,6 +35,7 @@ sub initialize {
 
 sub child_init {
     OpenILS::Application::Search::Z3950->child_init;
+    OpenILS::Application::Search::Browse->child_init;
 }
     
 
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Search/Browse.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Search/Browse.pm
new file mode 100644
index 0000000000..803f08b054
--- /dev/null
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Search/Browse.pm
@@ -0,0 +1,392 @@
+package OpenILS::Application::Search::Browse;
+use base qw/OpenILS::Application/;
+use strict; use warnings;
+
+# Most of this code is copied directly from ../../WWW/EGCatLoader/Browse.pm
+# and modified to be API-compatible.
+
+use Digest::MD5 qw/md5_hex/;
+use Apache2::Const -compile => qw/OK/;
+use MARC::Record;
+use List::Util qw/first/;
+
+use OpenSRF::Utils::Logger qw/$logger/;
+use OpenILS::Utils::CStoreEditor qw/:funcs/;
+use OpenILS::Utils::Fieldmapper;
+use OpenILS::Utils::Normalize qw/search_normalize/;
+use OpenILS::Application::AppUtils;
+use OpenSRF::Utils::JSON;
+use OpenSRF::Utils::Cache;
+use OpenSRF::Utils::SettingsClient;
+
+my $U = 'OpenILS::Application::AppUtils';
+my $browse_cache;
+my $browse_timeout;
+
+sub initialize { return 1; }
+
+sub child_init {
+    if (not defined $browse_cache) {
+        my $conf = new OpenSRF::Utils::SettingsClient;
+
+        $browse_timeout = $conf->config_value(
+            "apps", "open-ils.search", "app_settings", "cache_timeout"
+        ) || 300;
+        $browse_cache = new OpenSRF::Utils::Cache("global");
+    }
+}
+
+__PACKAGE__->register_method(
+    method      => "browse",
+    api_name    => "open-ils.search.browse.staff",
+    stream      => 1,
+    signature   => {
+        desc    => q/Bib + authority browse/,
+        params  => [{
+            params => {
+                name => 'Browse Parameters',
+                desc => q/Hash of arguments:
+                    browse_class
+                        -- title, author, subject, series
+                    term
+                        -- term to browse for
+                    org_unit
+                        -- context org unit ID
+                    copy_location_group
+                        -- copy location filter ID
+                    limit
+                        -- return this many results
+                    pivot
+                        -- browse entry ID
+                /
+            }
+        }]
+    }
+);
+
+__PACKAGE__->register_method(
+    method      => "browse",
+    api_name    => "open-ils.search.browse",
+    stream      => 1,
+    signature   => {
+        desc    => q/See open-ils.search.browse.staff/
+    }
+);
+
+sub browse {
+    my ($self, $client, $params) = @_;
+
+    $params->{staff} = 1 if $self->api_name =~ /staff/;
+    my ($cache_key, @params) = prepare_browse_parameters($params);
+
+    my $results = $browse_cache->get_cache($cache_key);
+
+    if (!$results) {
+        $results = 
+            new_editor()->json_query({from => ['metabib.browse', @params]});
+        if ($results) {
+            $browse_cache->put_cache($cache_key, $results, $browse_timeout);
+        }
+    }
+
+    my ($warning, $alternative) = 
+        leading_article_test($params->{browse_class}, $params->{term});
+
+    for my $result (@$results) {
+        $result->{leading_article_warning} = $warning;
+        $result->{leading_article_alternative} = $alternative;
+        flesh_browse_results([$result]);
+        $client->respond($result);
+    }
+
+    return undef;
+}
+
+
+# Returns cache key and a list of parameters for DB proc metabib.browse().
+sub prepare_browse_parameters {
+    my ($params) = @_;
+
+    no warnings 'uninitialized';
+
+    my @params = (
+        $params->{browse_class},
+        $params->{term},
+        $params->{org_unit},
+        $params->{copy_location_group},
+        $params->{staff} ? 't' : 'f',
+        $params->{pivot},
+        $params->{limit} || 10
+    );
+
+    return (
+        "oils_browse_" . md5_hex(OpenSRF::Utils::JSON->perl2JSON(\@params)),
+        @params
+    );
+}
+
+sub leading_article_test {
+    my ($browse_class, $bterm) = @_;
+
+    my $flag_name = "opac.browse.warnable_regexp_per_class";
+    my $flag = new_editor()->retrieve_config_global_flag($flag_name);
+
+    return unless $flag->enabled eq 't';
+
+    my $map;
+    my $warning;
+    my $alternative;
+
+    eval { $map = OpenSRF::Utils::JSON->JSON2perl($flag->value); };
+    if ($@) {
+        $logger->warn("cgf '$flag_name' enabled but value is invalid JSON? $@");
+        return;
+    }
+
+    # Don't crash over any of the things that could go wrong in here:
+    eval {
+        if ($map->{$browse_class}) {
+            if ($bterm =~ qr/$map->{$browse_class}/i) {
+                $warning = 1;
+                ($alternative = $bterm) =~ s/$map->{$browse_class}//;
+            }
+        }
+    };
+
+    if ($@) {
+        $logger->warn("cgf '$flag_name' has valid JSON in value, but: $@");
+    }
+
+    return ($warning, $alternative);
+}
+
+# flesh_browse_results() attaches data from authority records. It
+# changes $results and returns 1 for success, undef for failure
+# $results must be an arrayref of result rows from the DB's metabib.browse()
+sub flesh_browse_results {
+    my ($results) = @_;
+
+    for my $authority_field_name ( qw/authorities sees/ ) {
+        for my $r (@$results) {
+            # Turn comma-seprated strings of numbers in "authorities" and "sees"
+            # columns into arrays.
+            if ($r->{$authority_field_name}) {
+                $r->{$authority_field_name} = [split /,/, $r->{$authority_field_name}];
+            } else {
+                $r->{$authority_field_name} = [];
+            }
+            $r->{"list_$authority_field_name"} = [ @{$r->{$authority_field_name} } ];
+        }
+
+        # Group them in one arrray, not worrying about dupes because we're about
+        # to use them in an IN () comparison in a SQL query.
+        my @auth_ids = map { @{$_->{$authority_field_name}} } @$results;
+
+        if (@auth_ids) {
+            # Get all linked authority records themselves
+            my $linked = new_editor()->json_query({
+                select => {
+                    are => [qw/id marc control_set/],
+                    aalink => [{column => "target", transform => "array_agg",
+                        aggregate => 1}]
+                },
+                from => {
+                    are => {
+                        aalink => {
+                            type => "left",
+                            fkey => "id", field => "source"
+                        }
+                    }
+                },
+                where => {"+are" => {id => \@auth_ids}}
+            }) or return;
+
+            map_authority_headings_to_results(
+                $linked, $results, \@auth_ids, $authority_field_name);
+        }
+    }
+
+    return 1;
+}
+
+sub map_authority_headings_to_results {
+    my ($linked, $results, $auth_ids, $authority_field_name) = @_;
+
+    # Use the linked authority records' control sets to find and pick
+    # out non-main-entry headings. Build the headings and make a
+    # combined data structure for the template's use.
+    my %linked_headings_by_auth_id = map {
+        $_->{id} => find_authority_headings_and_notes($_)
+    } @$linked;
+
+    # Avoid sending the full MARC blobs to the caller.
+    delete $_->{marc} for @$linked;
+
+    # Graft this authority heading data onto our main result set at the
+    # named column, either "authorities" or "sees".
+    foreach my $row (@$results) {
+        $row->{$authority_field_name} = [
+            map { $linked_headings_by_auth_id{$_} } @{$row->{$authority_field_name}}
+        ];
+    }
+
+    # Get linked-bib counts for each of those authorities, and put THAT
+    # information into place in the data structure.
+    my $counts = new_editor()->json_query({
+        select => {
+            abl => [
+                {column => "id", transform => "count",
+                    alias => "count", aggregate => 1},
+                "authority"
+            ]
+        },
+        from => {abl => {}},
+        where => {
+            "+abl" => {
+                authority => [
+                    @$auth_ids,
+                    $U->unique_unnested_numbers(map { $_->{target} } @$linked)
+                ]
+            }
+        }
+    }) or return;
+
+    my %auth_counts = map { $_->{authority} => $_->{count} } @$counts;
+
+    # Soooo nesty!  We look for places where we'll need a count of bibs
+    # linked to an authority record, and put it there for the template to find.
+    for my $row (@$results) {
+        for my $auth (@{$row->{$authority_field_name}}) {
+            if ($auth->{headings}) {
+                for my $outer_heading (@{$auth->{headings}}) {
+                    for my $heading_blob (@{(values %$outer_heading)[0]}) {
+                        if ($heading_blob->{target}) {
+                            $heading_blob->{target_count} =
+                                $auth_counts{$heading_blob->{target}};
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
+
+
+# TOOD consider locale-aware caching
+sub get_acsaf {
+    my $control_set = shift;
+
+    my $acs = new_editor()
+        ->search_authority_control_set_authority_field(
+            {control_set => $control_set}
+        );
+
+    return {  map { $_->id => $_ } @$acs };
+}
+
+sub find_authority_headings_and_notes {
+    my ($row) = @_;
+
+    my $acsaf_table = get_acsaf($row->{control_set});
+
+    $row->{headings} = [];
+
+    my $record;
+    eval {
+        $record = new_from_xml MARC::Record($row->{marc});
+    };
+
+    if ($@) {
+        $logger->warn("Problem with MARC from authority record #" .
+            $row->{id} . ": $@");
+        return $row;    # We're called in map(), so we must move on without
+                        # a fuss.
+    }
+
+    extract_public_general_notes($record, $row);
+
+    # extract headings from the main authority record along with their
+    # types
+    my $parsed_headings = new_editor()->json_query({
+        from => ['authority.extract_headings', $row->{marc}]
+    });
+    my %heading_type_map = ();
+    if ($parsed_headings) {
+        foreach my $h (@$parsed_headings) {
+            $heading_type_map{$h->{normalized_heading}} =
+                $h->{purpose} eq 'variant' ? 'variant' :
+                $h->{purpose} eq 'related' ? $h->{related_type} :
+                '';
+        }
+    }
+
+    # By applying grep in this way, we get acsaf objects that *have* and
+    # therefore *aren't* main entries, which is what we want.
+    foreach my $acsaf (values(%$acsaf_table)) {
+        my @fields = $record->field($acsaf->tag);
+        my %sf_lookup = map { $_ => 1 } split("", $acsaf->display_sf_list);
+        my @headings;
+
+        foreach my $field (@fields) {
+            my $h = { main_entry => ( $acsaf->main_entry ? 0 : 1 ),
+                      heading => get_authority_heading($field, \%sf_lookup, $acsaf->joiner) };
+
+            my $norm = search_normalize($h->{heading});
+            if (exists $heading_type_map{$norm}) {
+                $h->{type} = $heading_type_map{$norm};
+            }
+            # XXX I was getting "target" from authority.authority_linking, but
+            # that makes no sense: that table can only tell you that one
+            # authority record as a whole points at another record.  It does
+            # not record when a specific *field* in one authority record
+            # points to another record (not that it makes much sense for
+            # one authority record to have links to multiple others, but I can't
+            # say there definitely aren't cases for that).
+            $h->{target} = $2
+                if ($field->subfield('0') || "") =~ /(^|\))(\d+)$/;
+
+            # The target is the row id if this is a main entry...
+            $h->{target} = $row->{id} if $h->{main_entry};
+
+            push @headings, $h;
+        }
+
+        push @{$row->{headings}}, {$acsaf->id => \@headings} if @headings;
+    }
+
+    return $row;
+}
+
+
+# Break out any Public General Notes (field 680) for display. These are
+# sometimes (erroneously?) called "scope notes." I say erroneously,
+# tentatively, because LoC doesn't seem to document a "scope notes"
+# field for authority records, while it does so for classification
+# records, which are something else. But I am not a librarian.
+sub extract_public_general_notes {
+    my ($record, $row) = @_;
+
+    # Make a list of strings, each string being a concatentation of any
+    # subfields 'i', '5', or 'a' from one field 680, in order of appearance.
+    $row->{notes} = [
+        map {
+            join(
+                " ",
+                map { $_->[1] } grep { $_->[0] =~ /[i5a]/ } $_->subfields
+            )
+        } $record->field('680')
+    ];
+}
+
+sub get_authority_heading {
+    my ($field, $sf_lookup, $joiner) = @_;
+
+    $joiner ||= ' ';
+
+    return join(
+        $joiner,
+        map { $_->[1] } grep { $sf_lookup->{$_->[0]} } $field->subfields
+    );
+}
+
+1;

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

Summary of changes:
 Open-ILS/examples/fm_IDL.xml                       |  21 +-
 Open-ILS/src/eg2/src/app/core/org.service.ts       |   8 +-
 Open-ILS/src/eg2/src/app/core/perm.service.ts      |   3 +-
 .../src/eg2/src/app/core/server-store.service.ts   |   2 +-
 .../eg2/src/app/share/catalog/basket.service.ts    | 103 ++++
 .../src/app/share/catalog/bib-record.service.ts    | 104 +++-
 .../src/app/share/catalog/catalog-common.module.ts |   6 +-
 .../src/app/share/catalog/catalog-url.service.ts   | 234 ++++++---
 .../eg2/src/app/share/catalog/catalog.service.ts   | 206 +++++++-
 .../eg2/src/app/share/catalog/search-context.ts    | 460 ++++++++++++-----
 .../share/date-select/date-select.component.html   |   3 +-
 .../app/share/date-select/date-select.component.ts |   6 +-
 .../src/app/share/fm-editor/fm-editor.component.ts |  11 +-
 .../src/app/share/grid/grid-body.component.html    |   8 +-
 .../eg2/src/app/share/grid/grid-body.component.ts  |   7 +
 .../src/app/share/grid/grid-header.component.html  |   8 +-
 .../src/eg2/src/app/share/grid/grid.component.ts   |   3 +
 Open-ILS/src/eg2/src/app/share/grid/grid.ts        |   1 +
 .../eg2/src/app/share/util/anon-cache.service.ts   |  59 +++
 .../staff/catalog/basket-actions.component.html    |  28 ++
 .../app/staff/catalog/basket-actions.component.ts  | 106 ++++
 .../src/app/staff/catalog/browse.component.html    |   5 +
 .../eg2/src/app/staff/catalog/browse.component.ts  |  28 ++
 .../staff/catalog/browse/results.component.html    |  84 ++++
 .../app/staff/catalog/browse/results.component.ts  | 139 ++++++
 .../eg2/src/app/staff/catalog/catalog.component.ts |   9 +-
 .../eg2/src/app/staff/catalog/catalog.module.ts    |  18 +-
 .../eg2/src/app/staff/catalog/catalog.service.ts   |  22 +
 .../src/app/staff/catalog/hold/hold.component.html | 302 +++++++++++
 .../src/app/staff/catalog/hold/hold.component.ts   | 401 +++++++++++++++
 .../staff/catalog/record/actions.component.html    |   6 +-
 .../app/staff/catalog/record/copies.component.html |   1 +
 .../record/part-merge-dialog.component.html}       |  16 +-
 .../catalog/record/part-merge-dialog.component.ts  |  70 +++
 .../app/staff/catalog/record/parts.component.html  |  22 +
 .../app/staff/catalog/record/parts.component.ts    | 123 +++++
 .../app/staff/catalog/record/record.component.html |  26 +-
 .../app/staff/catalog/record/record.component.ts   |  59 ++-
 .../eg2/src/app/staff/catalog/resolver.service.ts  |  24 +-
 .../app/staff/catalog/result/facets.component.ts   |   4 +-
 .../app/staff/catalog/result/record.component.css  |  15 +
 .../app/staff/catalog/result/record.component.html |  87 ++--
 .../app/staff/catalog/result/record.component.ts   |  67 ++-
 .../staff/catalog/result/results.component.html    |  85 +++-
 .../app/staff/catalog/result/results.component.ts  |  83 +++-
 .../eg2/src/app/staff/catalog/routing.module.ts    |  11 +-
 .../app/staff/catalog/search-form.component.css    |  14 +-
 .../app/staff/catalog/search-form.component.html   | 552 ++++++++++++---------
 .../src/app/staff/catalog/search-form.component.ts | 208 ++++++--
 Open-ILS/src/eg2/src/app/staff/nav.component.html  |  16 +-
 Open-ILS/src/eg2/src/app/staff/nav.component.ts    |  14 +
 Open-ILS/src/eg2/src/app/staff/resolver.service.ts |   3 +-
 .../share/bib-summary/bib-summary.component.ts     |   3 +
 .../buckets/record-bucket-dialog.component.html    |  11 +-
 .../buckets/record-bucket-dialog.component.ts      |  26 +-
 .../src/eg2/src/app/staff/share/hold.service.ts    | 142 ++++++
 .../eg2/src/app/staff/share/holdings.service.ts    |  41 +-
 Open-ILS/src/eg2/src/styles.css                    |   5 +-
 .../lib/OpenILS/Application/Actor/Container.pm     |   3 +-
 .../perlmods/lib/OpenILS/Application/Circ/Holds.pm |  93 +++-
 .../src/perlmods/lib/OpenILS/Application/Search.pm |   2 +
 .../EGCatLoader => Application/Search}/Browse.pm   | 503 +++++++++----------
 Open-ILS/src/sql/Pg/002.schema.config.sql          |   2 +-
 Open-ILS/src/sql/Pg/950.data.seed-values.sql       |  19 +-
 .../1151.data.ang-catalog-preview-setting.sql      |  23 +
 Open-ILS/src/templates/staff/navbar.tt2            |   7 +-
 .../web/js/ui/default/staff/services/navbar.js     |   9 +-
 .../web/js/ui/default/staff/services/startup.js    |   1 +
 .../Client/angular-catalog-exp.adoc                |  26 +
 69 files changed, 3843 insertions(+), 974 deletions(-)
 create mode 100644 Open-ILS/src/eg2/src/app/share/catalog/basket.service.ts
 create mode 100644 Open-ILS/src/eg2/src/app/share/util/anon-cache.service.ts
 create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/basket-actions.component.html
 create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/basket-actions.component.ts
 create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/browse.component.html
 create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/browse.component.ts
 create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/browse/results.component.html
 create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/browse/results.component.ts
 create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.html
 create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.ts
 copy Open-ILS/src/eg2/src/app/{share/dialog/prompt.component.html => staff/catalog/record/part-merge-dialog.component.html} (51%)
 create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/record/part-merge-dialog.component.ts
 create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/record/parts.component.html
 create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/record/parts.component.ts
 create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.css
 create mode 100644 Open-ILS/src/eg2/src/app/staff/share/hold.service.ts
 copy Open-ILS/src/perlmods/lib/OpenILS/{WWW/EGCatLoader => Application/Search}/Browse.pm (62%)
 create mode 100644 Open-ILS/src/sql/Pg/upgrade/1151.data.ang-catalog-preview-setting.sql
 create mode 100644 docs/RELEASE_NOTES_NEXT/Client/angular-catalog-exp.adoc


hooks/post-receive
-- 
Evergreen ILS


More information about the open-ils-commits mailing list