LP1806087 Catalog marc search; formatting
authorBill Erickson <berickxx@gmail.com>
Thu, 6 Dec 2018 23:12:46 +0000 (18:12 -0500)
committerBill Erickson <berickxx@gmail.com>
Thu, 10 Jan 2019 17:23:30 +0000 (12:23 -0500)
Signed-off-by: Bill Erickson <berickxx@gmail.com>
Open-ILS/src/eg2/src/app/share/catalog/catalog-url.service.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/result/results.component.ts
Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.html
Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.ts

index 116ab4a..831c5d3 100644 (file)
@@ -32,7 +32,10 @@ export class CatalogUrlService {
             offset: null,
             copyLocations: null,
             browsePivot: null,
-            hasBrowseEntry: null
+            hasBrowseEntry: null,
+            marcTag: [''],
+            marcSubfield: [''],
+            marcValue: ['']
         };
 
         params.org = context.searchOrg.id();
@@ -55,13 +58,22 @@ export class CatalogUrlService {
         if (params.identQuery) {
             // Ident queries (e.g. tcn search) discards all remaining filters
             return params;
+        } else {
+            // Avoid propagating the type when it's not used.
+            delete params.identQueryType;
         }
 
-        context.query.forEach((q, idx) => {
+        context.query.filter(q => q !== '').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.
+                // correlation between values
+                params[field][idx] = context[field][idx];
+            });
+        });
+
+        context.marcValue.filter(v => v !== '').forEach((val, idx) => {
+            ['marcValue', 'marcTag', 'marcSubfield'].forEach(field => {
                 params[field][idx] = context[field][idx];
             });
         });
@@ -131,6 +143,13 @@ export class CatalogUrlService {
             }
         });
 
+        ['marcValue', 'marcTag', 'marcSubfield'].forEach(field => {
+            const arr = params.getAll(field);
+            if (arr && arr.length) {
+                context[field] = arr;
+            }
+        });
+
         CATALOG_CCVM_FILTERS.forEach(code => {
             const val = params.get(code);
             if (val) {
index aaa6b59..30d9a09 100644 (file)
@@ -61,6 +61,8 @@ export class CatalogService {
 
         if (ctx.basket) {
             return this.basketSearch(ctx);
+        } else if (ctx.marcValue[0] !== '') {
+            return this.marcSearch(ctx);
         } else {
             return this.querySearch(ctx);
         }
@@ -85,6 +87,23 @@ export class CatalogService {
         });
     }
 
+    marcSearch(ctx: CatalogSearchContext): Promise<void> {
+        let method = 'open-ils.search.biblio.marc';
+        if (ctx.isStaff) { method += '.staff'; }
+
+        const queryStruct = ctx.compileMarcSearch();
+
+        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);
+        });
+    }
+
     querySearch(ctx: CatalogSearchContext): Promise<void> {
         const fullQuery = ctx.compileSearch();
 
index 5d676a1..9dd448f 100644 (file)
@@ -52,6 +52,9 @@ export class CatalogSearchContext {
     copyLocations: string[]; // ID's, but treated as strings in the UI.
     browsePivot: number;
     hasBrowseEntry: string; // "entryId,fieldId"
+    marcTag: string[];
+    marcSubfield: string[];
+    marcValue: string[];
 
     // Result from most recent search.
     result: any = {};
@@ -124,28 +127,50 @@ export class CatalogSearchContext {
         this.searchState = CatalogSearchState.PENDING;
         this.basket = false;
         this.copyLocations = [''];
+        this.marcTag = [''];
+        this.marcSubfield = [''];
+        this.marcValue = [''];
     }
 
     // Returns true if we have enough information to perform a search.
     isSearchable(): boolean {
+        return this.searchType() !== null;
+    }
+
+    // Returns the type of search that would be performed from this 
+    // context object if a search were run now.
+    // Returns NULL if no search is possible.
+    searchType(): string {
 
         if (this.basket) {
-            return true;
+            return 'basket';
         }
 
         if (this.identQuery && this.identQueryType) {
-            return true;
+            return 'ident';
         }
 
+        if (this.marcTag[0] !== '' && this.marcValue[0] !== '') {
+            // MARC field search
+            return 'marc';
+        }
+
+        // searchOrg required for all following search scenarios
         if (this.searchOrg === null) {
-            return false;
+            return null;
         }
 
         if (this.hasBrowseEntry) {
-            return true;
+            // Limit results by records that link to browse entry
+            return 'browse';
         }
 
-        return this.query.length && this.query[0] !== '';
+        // Query search
+        if (this.query.length && this.query[0] !== '') {
+            return 'query';
+        }
+
+        return null;
     }
 
     // Returns true if we have enough information to perform a browse.
@@ -157,6 +182,35 @@ export class CatalogSearchContext {
             && this.searchOrg !== null;
     }
 
+    compileMarcSearch(): any {
+        const searches: any = [];
+
+        this.marcValue.filter(v => v !== '').forEach((val, idx) => {
+            searches.push({
+                restrict: [{
+                    subfield: this.marcSubfield[idx],
+                    tag: this.marcTag[idx]
+                }],
+                term: this.marcValue[idx]
+            });
+        });
+
+        const args: any = {
+            searches: searches,
+            limit : this.pager.limit,
+            offset : this.pager.offset,
+            org_unit: this.searchOrg.id()
+        };
+
+        if (this.sort) {
+            const parts = this.sort.split(/\./);
+            args.sort = parts[0]; // title, author, etc.
+            if (parts[1]) { args.sort_dir = 'descending' };
+        }
+
+        return args;
+    }
+
     compileSearch(): string {
         let str = '';
 
index f402ea4..c98512b 100644 (file)
@@ -90,6 +90,7 @@ export class ResultsComponent implements OnInit, OnDestroy {
         this.allRecsSelected = allChecked;
     }
 
+    // Pull values from the URL and run the requested search.
     searchByUrl(params: ParamMap): void {
         this.catUrl.applyUrlParams(this.searchContext, params);
 
index cdcae0c..b0db7a3 100644 (file)
@@ -1,7 +1,7 @@
 <!--
 TODO focus search input
 -->
-<div id='staffcat-search-form' class='pb-2 mb-3'>
+<div id='staffcat-search-form' class='pb-2 mb-4'>
   <div class="row"
     *ngFor="let q of searchContext.query; let idx = index; trackBy:trackByIdx">
     <div class="col-lg-8 d-flex">
@@ -87,12 +87,12 @@ TODO focus search input
           *ngIf="!showAdvanced()"
           [disabled]="searchIsActive()"
           (click)="toggleAdvancedSearch()" i18n>
-          More Filters
+          More Options
         </button>
         <button class="btn btn-outline-secondary" type="button"
           *ngIf="showAdvanced()"
           (click)="toggleAdvancedSearch()" i18n>
-          Hide Filters
+          Hide Options
         </button>
         <button class="btn btn-info ml-1" type="button" 
             (click)="goToBrowse()" i18n>
@@ -156,93 +156,146 @@ TODO focus search input
       <eg-catalog-basket-actions></eg-catalog-basket-actions>
     </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">
-      <ng-container *ngIf="copyLocations.length > 0">
-        <select class="form-control" 
-          [(ngModel)]="searchContext.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>
-      </ng-container>
-    </div>
+
+  <div class="p-2 m-2" *ngIf="showAdvanced()">
+    <ngb-tabset #searchTabs [activeId]="searchTab" (tabChange)="onTabChange($event)">
+      <ngb-tab title="Search Filters" i18n-title id="filters">
+        <ng-template ngbTabContent>
+          <div class="row mt-3">
+            <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>
+          <div class="row pt-2">
+            <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">
+              <ng-container *ngIf="copyLocations.length > 0">
+                <select class="form-control" 
+                  [(ngModel)]="searchContext.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>
+              </ng-container>
+            </div>
+          </div>
+        </ng-template>
+      </ngb-tab>
+      <ngb-tab title="Numeric Search" i18n-title id="ident">
+        <ng-template ngbTabContent>
+          <div class="row mt-3">
+            <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)]="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>
+                <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)]="searchContext.identQuery"
+                  (keyup.enter)="formEnter('ident')"
+                  placeholder="Numeric Query..."/>
+                <button class="btn btn-success ml-2" type="button"
+                  [disabled]="searchIsActive()"
+                  (click)="formEnter('ident')" i18n>Search</button>
+              </div>
+            </div>
+          </div>
+        </ng-template>
+      </ngb-tab>
+      <ngb-tab title="MARC Search" i18n-title id="marc">
+        <ng-template ngbTabContent>
+          <div class="row mt-3">
+            <div class="col-lg-12">
+              <div class="form-inline mt-2" 
+                *ngFor="let q of searchContext.marcValue; 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)]="searchContext.marcTag[idx]"/>
+                <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)]="searchContext.marcSubfield[idx]"/>
+                <label for="marc-value-{{idx}}" class="ml-2" i18n>Value</label>
+                <input class="form-control ml-2" type="text" name="marc-value-{{idx}}"
+                  [(ngModel)]="searchContext.marcValue[idx]"/>
+                <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]="searchContext.marcValue.length < 2"
+                  (click)="delMarcSearchRow(idx)">
+                  <span class="material-icons">remove_circle_outline</span>
+                </button>
+                <button *ngIf="idx == 0" class="btn btn-success ml-2"
+                  (click)="formEnter('marc')" i18n>Submit</button>
+              </div>
+            </div>
+          </div>
+        </ng-template>
+      </ngb-tab>
+    </ngb-tabset>
   </div>
 </div>
 
index 417bb3e..3d8a1ad 100644 (file)
@@ -5,6 +5,7 @@ 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',
@@ -18,6 +19,8 @@ export class SearchFormComponent implements OnInit, AfterViewInit {
     cmfMap: {[cmf: string]: IdlObject} = {};
     showAdvancedSearch = false;
     copyLocations: IdlObject[];
+    searchTab: string;
+    //@ViewChild('searchTabs') searchTabs: NgbTabset;
 
     constructor(
         private renderer: Renderer2,
@@ -27,6 +30,7 @@ export class SearchFormComponent implements OnInit, AfterViewInit {
         private staffCat: StaffCatalogService
     ) {
         this.copyLocations = [];
+        this.searchTab = 'filters';
     }
 
     ngOnInit() {
@@ -44,17 +48,41 @@ 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(() => {
+            const st = this.searchContext.searchType();
+            if (st === 'marc' || st === 'ident') {
+                this.searchTab = st;
+            } else {
+                this.searchTab = 'filters';
+            }
+        });
 
         this.refreshCopyLocations();
     }
 
+    onTabChange(evt: NgbTabChangeEvent) {
+        this.searchTab = evt.nextId;
+
+        // 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;
+            default:
+                selector = '#first-query-input';
+        }
+
+        // Call focus after tab-change event has a chance to complete
+        // or the tab body and its input won't exist yet.
+        setTimeout(() => 
+            this.renderer.selectRootElement(selector).focus());
+    }
+
     /**
      * Display the advanced/extended search options when asked to
      * or if any advanced options are selected.
@@ -72,6 +100,7 @@ export class SearchFormComponent implements OnInit, AfterViewInit {
 
         if (this.searchContext.copyLocations[0] !== '') { return true; }
         if (this.searchContext.identQuery) { return true; }
+        if (this.searchContext.marcValue[0] !== '') { return true; }
 
         // ccvm filters may be present without any filters applied.
         // e.g. if filters were applied then removed.
@@ -123,6 +152,18 @@ export class SearchFormComponent implements OnInit, AfterViewInit {
         this.searchContext.matchOp.splice(index, 1);
     }
 
+    addMarcSearchRow(index: number): void {
+        this.searchContext.marcTag.splice(index, 0, '');
+        this.searchContext.marcSubfield.splice(index, 0, '');
+        this.searchContext.marcValue.splice(index, 0, '');
+    }
+
+    delMarcSearchRow(index: number): void {
+        this.searchContext.marcTag.splice(index, 1);
+        this.searchContext.marcSubfield.splice(index, 1);
+        this.searchContext.marcValue.splice(index, 1);
+    }
+
     formEnter(source) {
         this.searchContext.pager.offset = 0;
 
@@ -134,7 +175,9 @@ export class SearchFormComponent implements OnInit, AfterViewInit {
             case 'query': // main search form query input
 
                 // Be sure a previous ident search does not take precedence
-                // over the newly entered/submitted search query
+                // over the new term query submitted via Enter within
+                // the search term/query box.
+                this.searchContext.marcValue[0] = '';
                 this.searchContext.identQuery = null;
                 break;
 
@@ -148,6 +191,11 @@ export class SearchFormComponent implements OnInit, AfterViewInit {
                     this.searchContext.identQueryType = qt;
                 }
                 break;
+
+            case 'marc':
+                this.searchContext.identQuery = null;
+                this.searchContext.query[0] = ''; // prevent term queries
+                break;
         }
 
         this.searchByForm();