LP1837478 Angular Catalog Recent Searches & Templates
authorBill Erickson <berickxx@gmail.com>
Mon, 22 Jul 2019 21:50:01 +0000 (17:50 -0400)
committerGalen Charlton <gmc@equinoxinitiative.org>
Wed, 31 Jul 2019 19:26:04 +0000 (15:26 -0400)
Adds two new dropdowns (below basket actions) for recent searches
(similar to those found in the staff TPAC) and search templates.

Search templates are a new feature which allow staff to save canned
search filters/settings without the query content, so common searches
may be easily recalled.

For UI consistency and to preserve some space, the Basket Actions selector
is now a dropdown instead of a select element.

Adds a new workstation setting 'eg.catalog.search_templates' for storing
templates.

Includes a number of improvements to the underlying Catalog code and a
new ArrayUtil class, which adds a simple equals() function for comparing
arrays.

Signed-off-by: Bill Erickson <berickxx@gmail.com>
Signed-off-by: Chris Sharp <csharp@georgialibraries.org>
Signed-off-by: Galen Charlton <gmc@equinoxinitiative.org>
16 files changed:
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/share/util/array.spec.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/util/array.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/catalog/basket-actions.component.html
Open-ILS/src/eg2/src/app/staff/catalog/basket-actions.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/resolver.service.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
Open-ILS/src/eg2/src/app/staff/catalog/search-templates.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/catalog/search-templates.component.ts [new file with mode: 0644]
Open-ILS/src/sql/Pg/950.data.seed-values.sql
Open-ILS/src/sql/Pg/upgrade/XXXX.data.search-templates.sql [new file with mode: 0644]

index 91922d4..4c45a4e 100644 (file)
@@ -176,18 +176,18 @@ export class CatalogUrlService {
             context.showBasket = val;
         }
 
-        if (params.get('marcValue')) {
+        if (params.has('marcValue')) {
             context.marcSearch.tags = params.getAll('marcTag');
             context.marcSearch.subfields = params.getAll('marcSubfield');
             context.marcSearch.values = params.getAll('marcValue');
         }
 
-        if (params.get('identQuery')) {
+        if (params.has('identQuery')) {
             context.identSearch.value = params.get('identQuery');
             context.identSearch.queryType = params.get('identQueryType');
         }
 
-        if (params.get('browseTerm')) {
+        if (params.has('browseTerm')) {
             context.browseSearch.value = params.get('browseTerm');
             context.browseSearch.fieldClass = params.get('browseClass');
             if (params.has('browsePivot')) {
@@ -195,7 +195,7 @@ export class CatalogUrlService {
             }
         }
 
-        if (params.get('cnBrowseTerm')) {
+        if (params.has('cnBrowseTerm')) {
             context.cnBrowseSearch.value = params.get('cnBrowseTerm');
             context.cnBrowseSearch.offset = Number(params.get('cnBrowsePage'));
         }
index 9cff2c4..2aaaf1f 100644 (file)
@@ -1,6 +1,6 @@
 import {Injectable, EventEmitter} from '@angular/core';
 import {Observable} from 'rxjs';
-import {map, tap} from 'rxjs/operators';
+import {map, tap, finalize} 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';
@@ -358,9 +358,10 @@ export class CatalogService {
                 pivot: bs.pivot,
                 org_unit: ctx.searchOrg.id()
             }
-        ).pipe(tap(result => {
-            ctx.searchState = CatalogSearchState.COMPLETE;
-        }));
+        ).pipe(
+            tap(result => ctx.searchState = CatalogSearchState.COMPLETE),
+            finalize(() => this.onSearchComplete.emit(ctx))
+        );
     }
 
     cnBrowse(ctx: CatalogSearchContext): Observable<any> {
index ef0fd55..041d710 100644 (file)
@@ -1,6 +1,7 @@
 import {OrgService} from '@eg/core/org.service';
 import {IdlObject} from '@eg/core/idl.service';
 import {Pager} from '@eg/share/util/pager';
+import {ArrayUtil} from '@eg/share/util/array';
 
 // CCVM's we care about in a catalog context
 // Don't fetch them all because there are a lot.
@@ -41,6 +42,11 @@ export class FacetFilter {
             this.facetValue === filter.facetValue
         );
     }
+
+    clone(): FacetFilter {
+        return new FacetFilter(
+            this.facetClass, this.facetName, this.facetValue);
+    }
 }
 
 export class CatalogSearchResults {
@@ -71,6 +77,18 @@ export class CatalogBrowseContext {
             this.fieldClass !== ''
         );
     }
+
+    clone(): CatalogBrowseContext {
+        const ctx = new CatalogBrowseContext();
+        ctx.value = this.value;
+        ctx.pivot = this.pivot;
+        ctx.fieldClass = this.fieldClass;
+        return ctx;
+    }
+
+    equals(ctx: CatalogBrowseContext): boolean {
+        return ctx.value === this.value && ctx.fieldClass === this.fieldClass;
+    }
 }
 
 export class CatalogMarcContext {
@@ -91,6 +109,19 @@ export class CatalogMarcContext {
         );
     }
 
+    clone(): CatalogMarcContext {
+        const ctx = new CatalogMarcContext();
+        ctx.tags = [].concat(this.tags);
+        ctx.values = [].concat(this.values);
+        ctx.subfields = [].concat(this.subfields);
+        return ctx;
+    }
+
+    equals(ctx: CatalogMarcContext): boolean {
+        return ArrayUtil.equals(ctx.tags, this.tags)
+            && ArrayUtil.equals(ctx.values, this.values)
+            && ArrayUtil.equals(ctx.subfields, this.subfields);
+    }
 }
 
 export class CatalogIdentContext {
@@ -109,6 +140,16 @@ export class CatalogIdentContext {
         );
     }
 
+    clone(): CatalogIdentContext {
+        const ctx = new CatalogIdentContext();
+        ctx.value = this.value;
+        ctx.queryType = this.queryType;
+        return ctx;
+    }
+
+    equals(ctx: CatalogIdentContext): boolean {
+        return ctx.value === this.value && ctx.queryType === this.queryType;
+    }
 }
 
 export class CatalogCnBrowseContext {
@@ -123,7 +164,18 @@ export class CatalogCnBrowseContext {
     }
 
     isSearchable() {
-        return this.value !== '';
+        return this.value !== '' && this.value !== undefined;
+    }
+
+    clone(): CatalogCnBrowseContext {
+        const ctx = new CatalogCnBrowseContext();
+        ctx.value = this.value;
+        ctx.offset = this.offset;
+        return ctx;
+    }
+
+    equals(ctx: CatalogCnBrowseContext): boolean {
+        return ctx.value === this.value;
     }
 }
 
@@ -169,6 +221,63 @@ export class CatalogTermContext {
         CATALOG_CCVM_FILTERS.forEach(code => this.ccvmFilters[code] = ['']);
     }
 
+    clone(): CatalogTermContext {
+        const ctx = new CatalogTermContext();
+
+        ctx.query = [].concat(this.query);
+        ctx.fieldClass = [].concat(this.fieldClass);
+        ctx.matchOp = [].concat(this.matchOp);
+        ctx.joinOp = [].concat(this.joinOp);
+        ctx.copyLocations = [].concat(this.copyLocations);
+        ctx.format = this.format;
+        ctx.hasBrowseEntry = this.hasBrowseEntry;
+        ctx.date1 = this.date1;
+        ctx.date2 = this.date2;
+        ctx.dateOp = this.dateOp;
+        ctx.fromMetarecord = this.fromMetarecord;
+
+        ctx.facetFilters = this.facetFilters.map(f => f.clone());
+
+        ctx.ccvmFilters = {};
+        Object.keys(this.ccvmFilters).forEach(
+            key => ctx.ccvmFilters[key] = this.ccvmFilters[key]);
+
+        return ctx;
+    }
+
+    equals(ctx: CatalogTermContext): boolean {
+        if (   ArrayUtil.equals(ctx.query, this.query)
+            && ArrayUtil.equals(ctx.fieldClass, this.fieldClass)
+            && ArrayUtil.equals(ctx.matchOp, this.matchOp)
+            && ArrayUtil.equals(ctx.joinOp, this.joinOp)
+            && ArrayUtil.equals(ctx.copyLocations, this.copyLocations)
+            && ctx.format === this.format
+            && ctx.hasBrowseEntry === this.hasBrowseEntry
+            && ctx.date1 === this.date1
+            && ctx.date2 === this.date2
+            && ctx.dateOp === this.dateOp
+            && ctx.fromMetarecord === this.fromMetarecord
+            && ArrayUtil.equals(
+                ctx.facetFilters, this.facetFilters, (a, b) => a.equals(b))
+            && Object.keys(this.ccvmFilters).length ===
+                Object.keys(ctx.ccvmFilters).length
+        ) {
+
+            // So far so good, compare ccvm hash contents
+            let mismatch = false;
+            Object.keys(this.ccvmFilters).forEach(key => {
+                if (!ArrayUtil.equals(this.ccvmFilters[key], ctx.ccvmFilters[key])) {
+                    mismatch = true;
+                }
+            });
+
+            return !mismatch;
+        }
+
+        return false;
+    }
+
+
     // True when grouping by metarecord but not when displaying the
     // contents of a metarecord.
     isMetarecordSearch(): boolean {
@@ -252,6 +361,38 @@ export class CatalogSearchContext {
         this.reset();
     }
 
+    // Performs a deep clone of the search context as-is.
+    clone(): CatalogSearchContext {
+        const ctx = new CatalogSearchContext();
+
+        ctx.sort = this.sort;
+        ctx.isStaff = this.isStaff;
+        ctx.global = this.global;
+
+        // OK to share since the org object won't be changing.
+        ctx.searchOrg = this.searchOrg;
+
+        ctx.termSearch = this.termSearch.clone();
+        ctx.marcSearch = this.marcSearch.clone();
+        ctx.identSearch = this.identSearch.clone();
+        ctx.browseSearch = this.browseSearch.clone();
+        ctx.cnBrowseSearch = this.cnBrowseSearch.clone();
+
+        return ctx;
+    }
+
+    equals(ctx: CatalogSearchContext): boolean {
+        return (
+            this.termSearch.equals(ctx.termSearch)
+            && this.marcSearch.equals(ctx.marcSearch)
+            && this.identSearch.equals(ctx.identSearch)
+            && this.browseSearch.equals(ctx.browseSearch)
+            && this.cnBrowseSearch.equals(ctx.cnBrowseSearch)
+            && this.sort === ctx.sort
+            && this.global === ctx.global
+        );
+    }
+
     /**
      * Return search context to its default state, resetting search
      * parameters and clearing any cached result data.
@@ -267,6 +408,7 @@ export class CatalogSearchContext {
         this.marcSearch.reset();
         this.identSearch.reset();
         this.browseSearch.reset();
+        this.cnBrowseSearch.reset();
     }
 
     isSearchable(): boolean {
@@ -483,5 +625,57 @@ export class CatalogSearchContext {
 
         return str;
     }
+
+    // A search context can collect enough data for multiple search
+    // types to be searchable (e.g. users navigate through parts of a
+    // search form).  Calling this method and providing a search type
+    // ensures the context is cleared of any data unrelated to the
+    // desired type.
+    scrub(searchType: string): void {
+
+        switch (searchType) {
+
+            case 'term': // AKA keyword search
+                this.marcSearch.reset();
+                this.browseSearch.reset();
+                this.identSearch.reset();
+                this.cnBrowseSearch.reset();
+                this.termSearch.hasBrowseEntry = '';
+                this.termSearch.browseEntry = null;
+                this.termSearch.fromMetarecord = null;
+                this.termSearch.facetFilters = [];
+                break;
+
+            case 'ident':
+                this.marcSearch.reset();
+                this.browseSearch.reset();
+                this.termSearch.reset();
+                this.cnBrowseSearch.reset();
+                break;
+
+            case 'marc':
+                this.browseSearch.reset();
+                this.termSearch.reset();
+                this.identSearch.reset();
+                this.cnBrowseSearch.reset();
+                break;
+
+            case 'browse':
+                this.marcSearch.reset();
+                this.termSearch.reset();
+                this.identSearch.reset();
+                this.cnBrowseSearch.reset();
+                this.browseSearch.pivot = null;
+                break;
+
+            case 'cnbrowse':
+                this.marcSearch.reset();
+                this.termSearch.reset();
+                this.identSearch.reset();
+                this.browseSearch.reset();
+                this.cnBrowseSearch.offset = 0;
+                break;
+        }
+    }
 }
 
diff --git a/Open-ILS/src/eg2/src/app/share/util/array.spec.ts b/Open-ILS/src/eg2/src/app/share/util/array.spec.ts
new file mode 100644 (file)
index 0000000..10125be
--- /dev/null
@@ -0,0 +1,29 @@
+import {ArrayUtil} from './array';
+
+describe('ArrayUtil', () => {
+
+    const arr1 = [1, '2', true, undefined, null];
+    const arr2 = [1, '2', true, undefined, null];
+    const arr3 = [1, '2', true, undefined, null, 'foo'];
+    const arr4 = [[1, 2, 3], [4, 3, 2]];
+    const arr5 = [[1, 2, 3], [4, 3, 2]];
+    const arr6 = [[1, 2, 3], [1, 2, 3]];
+
+    it('Compare matching arrays', () => {
+        expect(ArrayUtil.equals(arr1, arr2)).toBe(true);
+    });
+
+    it('Compare non-matching arrays', () => {
+        expect(ArrayUtil.equals(arr1, arr3)).toBe(false);
+    });
+
+    // Using ArrayUtil.equals as a comparator -- testception!
+    it('Compare matching arrays with comparator', () => {
+        expect(ArrayUtil.equals(arr4, arr5, ArrayUtil.equals)).toBe(true);
+    });
+
+    it('Compare non-matching arrays with comparator', () => {
+        expect(ArrayUtil.equals(arr5, arr6, ArrayUtil.equals)).toBe(false);
+    });
+
+});
diff --git a/Open-ILS/src/eg2/src/app/share/util/array.ts b/Open-ILS/src/eg2/src/app/share/util/array.ts
new file mode 100644 (file)
index 0000000..a66f326
--- /dev/null
@@ -0,0 +1,39 @@
+
+/* Utility code for arrays */
+
+export class ArrayUtil {
+
+    // Returns true if the two arrays contain the same values as
+    // reported by the provided comparator function or ===
+    static equals(arr1: any[], arr2: any[],
+        comparator?: (a: any, b: any) => boolean): boolean {
+
+        if (!Array.isArray(arr1) || !Array.isArray(arr2)) {
+            return false;
+        }
+
+        if (arr1 === arr2) {
+            // Same array
+            return true;
+        }
+
+        if (arr1.length !== arr2.length) {
+            return false;
+        }
+
+        for (let i = 0; i < arr1.length; i++) {
+            if (comparator) {
+                if (!comparator(arr1[i], arr2[i])) {
+                    return false;
+                }
+            } else {
+                if (arr1[i] !== arr2[i]) {
+                    return false;
+                }
+            }
+        }
+
+        return true;
+    }
+}
+
index 9fcd873..2f32c22 100644 (file)
@@ -1,8 +1,8 @@
 <eg-bucket-dialog #addBasketToBucketDialog>
 </eg-bucket-dialog>
 
-<div class="row">
-  <div class="col-lg-4 pr-1">
+<div class="d-flex justify-content-end">
+  <div class="pr-1">
     <div class="float-right">
       <!-- note basket view link does not propagate search params -->
       <a routerLink="/staff/catalog/search" [queryParams]="{showBasket: true}" 
       </a>
     </div>
   </div>
-  <div class="col-lg-8 pl-1">
-    <select class="form-control" 
+  <div class="">
+    <div ngbDropdown placement="bottom-right">
+      <button class="btn btn-light" id="basketActions"
         [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="export_marc" i18n>Export Records</option>
-      <option value="clear"  i18n>Clear Basket</option>
-    </select>
+        ngbDropdownToggle i18n>Basket Actions</button>
+      <div ngbDropdownMenu aria-labelledby="basketActions">
+      <button class="dropdown-item" 
+        (click)="applyAction('view')" i18n>View Basket</button>
+      <button class="dropdown-item" 
+        (click)="applyAction('hold')" i18n>Place Hold</button>
+      <button class="dropdown-item" 
+        (click)="applyAction('print')" i18n>Print Title Details</button>
+      <button class="dropdown-item" 
+        (click)="applyAction('email')" i18n>Email Title Details</button>
+      <button class="dropdown-item" 
+        (click)="applyAction('bucket')" i18n>Add Basket to Bucket</button>
+      <button class="dropdown-item" 
+        (click)="applyAction('export_marc')" i18n>Export Records</button>
+      <button class="dropdown-item" 
+        (click)="applyAction('clear')" i18n>Clear Basket</button>
+    </div>
   </div>
 </div>
index c42b7dd..fa4f49f 100644 (file)
@@ -37,7 +37,8 @@ export class BasketActionsComponent implements OnInit {
 
     // TODO: confirmation dialogs?
 
-    applyAction() {
+    applyAction(action: string) {
+        this.basketAction = action;
         console.debug('Performing basket action', this.basketAction);
 
         switch (this.basketAction) {
index e78a951..e0fbff8 100644 (file)
@@ -26,6 +26,7 @@ import {HoldingsMaintenanceComponent} from './record/holdings.component';
 import {ConjoinedComponent} from './record/conjoined.component';
 import {CnBrowseComponent} from './cnbrowse.component';
 import {CnBrowseResultsComponent} from './cnbrowse/results.component';
+import {SearchTemplatesComponent} from './search-templates.component';
 
 @NgModule({
   declarations: [
@@ -47,6 +48,7 @@ import {CnBrowseResultsComponent} from './cnbrowse/results.component';
     BrowseResultsComponent,
     ConjoinedComponent,
     HoldingsMaintenanceComponent,
+    SearchTemplatesComponent,
     CnBrowseComponent,
     CnBrowseResultsComponent
   ],
index 3c1ba95..86501fc 100644 (file)
@@ -17,6 +17,8 @@ export class StaffCatalogService {
     routeIndex = 0;
     defaultSearchOrg: IdlObject;
     defaultSearchLimit: number;
+    // Track the current template through route changes.
+    selectedTemplate: string;
 
     // TODO: does unapi support pref-lib for result-page copy counts?
     prefOrg: IdlObject;
index 1dac536..1b6dac1 100644 (file)
@@ -48,7 +48,8 @@ export class CatalogResolver implements Resolve<Promise<any[]>> {
             'cat.marcedit.stack_subfields',
             'cat.marcedit.flateditor',
             'cat.holdings_show_copies',
-            'cat.holdings_show_vols'
+            'cat.holdings_show_vols',
+            'opac.staff_saved_search.size'
         ]).then(settings => {
             this.staffCat.defaultSearchOrg =
                 this.org.get(settings['eg.search.search_lib']);
index 8d6e348..72386f2 100644 (file)
@@ -340,7 +340,13 @@ TODO focus search input
         </div>
       </div>
     </div>
-    <div class="row mt-2">
+    <div class="row mt-1">
+      <div class="col-lg-12">
+        <eg-catalog-search-templates [searchTab]="searchTab">
+        </eg-catalog-search-templates>
+      </div>
+    </div>
+    <div class="row mt-1">
       <div class="col-lg-12">
         <eg-catalog-basket-actions></eg-catalog-basket-actions>
       </div>
index c8cee02..0e010eb 100644 (file)
@@ -1,5 +1,5 @@
 import {Component, OnInit, AfterViewInit, Renderer2} from '@angular/core';
-import {Router} from '@angular/router';
+import {ActivatedRoute} 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';
@@ -23,12 +23,22 @@ export class SearchFormComponent implements OnInit, AfterViewInit {
 
     constructor(
         private renderer: Renderer2,
-        private router: Router,
+        private route: ActivatedRoute,
         private org: OrgService,
         private cat: CatalogService,
         private staffCat: StaffCatalogService
     ) {
         this.copyLocations = [];
+
+        // Some search scenarios, like rendering a search template,
+        // will not be searchable and thus not resovle to a specific
+        // search tab.  Check to see if a specific tab is requested
+        // via the URL.
+        this.route.queryParams.subscribe(params => {
+            if (params.searchTab) {
+                this.searchTab = params.searchTab;
+            }
+        });
     }
 
     ngOnInit() {
@@ -114,7 +124,10 @@ export class SearchFormComponent implements OnInit, AfterViewInit {
      * or if any advanced options are selected.
      */
     showFilters(): boolean {
-        return this.showSearchFilters;
+        // Note that filters may become active due to external
+        // actions on the search context.  Always show the filters
+        // if filter values are applied.
+        return this.showSearchFilters || this.filtersActive();
     }
 
     toggleFilters() {
@@ -194,51 +207,21 @@ export class SearchFormComponent implements OnInit, AfterViewInit {
         // Form search overrides basket display
         this.context.showBasket = false;
 
-        switch (this.searchTab) {
+        this.context.scrub(this.searchTab);
 
-            case 'term': // AKA keyword search
-                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;
-                this.context.termSearch.facetFilters = [];
-                this.staffCat.search();
-                break;
+        switch (this.searchTab) {
 
+            case 'term':
             case 'ident':
-                this.context.marcSearch.reset();
-                this.context.browseSearch.reset();
-                this.context.termSearch.reset();
-                this.context.cnBrowseSearch.reset();
-                this.staffCat.search();
-                break;
-
             case 'marc':
-                this.context.browseSearch.reset();
-                this.context.termSearch.reset();
-                this.context.identSearch.reset();
-                this.context.cnBrowseSearch.reset();
                 this.staffCat.search();
                 break;
 
             case 'browse':
-                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;
         }
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/search-templates.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/search-templates.component.html
new file mode 100644 (file)
index 0000000..2698bef
--- /dev/null
@@ -0,0 +1,99 @@
+<eg-confirm-dialog #confirmDelete
+  i18n-dialogTitle i18n-dialogBody
+  dialogTitle="Confirm Delete"
+  dialogBody="Delete saved search template '{{selectedTemplate()}}'?">
+</eg-confirm-dialog>
+
+<eg-confirm-dialog #confirmDeleteAll
+  i18n-dialogTitle i18n-dialogBody
+  dialogTitle="Confirm Delete All"
+  dialogBody="Delete all saved templates?">
+</eg-confirm-dialog>
+
+<eg-confirm-dialog #confirmDeleteSearches
+  i18n-dialogTitle i18n-dialogBody
+  dialogTitle="Confirm clear searches"
+  dialogBody="Clear all recent searches?">
+</eg-confirm-dialog>
+
+<ng-template #searchName let-tab="tab" let-query="query" i18n>
+  <ng-container [ngSwitch]="tab">
+    <span *ngSwitchCase="'term'">Search:</span>
+    <span *ngSwitchCase="'ident'">Identifier:</span>
+    <span *ngSwitchCase="'marc'">MARC:</span>
+    <span *ngSwitchCase="'browse'">Browse:</span>
+  </ng-container> {{query}}
+</ng-template>
+
+<eg-string key='eg.catalog.recent_search.label' [template]="searchName">
+</eg-string>
+
+<div class="d-flex justify-content-end">
+  
+  <ng-container *ngIf="recentSearchesCount > 0">
+    <div ngbDropdown placement="bottom-right">
+      <button class="btn btn-light" id="recentSearches"
+        ngbDropdownToggle i18n>Recent Searches</button>
+      <div ngbDropdownMenu aria-labelledby="recentSearches">
+      <button class="dropdown-item" (click)="deleteSearches()" 
+        [disabled]="searches.length === 0" i18n>Clear Recent Searches</button>
+      <div class="dropdown-divider"></div>
+      <button [disabled]="true" *ngIf="searches.length === 0" 
+        class="dropdown-item font-italic" i18n>No Recent Searches</button>
+      <button *ngFor="let search of sortSearches()"
+        class="dropdown-item" 
+        (click)="searchSelected(search)"
+        [routerLink]="getSearchPath(search)"
+        [queryParams]="search.params">{{search.name}}</button>
+      </div>
+    </div>
+  </ng-container>
+
+  <div ngbDropdown placement="bottom-right">
+    <button class="btn btn-light" id="searchTemplates" 
+      ngbDropdownToggle i18n>Search Templates</button>
+    <div ngbDropdownMenu aria-labelledby="searchTemplates">
+      <button class="dropdown-item" i18n (click)="open()"
+        [disabled]="searchTab === 'cnbrowse'">Save Template</button>
+      <button class="dropdown-item" (click)="deleteTemplate()" 
+        [disabled]="!selectedTemplate()" i18n>Delete Selected</button>
+      <button class="dropdown-item" (click)="deleteAllTemplates()" 
+        [disabled]="templates.length === 0" i18n>Delete All Templates</button>
+      <div class="dropdown-divider"></div>
+      <button [disabled]="true" *ngIf="templates.length === 0" 
+        class="dropdown-item font-italic" i18n>No Saved Templates</button>
+      <button *ngFor="let tmpl of sortTemplates()"
+        class="dropdown-item" 
+        (click)="templateSelected(tmpl)"
+        [ngClass]="{'font-weight-bold': tmpl.name === selectedTemplate()}"
+        [routerLink]="getSearchPath(tmpl)"
+        [queryParams]="tmpl.params">{{tmpl.name}}</button>
+    </div>
+  </div>
+</div>
+
+<ng-template #dialogContent>
+  <div class="modal-header bg-info">
+    <h4 class="modal-title" i18n>Save Template</h4>
+    <button type="button" class="close" 
+      i18n-aria-label aria-label="Close" (click)="close()">
+      <span aria-hidden="true">&times;</span>
+    </button>
+  </div>
+  <div class="modal-body">
+    <div class="row">
+      <div class="col-lg-4" i18n id="templateNameLabel">Template Name:</div>
+      <div class="col-lg-6">
+        <input class="form-control" [(ngModel)]="templateName"
+          aria-labelledby="templateNameLabel"/>
+      </div>
+    </div>
+  </div>
+  <div class="modal-footer">
+    <button type="button" class="btn btn-success" 
+      (click)="saveTemplate()" i18n>Save</button>
+    <button type="button" class="btn btn-warning" 
+      (click)="close()" i18n>Cancel</button>
+  </div>
+</ng-template>
+
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/search-templates.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/search-templates.component.ts
new file mode 100644 (file)
index 0000000..67b024f
--- /dev/null
@@ -0,0 +1,336 @@
+import {Component, OnInit, Input, ViewChild} from '@angular/core';
+import {OrgService} from '@eg/core/org.service';
+import {StoreService} from '@eg/core/store.service';
+import {ServerStoreService} from '@eg/core/server-store.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {DialogComponent} from '@eg/share/dialog/dialog.component';
+import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component';
+import {StringService} from '@eg/share/string/string.service';
+import {CatalogService} from '@eg/share/catalog/catalog.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 {AnonCacheService} from '@eg/share/util/anon-cache.service';
+import {NgbModal, NgbModalOptions} from '@ng-bootstrap/ng-bootstrap';
+
+const SAVED_TEMPLATES_SETTING = 'eg.catalog.search_templates';
+const RECENT_SEARCHES_KEY = 'eg.catalog.recent_searches';
+
+class SearchTemplate {
+    name: string;
+    params: any = {}; // routerLink-compatible URL params object
+    addTime?: number;
+    constructor(name: string, params: any) {
+        this.name = name;
+        this.params = params;
+    }
+}
+
+@Component({
+  selector: 'eg-catalog-search-templates',
+  templateUrl: 'search-templates.component.html'
+})
+export class SearchTemplatesComponent extends DialogComponent implements OnInit {
+
+    recentSearchesCount = 0;
+    context: CatalogSearchContext;
+    templates: SearchTemplate[] = [];
+    searches: SearchTemplate[] = [];
+    searchesCacheKey: string;
+    templateName: string;
+
+    @Input() searchTab: string;
+
+    @ViewChild('confirmDelete') confirmDelete: ConfirmDialogComponent;
+    @ViewChild('confirmDeleteAll') confirmDeleteAll: ConfirmDialogComponent;
+    @ViewChild('confirmDeleteSearches') confirmDeleteSearches: ConfirmDialogComponent;
+
+    constructor(
+        private org: OrgService,
+        private store: StoreService,             // anon cache key
+        private serverStore: ServerStoreService, // search templates
+        private cache: AnonCacheService,         // recent searches
+        private strings: StringService,
+        private cat: CatalogService,
+        private catUrl: CatalogUrlService,
+        private staffCat: StaffCatalogService,
+        private modal: NgbModal) {
+        super(modal);
+
+        this.store.addLoginSessionKey(RECENT_SEARCHES_KEY);
+    }
+
+    ngOnInit() {
+        this.context = this.staffCat.searchContext;
+        console.log('ngOnInit() with selected = ', this.staffCat.selectedTemplate);
+
+        this.org.settings('opac.staff_saved_search.size').then(sets => {
+            const size = sets['opac.staff_saved_search.size'] || 0;
+            if (!size) { return; }
+
+            this.recentSearchesCount = Number(size);
+
+            this.getSearches().then(_ => {
+                this.searches.forEach(
+                    s => s.params.ridx = ++this.staffCat.routeIndex);
+
+                // Save the search that runs on page load.
+                this.saveSearch(this.context);
+                // Watch for new searches
+                this.cat.onSearchComplete.subscribe(ctx => this.saveSearch(ctx));
+            });
+        });
+
+        this.getTemplates();
+    }
+
+    selectedTemplate(): string {
+        return this.staffCat.selectedTemplate;
+    }
+
+    getSearches(): Promise<any> {
+        this.searches = [];
+
+        if (this.searchesCacheKey) {
+            // We've already started saving searches in the current instance.
+
+            return this.cache.getItem(this.searchesCacheKey, 'searches')
+                .then(searches => this.searches = searches || []);
+        }
+
+        const cacheKey = this.store.getLoginSessionItem(RECENT_SEARCHES_KEY);
+
+        if (cacheKey) {
+            // We have a saved search key, see if we have any searches.
+
+            this.searchesCacheKey = cacheKey;
+            return this.cache.getItem(this.searchesCacheKey, 'searches')
+                .then(searches => this.searches = searches || []);
+
+        } else {
+            // No saved searches in progress.  Start from scratch.
+
+            return this.cache.setItem(null, 'searches', []) // generates cache key
+            .then(cKey => {
+                this.searchesCacheKey = cKey;
+                this.store.setLoginSessionItem(RECENT_SEARCHES_KEY, cKey);
+            });
+        }
+    }
+
+    searchSelected(search: SearchTemplate) {
+        // increment the router index in case the template is used
+        // twice in a row.
+        search.params.ridx = ++this.staffCat.routeIndex;
+    }
+
+    // Returns searches most recent first
+    sortSearches(): SearchTemplate[] {
+        return this.searches.sort((a, b) => a.addTime > b.addTime ? -1 : 1);
+    }
+
+    deleteSearches() {
+        this.confirmDeleteSearches.open().subscribe(yes => {
+            if (!yes) { return; }
+            this.searches = [];
+            this.cache.setItem(this.searchesCacheKey, 'searches', []);
+        });
+    }
+
+    getSearchPath(search: SearchTemplate): string {
+        return search.params.searchTab === 'browse' ?
+            '/staff/catalog/browse' : '/staff/catalog/search';
+    }
+
+    saveSearch(context: CatalogSearchContext) {
+
+        let matchFound = false;
+        this.searches.forEach(sch => {
+            const tmpCtx = this.catUrl.fromUrlHash(sch.params);
+            if (tmpCtx.equals(context)) {
+                matchFound = true;
+            }
+        });
+
+        if (matchFound) { return; }
+
+        let query: string;
+        switch (this.searchTab) {
+            case 'term':
+                query = context.termSearch.query[0];
+                break;
+            case 'marc':
+                query = context.marcSearch.values[0];
+                break;
+            case 'ident':
+                query = context.identSearch.value;
+                break;
+            case 'browse':
+                query = context.browseSearch.value;
+                break;
+            case 'cnbrowse':
+                query = context.cnBrowseSearch.value;
+                break;
+        }
+
+        if (!query) {
+            // no query means nothing was searchable.
+            return;
+        }
+
+        this.strings.interpolate(
+            'eg.catalog.recent_search.label',
+            {query: query, tab: this.searchTab}
+
+        ).then(txt => {
+
+            const urlParams = this.prepareSearch(context);
+            const search = new SearchTemplate(txt, urlParams);
+            search.addTime = new Date().getTime();
+
+            this.searches.unshift(search);
+
+            if (this.searches.length > this.recentSearchesCount) {
+                // this bit of magic will lop off the end of the array.
+                this.searches.length = this.recentSearchesCount;
+            }
+
+            this.cache.setItem(
+                this.searchesCacheKey, 'searches', this.searches)
+            .then(_ => search.params.ridx = ++this.staffCat.routeIndex);
+        });
+    }
+
+    getTemplates(): Promise<any> {
+        this.templates = [];
+
+        return this.serverStore.getItem(SAVED_TEMPLATES_SETTING).then(
+            templates => {
+                if (templates && templates.length) {
+                    this.templates = templates;
+
+                    // route index required to force the route to take
+                    // effect.  See ./catalog.service.ts
+                    this.templates.forEach(tmpl =>
+                        tmpl.params.ridx = ++this.staffCat.routeIndex);
+                }
+            }
+        );
+    }
+
+    sortTemplates(): SearchTemplate[] {
+        return this.templates.sort((a, b) =>
+            a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1);
+    }
+
+    templateSelected(tmpl: SearchTemplate) {
+        this.staffCat.selectedTemplate = tmpl.name;
+        // increment the router index in case the template is used
+        // twice in a row.
+        tmpl.params.ridx = ++this.staffCat.routeIndex;
+        console.log('selected template = ', this.staffCat.selectedTemplate);
+    }
+
+    // Adds dummy query content to the context object so the
+    // CatalogUrlService will recognize the content as searchable
+    // and therefor URL-encodable.
+    addDummyQueries(context: CatalogSearchContext) {
+        context.termSearch.query = context.termSearch.query.map(q => 'x');
+        context.marcSearch.values = context.marcSearch.values.map(q => 'x');
+        context.browseSearch.value = 'x';
+        context.identSearch.value = 'x';
+    }
+
+    // Remove the dummy query content before saving the search template.
+    removeDummyQueries(urlParams: any) {
+
+        if (Array.isArray(urlParams.query)) {
+            const arr = urlParams.query as Array<string>;
+            urlParams.query = arr.map(q => '');
+        } else {
+            urlParams.query = '';
+        }
+
+        if (Array.isArray(urlParams.marcValue)) {
+            const arr = urlParams.marcValue as Array<string>;
+            urlParams.marcValue = arr.map(q => '');
+        } else {
+            urlParams.marcValue = '';
+        }
+
+        urlParams.identQuery = '';
+        urlParams.browseTerm = '';
+    }
+
+    // Prepares a save-able URL params hash from the current context.
+    prepareSearch(ctx: CatalogSearchContext,
+        withDummyData?: boolean): {[key: string]: string | string[]} {
+
+        const context = ctx.clone();
+
+        if (withDummyData) {
+            this.addDummyQueries(context);
+        }
+
+        context.scrub(this.searchTab);
+
+        const urlParams = this.catUrl.toUrlParams(context);
+
+        if (withDummyData) {
+            this.removeDummyQueries(urlParams);
+        }
+
+        // Some data should not go into the template.
+        delete urlParams.org;
+        delete urlParams.ridx;
+
+        urlParams.searchTab = this.searchTab;
+
+        return urlParams;
+    }
+
+    saveTemplate(): Promise<any> {
+        if (!this.templateName) { return Promise.resolve(); }
+
+        this.staffCat.selectedTemplate = this.templateName;
+
+        const urlParams = this.prepareSearch(this.context, true);
+
+        this.templates.push(
+            new SearchTemplate(this.templateName, urlParams));
+
+        return this.applyTemplateChanges().then(_ => this.close());
+    }
+
+    applyTemplateChanges(): Promise<any> {
+        return this.serverStore.setItem(SAVED_TEMPLATES_SETTING, this.templates);
+    }
+
+    deleteTemplate() {
+        this.confirmDelete.open().subscribe(yes => {
+            if (!yes) { return; }
+
+            const templates: SearchTemplate[] = [];
+            this.templates.forEach(tmpl => {
+                if (tmpl.name !== this.staffCat.selectedTemplate) {
+                    templates.push(tmpl);
+                }
+            });
+
+            this.templates = templates;
+            this.staffCat.selectedTemplate = '';
+            this.applyTemplateChanges();
+        });
+    }
+
+    deleteAllTemplates() {
+        this.confirmDeleteAll.open().subscribe(yes => {
+            if (!yes) { return; }
+            this.templates = [];
+            this.staffCat.selectedTemplate = '';
+            this.applyTemplateChanges();
+        });
+    }
+}
+
+
index 67b0471..e8dc76a 100644 (file)
@@ -19970,3 +19970,14 @@ VALUES (
         'cwst', 'label'
     )
 );
+
+INSERT INTO config.workstation_setting_type (name, grp, datatype, label)
+VALUES (
+    'eg.catalog.search_templates', 'gui', 'object',
+    oils_i18n_gettext(
+        'eg.catalog.search_templates',
+        'Staff Catalog Search Templates',
+        'cwst', 'label'
+    )
+);
+
diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.data.search-templates.sql b/Open-ILS/src/sql/Pg/upgrade/XXXX.data.search-templates.sql
new file mode 100644 (file)
index 0000000..9526bd6
--- /dev/null
@@ -0,0 +1,17 @@
+
+BEGIN;
+
+--SELECT evergreen.upgrade_deps_block_check('TODO', :eg_version);
+
+INSERT INTO config.workstation_setting_type (name, grp, datatype, label)
+VALUES (
+    'eg.catalog.search_templates', 'gui', 'object',
+    oils_i18n_gettext(
+        'eg.catalog.search_templates',
+        'Staff Catalog Search Templates',
+        'cwst', 'label'
+    )
+);
+
+COMMIT;
+