LP1860044 Angular catalog search result highlights
authorBill Erickson <berickxx@gmail.com>
Thu, 16 Jan 2020 18:23:15 +0000 (13:23 -0500)
committerBill Erickson <berickxx@gmail.com>
Fri, 21 Feb 2020 16:44:38 +0000 (11:44 -0500)
Support search field highlighting in the Angular staff catalog
search result and record detail pages.

Adds a new <eg-bib-display-field /> component for rendering the
highlighted content.

Move the catalog-common module import into the staff common module so
the bib-summary component has access to the new display-field component.

Drop the default search result page size to 10 for consistency with
other catalogs (and to speed up rendering).  Note users can still set
the page size of their choice via user settings.

Signed-off-by: Bill Erickson <berickxx@gmail.com>
Signed-off-by: Ruth Frasur <rfrasur@gmail.com>
16 files changed:
Open-ILS/src/eg2/src/app/share/catalog/bib-display-field.component.css [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/catalog/bib-display-field.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/catalog/bib-display-field.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/catalog/bib-record.service.ts
Open-ILS/src/eg2/src/app/share/catalog/catalog-common.module.ts
Open-ILS/src/eg2/src/app/share/catalog/catalog.service.ts
Open-ILS/src/eg2/src/app/share/catalog/search-context.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/record/pagination.component.ts
Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html
Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.ts
Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.css
Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.html
Open-ILS/src/eg2/src/app/staff/common.module.ts
Open-ILS/src/eg2/src/app/staff/share/bib-summary/bib-summary.component.html

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