From 58814097786c080b46ce3cd5d54f8bc21d23297f Mon Sep 17 00:00:00 2001
From: Bill Erickson <berickxx@gmail.com>
Date: Mon, 11 Mar 2019 11:37:30 -0400
Subject: [PATCH] LP1819498 Angular staff catalog call number browse

Implements call number browse as a vertical paged set, similiar to the
browse UI and search results.

Signed-off-by: Bill Erickson <berickxx@gmail.com>
Signed-off-by: Dan Wells <dbw2@calvin.edu>
---
 .../src/app/share/accesskey/accesskey.service.ts   |   4 +-
 .../src/app/share/catalog/catalog-url.service.ts   |  10 ++
 .../eg2/src/app/share/catalog/catalog.service.ts   |  11 ++
 .../eg2/src/app/share/catalog/search-context.ts    |  18 +++
 .../src/app/staff/catalog/browse.component.html    |   2 +-
 .../eg2/src/app/staff/catalog/browse.component.ts  |   8 +-
 .../app/staff/catalog/browse/results.component.ts  |  23 ++--
 .../eg2/src/app/staff/catalog/catalog.component.ts |   3 -
 .../eg2/src/app/staff/catalog/catalog.module.ts    |   6 +-
 .../eg2/src/app/staff/catalog/catalog.service.ts   |  12 +-
 .../src/app/staff/catalog/cnbrowse.component.html  |   5 +
 .../src/app/staff/catalog/cnbrowse.component.ts    |  22 ++++
 .../staff/catalog/cnbrowse/results.component.html  |  52 +++++++++
 .../staff/catalog/cnbrowse/results.component.ts    | 122 +++++++++++++++++++++
 .../eg2/src/app/staff/catalog/resolver.service.ts  |  10 +-
 .../app/staff/catalog/result/record.component.html |   9 ++
 .../app/staff/catalog/result/record.component.ts   |   7 ++
 .../eg2/src/app/staff/catalog/routing.module.ts    |  16 ++-
 .../app/staff/catalog/search-form.component.html   |  16 +++
 .../src/app/staff/catalog/search-form.component.ts |  22 +++-
 20 files changed, 337 insertions(+), 41 deletions(-)
 create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/cnbrowse.component.html
 create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/cnbrowse.component.ts
 create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/cnbrowse/results.component.html
 create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/cnbrowse/results.component.ts

diff --git a/Open-ILS/src/eg2/src/app/share/accesskey/accesskey.service.ts b/Open-ILS/src/eg2/src/app/share/accesskey/accesskey.service.ts
index 51dda57940..0b6302f81d 100644
--- a/Open-ILS/src/eg2/src/app/share/accesskey/accesskey.service.ts
+++ b/Open-ILS/src/eg2/src/app/share/accesskey/accesskey.service.ts
@@ -25,7 +25,9 @@ export class AccessKeyService {
      * string.  For example:  Control and 't' becomes 'ctrl+t'.
      */
     compressKeys(evt: KeyboardEvent): string {
-
+        if (!evt.key) {
+            return null;
+        }
         let s = '';
         if (evt.ctrlKey || evt.metaKey) { s += 'ctrl+'; }
         if (evt.altKey) { s += 'alt+'; }
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 8f326adead..a59b7fc2db 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
@@ -122,6 +122,11 @@ export class CatalogUrlService {
             }
         }
 
+        if (context.cnBrowseSearch.isSearchable()) {
+            params.cnBrowseTerm = context.cnBrowseSearch.value;
+            params.cnBrowsePage = context.cnBrowseSearch.offset;
+        }
+
         return params;
     }
 
@@ -185,6 +190,11 @@ export class CatalogUrlService {
             }
         }
 
+        if (params.get('cnBrowseTerm')) {
+            context.cnBrowseSearch.value = params.get('cnBrowseTerm');
+            context.cnBrowseSearch.offset = Number(params.get('cnBrowsePage'));
+        }
+
         const ts = context.termSearch;
 
         // browseEntry and query searches may be facet-limited
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 90a959975a..55fd18e188 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
@@ -362,4 +362,15 @@ export class CatalogService {
             ctx.searchState = CatalogSearchState.COMPLETE;
         }));
     }
+
+    cnBrowse(ctx: CatalogSearchContext): Observable<any> {
+        ctx.searchState = CatalogSearchState.SEARCHING;
+        const cbs = ctx.cnBrowseSearch;
+
+        return this.net.request(
+            'open-ils.supercat',
+            'open-ils.supercat.call_number.browse',
+            cbs.value, ctx.searchOrg.shortname(), ctx.pager.limit, cbs.offset
+        ).pipe(tap(result => ctx.searchState = CatalogSearchState.COMPLETE));
+    }
 }
diff --git a/Open-ILS/src/eg2/src/app/share/catalog/search-context.ts b/Open-ILS/src/eg2/src/app/share/catalog/search-context.ts
index f18a8cde2d..222536f69e 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
@@ -112,6 +112,22 @@ export class CatalogIdentContext {
 
 }
 
+export class CatalogCnBrowseContext {
+    value: string;
+    // offset in pages from base browse term
+    // e.g. -2 means 2 pages back (alphabetically) from the original search.
+    offset: number;
+
+    reset() {
+        this.value = '';
+        this.offset = 0;
+    }
+
+    isSearchable() {
+        return this.value !== '';
+    }
+}
+
 export class CatalogTermContext {
     fieldClass: string[];
     query: string[];
@@ -214,6 +230,7 @@ export class CatalogSearchContext {
     marcSearch: CatalogMarcContext;
     identSearch: CatalogIdentContext;
     browseSearch: CatalogBrowseContext;
+    cnBrowseSearch: CatalogCnBrowseContext;
 
     // Result from most recent search.
     result: CatalogSearchResults;
@@ -232,6 +249,7 @@ export class CatalogSearchContext {
         this.marcSearch = new CatalogMarcContext();
         this.identSearch = new CatalogIdentContext();
         this.browseSearch = new CatalogBrowseContext();
+        this.cnBrowseSearch = new CatalogCnBrowseContext();
         this.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
index b50a415b4b..175104b81b 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/browse.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/browse.component.html
@@ -1,5 +1,5 @@
 
 <eg-catalog-search-form #searchForm></eg-catalog-search-form>
 
-<eg-catalog-browse-results><eg-catalog-browse-results>
+<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
index 67e5eed1f1..bf46a4e115 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/browse.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/browse.component.ts
@@ -1,6 +1,5 @@
 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';
 
 @Component({
@@ -11,17 +10,12 @@ export class BrowseComponent implements OnInit {
     @ViewChild('searchForm') searchForm: SearchFormComponent;
 
     constructor(
-        private staffCat: StaffCatalogService,
-        private basket: BasketService
+        private staffCat: StaffCatalogService
     ) {}
 
     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.ts b/Open-ILS/src/eg2/src/app/staff/catalog/browse/results.component.ts
index 65d02e5342..e8b3499c66 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,38 +1,37 @@
-import {Component, OnInit, Input} from '@angular/core';
-import {Observable, Subscription} from 'rxjs';
-import {map, switchMap, distinctUntilChanged} from 'rxjs/operators';
+import {Component, OnInit, OnDestroy} from '@angular/core';
 import {ActivatedRoute, ParamMap} from '@angular/router';
+import {Subscription} from 'rxjs';
 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';
 
 @Component({
   selector: 'eg-catalog-browse-results',
   templateUrl: 'results.component.html'
 })
-export class BrowseResultsComponent implements OnInit {
+export class BrowseResultsComponent implements OnInit, OnDestroy {
 
     searchContext: CatalogSearchContext;
     results: any[];
+    routeSub: Subscription;
 
     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);
-        });
+        this.routeSub = this.route.queryParamMap.subscribe(
+            (params: ParamMap) => this.browseByUrl(params)
+        );
+    }
+
+    ngOnDestroy() {
+        this.routeSub.unsubscribe();
     }
 
     browseByUrl(params: ParamMap): void {
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 0e2fc98884..f9fcf6dc8f 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
@@ -17,9 +17,6 @@ export class CatalogComponent implements OnInit {
         // 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 b07938a4c8..e78a951e62 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
@@ -24,6 +24,8 @@ import {BrowseComponent} from './browse.component';
 import {BrowseResultsComponent} from './browse/results.component';
 import {HoldingsMaintenanceComponent} from './record/holdings.component';
 import {ConjoinedComponent} from './record/conjoined.component';
+import {CnBrowseComponent} from './cnbrowse.component';
+import {CnBrowseResultsComponent} from './cnbrowse/results.component';
 
 @NgModule({
   declarations: [
@@ -44,7 +46,9 @@ import {ConjoinedComponent} from './record/conjoined.component';
     BrowseComponent,
     BrowseResultsComponent,
     ConjoinedComponent,
-    HoldingsMaintenanceComponent
+    HoldingsMaintenanceComponent,
+    CnBrowseComponent,
+    CnBrowseResultsComponent
   ],
   imports: [
     StaffCommonModule,
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 cf0a36c97f..3b4f6962b6 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
@@ -89,7 +89,6 @@ export class StaffCatalogService {
      */
     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
@@ -102,7 +101,16 @@ export class StaffCatalogService {
         params.ridx = '' + this.routeIndex++;
 
         this.router.navigate(
-          ['/staff/catalog/browse'], {queryParams: params});
+            ['/staff/catalog/browse'], {queryParams: params});
+    }
+
+    // Call number browse.
+    // Redirect to cn browse page and let its component perform the search
+    cnBrowse(): void {
+        if (!this.searchContext.cnBrowseSearch.isSearchable()) { return; }
+        const params = this.catUrl.toUrlParams(this.searchContext);
+        params.ridx = '' + this.routeIndex++; // see comments above
+        this.router.navigate(['/staff/catalog/cnbrowse'], {queryParams: params});
     }
 }
 
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/cnbrowse.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/cnbrowse.component.html
new file mode 100644
index 0000000000..9be00ff8db
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/cnbrowse.component.html
@@ -0,0 +1,5 @@
+
+<eg-catalog-search-form #searchForm></eg-catalog-search-form>
+
+<eg-catalog-cn-browse-results></eg-catalog-cn-browse-results>
+
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/cnbrowse.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/cnbrowse.component.ts
new file mode 100644
index 0000000000..e0f7bf73fe
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/cnbrowse.component.ts
@@ -0,0 +1,22 @@
+import {Component, OnInit, ViewChild} from '@angular/core';
+import {StaffCatalogService} from './catalog.service';
+import {SearchFormComponent} from './search-form.component';
+
+@Component({
+  templateUrl: 'cnbrowse.component.html'
+})
+export class CnBrowseComponent implements OnInit {
+
+    @ViewChild('searchForm') searchForm: SearchFormComponent;
+
+    constructor(
+        private staffCat: StaffCatalogService,
+    ) {}
+
+    ngOnInit() {
+        // A SearchContext provides all the data needed for browse.
+        this.staffCat.createContext();
+        this.searchForm.searchTab = 'cnbrowse';
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/cnbrowse/results.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/cnbrowse/results.component.html
new file mode 100644
index 0000000000..09a1f4e870
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/cnbrowse/results.component.html
@@ -0,0 +1,52 @@
+<!-- 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; let idx = index">
+    <div class="col-lg-12" *ngIf="result._bibSummary">
+      <eg-catalog-result-record [summary]="result._bibSummary" 
+        [index]="idx" [callNumber]="result">
+      </eg-catalog-result-record>
+    </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/cnbrowse/results.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/cnbrowse/results.component.ts
new file mode 100644
index 0000000000..037b9ea88f
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/cnbrowse/results.component.ts
@@ -0,0 +1,122 @@
+import {Component, OnInit, OnDestroy} from '@angular/core';
+import {ActivatedRoute, Router, ParamMap} from '@angular/router';
+import {Subscription} from 'rxjs';
+import {IdlObject} from '@eg/core/idl.service';
+import {CatalogService} from '@eg/share/catalog/catalog.service';
+import {BibRecordService} from '@eg/share/catalog/bib-record.service';
+import {CatalogUrlService} from '@eg/share/catalog/catalog-url.service';
+import {CatalogSearchContext, CatalogSearchState} from '@eg/share/catalog/search-context';
+import {StaffCatalogService} from '../catalog.service';
+import {BibRecordSummary} from '@eg/share/catalog/bib-record.service';
+
+@Component({
+  selector: 'eg-catalog-cn-browse-results',
+  templateUrl: 'results.component.html'
+})
+export class CnBrowseResultsComponent implements OnInit, OnDestroy {
+
+    searchContext: CatalogSearchContext;
+    results: any[];
+    routeSub: Subscription;
+
+    constructor(
+        private router: Router,
+        private route: ActivatedRoute,
+        private cat: CatalogService,
+        private bib: BibRecordService,
+        private catUrl: CatalogUrlService,
+        private staffCat: StaffCatalogService
+    ) {}
+
+    ngOnInit() {
+        this.searchContext = this.staffCat.searchContext;
+        this.routeSub = this.route.queryParamMap.subscribe(
+            (params: ParamMap) => this.browseByUrl(params)
+        );
+    }
+
+    ngOnDestroy() {
+        this.routeSub.unsubscribe();
+    }
+
+    browseByUrl(params: ParamMap): void {
+        this.catUrl.applyUrlParams(this.searchContext, params);
+        const cbs = this.searchContext.cnBrowseSearch;
+
+        if (cbs.isSearchable()) {
+            this.results = [];
+            this.cat.cnBrowse(this.searchContext)
+                .subscribe(results => this.processResults(results));
+        }
+    }
+
+    processResults(results: any[]) {
+        this.results = results;
+
+        const depth = this.searchContext.global ?
+            this.searchContext.org.root().ou_type().depth() :
+            this.searchContext.searchOrg.ou_type().depth();
+
+        const bibIds = this.results.map(r => r.record().id());
+        const distinct = (value: any, index: number, self: Array<number>) => {
+            return self.indexOf(value) === index;
+        };
+
+        const bres: IdlObject[] = [];
+        this.bib.getBibSummary(
+            bibIds.filter(distinct),
+            this.searchContext.searchOrg.id(), depth
+        ).subscribe(
+            summary => {
+                // Response order not guaranteed.  Match the summary
+                // object up with its response object.  A bib may be
+                // linked to multiple call numbers
+                const bibResults = this.results.filter(
+                    r => Number(r.record().id()) === summary.id);
+
+                bres.push(summary.record);
+
+                // Use _ since result is an 'acn' object.
+                bibResults.forEach(r => r._bibSummary = summary);
+            },
+            err => {},
+            ()  => {
+                this.bib.fleshBibUsers(bres);
+            }
+        );
+    }
+
+    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() {
+        this.searchContext.cnBrowseSearch.offset--;
+        this.staffCat.cnBrowse();
+    }
+
+    nextPage() {
+        this.searchContext.cnBrowseSearch.offset++;
+        this.staffCat.cnBrowse();
+    }
+
+    /**
+     * Propagate the search params along when navigating to each record.
+     */
+    navigateToRecord(summary: BibRecordSummary) {
+        const params = this.catUrl.toUrlParams(this.searchContext);
+
+        this.router.navigate(
+            ['/staff/catalog/record/' + summary.id], {queryParams: params});
+    }
+}
+
+
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 0a09cbdca7..1dac53644e 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
@@ -1,14 +1,14 @@
 import {Injectable} from '@angular/core';
-import {Observable, Observer} from 'rxjs';
 import {Router, Resolve, RouterStateSnapshot,
         ActivatedRouteSnapshot} from '@angular/router';
 import {ServerStoreService} from '@eg/core/server-store.service';
 import {NetService} from '@eg/core/net.service';
 import {OrgService} from '@eg/core/org.service';
 import {AuthService} from '@eg/core/auth.service';
-import {PcrudService} from '@eg/core/pcrud.service';
 import {CatalogService} from '@eg/share/catalog/catalog.service';
 import {StaffCatalogService} from './catalog.service';
+import {BasketService} from '@eg/share/catalog/basket.service';
+
 
 @Injectable()
 export class CatalogResolver implements Resolve<Promise<any[]>> {
@@ -20,7 +20,8 @@ export class CatalogResolver implements Resolve<Promise<any[]>> {
         private net: NetService,
         private auth: AuthService,
         private cat: CatalogService,
-        private staffCat: StaffCatalogService
+        private staffCat: StaffCatalogService,
+        private basket: BasketService
     ) {}
 
     resolve(
@@ -32,7 +33,8 @@ export class CatalogResolver implements Resolve<Promise<any[]>> {
         return Promise.all([
             this.cat.fetchCcvms(),
             this.cat.fetchCmfs(),
-            this.fetchSettings()
+            this.fetchSettings(),
+            this.basket.getRecordIds()
         ]);
     }
 
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 9a65dafc60..eb33fe8877 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
@@ -25,6 +25,15 @@
             <img src="/opac/extras/ac/jacket/small/r/{{summary.id}}"/>
           </a>
         </div>
+        <!-- for call number browse display -->
+        <ng-container *ngIf="callNumber">
+          <div class="pl-2 font-weight-bold">
+            {{callNumber.prefix().label()}}
+            {{callNumber.label()}}
+            {{callNumber.suffix().label()}}
+            @ {{orgName(callNumber.owning_lib())}}
+          </div>
+        </ng-container>
         <div class="flex-1 pl-2">
           <div class="row">
             <div class="col-lg-12 font-weight-bold">
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 dd13b9d8f6..8505e768f3 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
@@ -3,6 +3,7 @@ import {Subscription} from 'rxjs';
 import {Router} from '@angular/router';
 import {OrgService} from '@eg/core/org.service';
 import {NetService} from '@eg/core/net.service';
+import {IdlObject} from '@eg/core/idl.service';
 import {CatalogService} from '@eg/share/catalog/catalog.service';
 import {BibRecordService, BibRecordSummary} from '@eg/share/catalog/bib-record.service';
 import {CatalogSearchContext} from '@eg/share/catalog/search-context';
@@ -19,6 +20,12 @@ export class ResultRecordComponent implements OnInit, OnDestroy {
 
     @Input() index: number;  // 0-index display row
     @Input() summary: BibRecordSummary;
+
+    // Optional call number (acn) object to highlight
+    // Assumed prefix/suffix are fleshed
+    // Used by call number browse.
+    @Input() callNumber: IdlObject;
+
     searchContext: CatalogSearchContext;
     isRecordSelected: boolean;
     basketSub: Subscription;
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 8bcef4f30c..e0da65f9d1 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
@@ -6,6 +6,7 @@ import {RecordComponent} from './record/record.component';
 import {CatalogResolver} from './resolver.service';
 import {HoldComponent} from './hold/hold.component';
 import {BrowseComponent} from './browse.component';
+import {CnBrowseComponent} from './cnbrowse.component';
 
 const routes: Routes = [{
   path: '',
@@ -24,11 +25,16 @@ const routes: Routes = [{
     path: 'record/:id/:tab',
     component: RecordComponent
   }]}, {
-  // Browse is a top-level UI
-  path: 'browse',
-  component: BrowseComponent,
-  resolve: {catResolver : CatalogResolver},
-}];
+    // Browse is a top-level UI
+    path: 'browse',
+    component: BrowseComponent,
+    resolve: {catResolver : CatalogResolver}
+  }, {
+    path: 'cnbrowse',
+    component: CnBrowseComponent,
+    resolve: {catResolver : CatalogResolver}
+  }
+];
 
 @NgModule({
   imports: [RouterModule.forChild(routes)],
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 ee4abc522c..18af372284 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
@@ -296,6 +296,22 @@ TODO focus search input
           </div>
         </ng-template>
       </ngb-tab>
+      <ngb-tab title="Shelf Browse" i18n-title id="cnbrowse">
+        <ng-template ngbTabContent>
+          <div class="row mt-4">
+            <div class="col-lg-12 form-inline">
+              <label for="cnbrowse-term-input" i18n>
+                Browse Call Numbers starting with 
+              </label>
+              <input type="text" class="form-control ml-2" 
+                id='cnbrowse-term-input' name="query"
+                [(ngModel)]="context.cnBrowseSearch.value"
+                (keyup.enter)="searchByForm()"
+                placeholder="Browse Call Numbers..."/>
+            </div>
+          </div>
+        </ng-template>
+      </ngb-tab>      
     </ngb-tabset>
   </div>
   <div class="col-lg-4">
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 785e69e682..c8cee02105 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
@@ -81,7 +81,7 @@ export class SearchFormComponent implements OnInit, AfterViewInit {
 
     focusTabInput() {
         // Select a DOM node to focus when the tab changes.
-        let selector;
+        let selector: string;
         switch (this.searchTab) {
             case 'ident':
                 selector = '#ident-query-input';
@@ -92,6 +92,9 @@ export class SearchFormComponent implements OnInit, AfterViewInit {
             case 'browse':
                 selector = '#browse-term-input';
                 break;
+            case 'cnbrowse':
+                selector = '#cnbrowse-term-input';
+                break;
             default:
                 this.refreshCopyLocations();
                 selector = '#first-query-input';
@@ -197,6 +200,7 @@ export class SearchFormComponent implements OnInit, AfterViewInit {
                 this.context.marcSearch.reset();
                 this.context.browseSearch.reset();
                 this.context.identSearch.reset();
+                this.context.cnBrowseSearch.reset();
                 this.context.termSearch.hasBrowseEntry = '';
                 this.context.termSearch.browseEntry = null;
                 this.context.termSearch.fromMetarecord = null;
@@ -208,6 +212,7 @@ export class SearchFormComponent implements OnInit, AfterViewInit {
                 this.context.marcSearch.reset();
                 this.context.browseSearch.reset();
                 this.context.termSearch.reset();
+                this.context.cnBrowseSearch.reset();
                 this.staffCat.search();
                 break;
 
@@ -215,6 +220,7 @@ export class SearchFormComponent implements OnInit, AfterViewInit {
                 this.context.browseSearch.reset();
                 this.context.termSearch.reset();
                 this.context.identSearch.reset();
+                this.context.cnBrowseSearch.reset();
                 this.staffCat.search();
                 break;
 
@@ -222,9 +228,19 @@ export class SearchFormComponent implements OnInit, AfterViewInit {
                 this.context.marcSearch.reset();
                 this.context.termSearch.reset();
                 this.context.identSearch.reset();
+                this.context.cnBrowseSearch.reset();
                 this.context.browseSearch.pivot = null;
                 this.staffCat.browse();
                 break;
+
+            case 'cnbrowse':
+                this.context.marcSearch.reset();
+                this.context.termSearch.reset();
+                this.context.identSearch.reset();
+                this.context.browseSearch.reset();
+                this.context.cnBrowseSearch.offset = 0;
+                this.staffCat.cnBrowse();
+                break;
         }
     }
 
@@ -236,10 +252,6 @@ export class SearchFormComponent implements OnInit, AfterViewInit {
     searchIsActive(): boolean {
         return this.context.searchState === CatalogSearchState.SEARCHING;
     }
-
-    goToBrowse() {
-        this.router.navigate(['/staff/catalog/browse']);
-    }
 }
 
 
-- 
2.11.0