LP1904788 Staff catalog browse results paging
authorBill Erickson <berickxx@gmail.com>
Tue, 17 Nov 2020 22:55:20 +0000 (17:55 -0500)
committerGalen Charlton <gmc@equinoxOLI.org>
Wed, 11 Aug 2021 21:41:29 +0000 (17:41 -0400)
Adds the ability to step through browse headings directly from the
heading record list page without having to return to the original browse
search

To test:

1. Navigate to the staff catalog and perform a Browse search.

2. Click on one of the headings and you'll be taken to the page which
lists the bib records that use the selected heading.

3. Click the Previous Heading / Next Heading buttons to step through the
headings and view the linked bib records.

Signed-off-by: Bill Erickson <berickxx@gmail.com>
Signed-off-by: Gina Monti <gmonti@biblio.org>
Signed-off-by: Galen Charlton <gmc@equinoxOLI.org>
Open-ILS/src/eg2/src/app/staff/catalog/browse/results.component.ts
Open-ILS/src/eg2/src/app/staff/catalog/catalog.module.ts
Open-ILS/src/eg2/src/app/staff/catalog/catalog.service.ts
Open-ILS/src/eg2/src/app/staff/catalog/result/browse-pager.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/catalog/result/browse-pager.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/catalog/result/results.component.html
Open-ILS/src/eg2/src/app/staff/catalog/result/results.component.ts
Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.ts

index 3aeaf6b..3ae935a 100644 (file)
@@ -119,9 +119,7 @@ export class BrowseResultsComponent implements OnInit, OnDestroy {
 
     searchByBrowseEntryParams(result) {
         const ctx = this.searchContext.clone();
-        ctx.browseSearch.reset(); // we're done browsing
-        ctx.termSearch.hasBrowseEntry =
-            result.browse_entry + ',' + result.fields;
+        ctx.termSearch.hasBrowseEntry = result.browse_entry + ',' + result.fields;
         return this.catUrl.toUrlParams(ctx);
     }
 
index cff15a1..4df2ffd 100644 (file)
@@ -32,6 +32,7 @@ import {CnBrowseResultsComponent} from './cnbrowse/results.component';
 import {SearchTemplatesComponent} from './search-templates.component';
 import {MarcEditModule} from '@eg/staff/share/marc-edit/marc-edit.module';
 import {PreferencesComponent} from './prefs.component';
+import {BrowsePagerComponent} from './result/browse-pager.component';
 
 @NgModule({
   declarations: [
@@ -58,7 +59,8 @@ import {PreferencesComponent} from './prefs.component';
     CnBrowseComponent,
     OpacViewComponent,
     PreferencesComponent,
-    CnBrowseResultsComponent
+    CnBrowseResultsComponent,
+    BrowsePagerComponent
   ],
   imports: [
     StaffCommonModule,
index e763b35..9c2155f 100644 (file)
@@ -48,6 +48,10 @@ export class StaffCatalogService {
     // Add digital bookplate to search options.
     enableBookplates = false;
 
+    // Cache of browse results so the browse pager is not forced to
+    // re-run the browse search on each navigation.
+    browsePagerData: any[];
+
     constructor(
         private router: Router,
         private route: ActivatedRoute,
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/result/browse-pager.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/result/browse-pager.component.html
new file mode 100644 (file)
index 0000000..8d592da
--- /dev/null
@@ -0,0 +1,51 @@
+
+<div class="row mb-2 p-2 border border-info rounded">
+
+  <ng-container *ngIf="browseLoading">
+    <div class="col-lg-6 offset-lg-3">
+      <eg-progress-inline></eg-progress-inline>
+    </div>
+  </ng-container>
+
+  <ng-container *ngIf="!browseLoading">
+    <div class="col-lg-4">
+      <div *ngIf="prevEntry" class="d-flex">
+        <div class="">
+          <button class="btn btn-sm btn-outline-dark mr-2" 
+            (click)="goToBrowsePage(true)" i18n>Previous Heading</button>
+        </div>
+        <div class="flex-1 pt-1">
+          <span>{{prevEntry.value}}</span>
+        </div>
+      </div>
+    </div>
+    <div class="col-lg-4 d-flex justify-content-center">
+      <div class="pt-1">
+        <ng-container [ngSwitch]="searchContext.browseSearch.fieldClass">
+          <span i18n *ngSwitchCase="'title'">Title: </span>
+          <span i18n *ngSwitchCase="'author'">Author: </span>
+          <span i18n *ngSwitchCase="'subject'">Subject: </span>
+          <span i18n *ngSwitchCase="'series'">Series: </span>
+        </ng-container>
+        <span class="font-weight-bold">
+          {{searchContext.termSearch.browseEntry.value()}}
+        </span>
+      </div>
+    </div>
+    <div class="col-lg-4">
+      <div *ngIf="nextEntry" class="float-right">
+        <div class="d-flex">
+          <div class="flex-1 pt-1">
+            <span>{{nextEntry.value}}</span>
+          </div>
+          <div class="">
+            <button class="btn btn-sm btn-outline-dark ml-2" 
+              (click)="goToBrowsePage(false)" i18n>Next Heading</button>
+          </div>
+        </div>
+      </div>
+    </div>
+  </ng-container>
+
+</div>
+
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/result/browse-pager.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/result/browse-pager.component.ts
new file mode 100644 (file)
index 0000000..006ad8c
--- /dev/null
@@ -0,0 +1,229 @@
+import {Component, OnInit, OnDestroy, Input} from '@angular/core';
+import {Observable, Subscription} from 'rxjs';
+import {tap, map, switchMap, distinctUntilChanged} from 'rxjs/operators';
+import {CatalogService} from '@eg/share/catalog/catalog.service';
+import {CatalogSearchContext, CatalogSearchState} from '@eg/share/catalog/search-context';
+import {StaffCatalogService} from '../catalog.service';
+import {IdlObject} from '@eg/core/idl.service';
+import {BasketService} from '@eg/share/catalog/basket.service';
+
+interface BrowsePage {
+    leftPivot: number;
+    rightPivot: number;
+    entries: any[];
+}
+
+@Component({
+  selector: 'eg-catalog-browse-pager',
+  templateUrl: 'browse-pager.component.html'
+})
+export class BrowsePagerComponent implements OnInit {
+
+    searchContext: CatalogSearchContext;
+    browseLoading = false;
+    prevEntry: any;
+    nextEntry: any;
+
+    constructor(
+        private cat: CatalogService,
+        private staffCat: StaffCatalogService
+    ) {}
+
+    ngOnInit() {
+        this.searchContext = this.staffCat.searchContext;
+        this.fetchPageData().then(_ => this.setPrevNext());
+    }
+
+    pageEntryId(): number {
+        return Number(
+            this.searchContext.termSearch.hasBrowseEntry.split(',')[0]
+        );
+    }
+
+    getEntryPageIndex(mbeId: number): number {
+        let idx = null;
+        this.staffCat.browsePagerData.forEach((page, index) => {
+            page.entries.forEach(entry => {
+                if (entry.browse_entry === mbeId) {
+                    idx = index;
+                }
+            });
+        });
+        return idx;
+    }
+
+
+    getEntryPage(mbeId: number): BrowsePage {
+        return this.staffCat.browsePagerData[this.getEntryPageIndex(mbeId)];
+    }
+
+    fetchPageData(): Promise<any> {
+
+        if (this.getEntryPage(this.pageEntryId())) {
+            // We have this page's data already
+            return Promise.resolve();
+        }
+
+        return this.fetchBrowsePage(null);
+    }
+
+    // Grab a page of browse results
+    fetchBrowsePage(prev: boolean): Promise<any> {
+        const ctx = this.searchContext.clone();
+        ctx.pager.limit = this.searchContext.pager.limit;
+        ctx.termSearch.hasBrowseEntry = null; // avoid term search
+
+        if (prev !== null) {
+            // Fetching data for a prev/next page which is not the
+            // current page.
+            const page = this.getEntryPage(this.pageEntryId());
+            const pivot = prev ? page.leftPivot : page.rightPivot;
+            if (pivot === null) {
+                console.debug('Browse has reached the end of the rainbow');
+                return;
+            }
+            ctx.browseSearch.pivot = pivot;
+        }
+
+        const results = [];
+        this.browseLoading = true;
+
+        return this.cat.browse(ctx)
+        .pipe(tap(result => results.push(result)))
+        .toPromise().then(_ => {
+            if (results.length === 0) { return; }
+
+            // At the end of the data set, final pivots are not present
+            let leftPivot = null;
+            let rightPivot = null;
+            if (results[0].pivot_point) {
+                leftPivot = results.shift().pivot_point;
+            }
+            if (results[results.length - 1].pivot_point) {
+               rightPivot = results.pop().pivot_point;
+            }
+
+            // We only care about entries with bib record sources
+            let keepEntries = results.filter(e => Boolean(e.sources));
+
+            if (leftPivot === null || rightPivot === null) {
+                // When you reach the edge of the data set, you can get
+                // the same browse entries from different API calls.
+                // From what I can tell, the last page will always have
+                // a half page of entries, even if you've already seen some
+                // of them in the previous page.  Trim the dupes since they
+                // affect the logic.
+                const keep = [];
+                keepEntries.forEach(e => {
+                    if (!this.getEntryPage(e.browse_entry)) {
+                        keep.push(e);
+                    }
+                });
+                keepEntries = keep;
+            }
+
+            const page: BrowsePage = {
+                leftPivot: leftPivot,
+                rightPivot: rightPivot,
+                entries: keepEntries
+            };
+
+            if (prev) {
+                this.staffCat.browsePagerData.unshift(page);
+            } else {
+                this.staffCat.browsePagerData.push(page);
+            }
+            this.browseLoading = false;
+        });
+    }
+
+    // Collect enough browse data to display previous, current, and
+    // next heading.  This can mean fetching an additional page of data.
+    setPrevNext(take2: boolean = false): Promise<any> {
+
+        let previous: any;
+        const mbeId = this.pageEntryId();
+
+        this.staffCat.browsePagerData.forEach(page => {
+            page.entries.forEach(entry => {
+
+                if (previous) {
+                    if (entry.browse_entry === mbeId) {
+                        this.prevEntry = previous;
+                    }
+                    if (previous.browse_entry === mbeId) {
+                        this.nextEntry = entry;
+                    }
+                }
+                previous = entry;
+            });
+        });
+
+        if (take2) {
+            // If we have to call this more than twice it means we've
+            // reached the boundary of the full data set and there's
+            // no more data to fetch.
+            return Promise.resolve();
+        }
+
+        let promise;
+
+        if (!this.prevEntry) {
+            promise = this.fetchBrowsePage(true);
+
+        } else if (!this.nextEntry) {
+            promise = this.fetchBrowsePage(false);
+        }
+
+        if (promise) {
+            return promise.then(_ => this.setPrevNext(true));
+        }
+
+        return Promise.resolve();
+    }
+
+    setSearchPivot(prev?: boolean) {
+        // When traversing browse result page boundaries, modify the
+        // search pivot to keep up.
+
+        const targetMbe = Number(
+            prev ? this.prevEntry.browse_entry : this.nextEntry.browse_entry
+        );
+
+        const curPageIdx = this.getEntryPageIndex(this.pageEntryId());
+        const targetPageIdx = this.getEntryPageIndex(targetMbe);
+
+        if (targetPageIdx !== curPageIdx) {
+            // We are crossing a page boundary
+
+            const curPage = this.getEntryPage(this.pageEntryId());
+
+            if (prev) {
+                this.searchContext.browseSearch.pivot = curPage.leftPivot;
+
+            } else {
+                this.searchContext.browseSearch.pivot = curPage.rightPivot;
+            }
+        }
+    }
+
+    // Find the browse entry for the next/prev page and navigate there
+    // if possible.  Returns false if not enough data is available.
+    goToBrowsePage(prev: boolean): boolean {
+        const ctx = this.searchContext;
+        const target = prev ? this.prevEntry : this.nextEntry;
+
+        if (!target) { return false; }
+
+        this.setSearchPivot(prev);
+
+        // Jump to the selected browse entry's page.
+        ctx.termSearch.hasBrowseEntry = target.browse_entry + ',' + target.fields;
+        ctx.pager.offset = 0; // this is a new records-for-browse-entry search
+        this.staffCat.search();
+
+        return true;
+    }
+}
+
+
index 96953ee..5f584a5 100644 (file)
 
 <!-- header, pager, and list of records -->
 <div id="staff-catalog-results-container" *ngIf="searchHasResults()">
+
+  <eg-catalog-browse-pager *ngIf="searchContext.termSearch.browseEntry">
+  </eg-catalog-browse-pager>
+
   <div class="row">
     <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>
index afdcd93..61be085 100644 (file)
@@ -1,6 +1,6 @@
 import {Component, OnInit, OnDestroy, Input} from '@angular/core';
 import {Observable, Subscription} from 'rxjs';
-import {map, switchMap, distinctUntilChanged} from 'rxjs/operators';
+import {tap, 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';
@@ -44,6 +44,7 @@ export class ResultsComponent implements OnInit, OnDestroy {
 
     ngOnInit() {
         this.searchContext = this.staffCat.searchContext;
+        this.staffCat.browsePagerData = [];
 
         // Our search context is initialized on page load.  Once
         // ResultsComponent is active, it will not be reinitialized,
@@ -65,8 +66,9 @@ export class ResultsComponent implements OnInit, OnDestroy {
         });
 
         // After each completed search, update the record selector.
-        this.searchSub = this.cat.onSearchComplete.subscribe(
-            ctx => this.applyRecordSelection());
+        this.searchSub = this.cat.onSearchComplete.subscribe(ctx => {
+            this.applyRecordSelection();
+        });
 
         // Watch for basket changes applied by other components.
         this.basketSub = this.basket.onChange.subscribe(
index 16a124d..d416f16 100644 (file)
@@ -116,7 +116,13 @@ export class SearchFormComponent implements OnInit, AfterViewInit {
                     this.searchTab = 'marc';
                 } else if (this.context.identSearch.isSearchable()) {
                     this.searchTab = 'ident';
-                } else if (this.context.browseSearch.isSearchable()) {
+
+                // Browse search may remain 'searchable' even though we
+                // are displaying bibs linked to a browse entry.
+                // This is so browse search paging can be added to
+                // the record list page.
+                } else if (this.context.browseSearch.isSearchable()
+                    && !this.context.termSearch.hasBrowseEntry) {
                     this.searchTab = 'browse';
                 } else if (this.context.termSearch.isSearchable()) {
                     this.searchTab = 'term';