[open-ils-commits] [GIT] Evergreen ILS branch master updated. 36973b4aad31f6b191d4b4672cbb2c18811fefc9

Evergreen Git git at git.evergreen-ils.org
Wed Jul 31 15:26:10 EDT 2019


This is an automated email from the git hooks/post-receive script. It was
generated because a ref change was pushed to the repository containing
the project "Evergreen ILS".

The branch, master has been updated
       via  36973b4aad31f6b191d4b4672cbb2c18811fefc9 (commit)
       via  fc19c9f3dbffb739519c0bc3d3be484c5bb991c8 (commit)
       via  763fe1a0b39474f07465ec446d7b3724dc518e7d (commit)
      from  6b678e1ccaf972d297ba1f833129d0efe6dc54a1 (commit)

Those revisions listed above that are new to this repository have
not appeared on any other notification email; so we list those
revisions in full, below.

- Log -----------------------------------------------------------------
commit 36973b4aad31f6b191d4b4672cbb2c18811fefc9
Author: Galen Charlton <gmc at equinoxinitiative.org>
Date:   Wed Jul 31 15:25:28 2019 -0400

    LP#1837478: stamp DB update
    
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>

diff --git a/Open-ILS/src/sql/Pg/002.schema.config.sql b/Open-ILS/src/sql/Pg/002.schema.config.sql
index efac488bd6..b516ac60e8 100644
--- a/Open-ILS/src/sql/Pg/002.schema.config.sql
+++ b/Open-ILS/src/sql/Pg/002.schema.config.sql
@@ -92,7 +92,7 @@ CREATE TRIGGER no_overlapping_deps
     BEFORE INSERT OR UPDATE ON config.db_patch_dependencies
     FOR EACH ROW EXECUTE PROCEDURE evergreen.array_overlap_check ('deprecates');
 
-INSERT INTO config.upgrade_log (version, applied_to) VALUES ('1168', :eg_version); -- csharp/khuckins/gmcharlt
+INSERT INTO config.upgrade_log (version, applied_to) VALUES ('1169', :eg_version); -- berick/csharp/gmcharlt
 
 CREATE TABLE config.bib_source (
 	id		SERIAL	PRIMARY KEY,
diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.data.search-templates.sql b/Open-ILS/src/sql/Pg/upgrade/1169.data.search-templates.sql
similarity index 81%
rename from Open-ILS/src/sql/Pg/upgrade/XXXX.data.search-templates.sql
rename to Open-ILS/src/sql/Pg/upgrade/1169.data.search-templates.sql
index 9526bd6ba9..ec43e99ae5 100644
--- a/Open-ILS/src/sql/Pg/upgrade/XXXX.data.search-templates.sql
+++ b/Open-ILS/src/sql/Pg/upgrade/1169.data.search-templates.sql
@@ -1,7 +1,7 @@
 
 BEGIN;
 
---SELECT evergreen.upgrade_deps_block_check('TODO', :eg_version);
+SELECT evergreen.upgrade_deps_block_check('1169', :eg_version);
 
 INSERT INTO config.workstation_setting_type (name, grp, datatype, label)
 VALUES (

commit fc19c9f3dbffb739519c0bc3d3be484c5bb991c8
Author: Bill Erickson <berickxx at gmail.com>
Date:   Thu Jul 25 10:24:15 2019 -0400

    LP1837478 Ang catalog recents/templates release notes
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Chris Sharp <csharp at georgialibraries.org>
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>

diff --git a/docs/RELEASE_NOTES_NEXT/Client/ang-staff-cat-templates.adoc b/docs/RELEASE_NOTES_NEXT/Client/ang-staff-cat-templates.adoc
new file mode 100644
index 0000000000..053bca3d35
--- /dev/null
+++ b/docs/RELEASE_NOTES_NEXT/Client/ang-staff-cat-templates.adoc
@@ -0,0 +1,25 @@
+Angular Staff Catalog Recent Searches & Templates
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Recent Searches
++++++++++++++++
+
+Adds support for Recent Searches in the Angular staff catalog, consistent
+with TPAC staff recent searches.  Setting a value for the library setting
+'opac.staff_saved_search.size' is required for the recent searches to appear.
+
+Search Templates
+++++++++++++++++
+
+Adds support for named catalog search templates.  Templates allow staff to
+create predefined searches (e.g. title =, subject =, format =, etc.) 
+where all that's left do to perform the search is fill in the search 
+values.
+
+Templates may be built from any of the search tabs -- search, numeric search, 
+marc, and browse -- except shelf browse, which uses no filters.
+
+Templates are stored by default as workstation settings, using the setting
+key 'eg.catalog.search_templates'.
+
+

commit 763fe1a0b39474f07465ec446d7b3724dc518e7d
Author: Bill Erickson <berickxx at gmail.com>
Date:   Mon Jul 22 17:50:01 2019 -0400

    LP1837478 Angular Catalog Recent Searches & Templates
    
    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 at gmail.com>
    Signed-off-by: Chris Sharp <csharp at georgialibraries.org>
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>

diff --git a/Open-ILS/src/eg2/src/app/share/catalog/catalog-url.service.ts b/Open-ILS/src/eg2/src/app/share/catalog/catalog-url.service.ts
index 91922d48d5..4c45a4e3c7 100644
--- a/Open-ILS/src/eg2/src/app/share/catalog/catalog-url.service.ts
+++ b/Open-ILS/src/eg2/src/app/share/catalog/catalog-url.service.ts
@@ -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'));
         }
diff --git a/Open-ILS/src/eg2/src/app/share/catalog/catalog.service.ts b/Open-ILS/src/eg2/src/app/share/catalog/catalog.service.ts
index 9cff2c4e5d..2aaaf1f3ae 100644
--- a/Open-ILS/src/eg2/src/app/share/catalog/catalog.service.ts
+++ b/Open-ILS/src/eg2/src/app/share/catalog/catalog.service.ts
@@ -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> {
diff --git a/Open-ILS/src/eg2/src/app/share/catalog/search-context.ts b/Open-ILS/src/eg2/src/app/share/catalog/search-context.ts
index ef0fd552ea..041d710a4b 100644
--- a/Open-ILS/src/eg2/src/app/share/catalog/search-context.ts
+++ b/Open-ILS/src/eg2/src/app/share/catalog/search-context.ts
@@ -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
index 0000000000..10125be2af
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/share/util/array.spec.ts
@@ -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
index 0000000000..a66f32607b
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/share/util/array.ts
@@ -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;
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/basket-actions.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/basket-actions.component.html
index 9fcd873cee..2f32c22fc5 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/basket-actions.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/basket-actions.component.html
@@ -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}" 
@@ -12,18 +12,26 @@
       </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>
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/basket-actions.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/basket-actions.component.ts
index c42b7ddc75..fa4f49f0a2 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/basket-actions.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/basket-actions.component.ts
@@ -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) {
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/catalog.module.ts b/Open-ILS/src/eg2/src/app/staff/catalog/catalog.module.ts
index e78a951e62..e0fbff851d 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/catalog.module.ts
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/catalog.module.ts
@@ -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
   ],
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/catalog.service.ts b/Open-ILS/src/eg2/src/app/staff/catalog/catalog.service.ts
index 3c1ba9579a..86501fc10f 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/catalog.service.ts
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/catalog.service.ts
@@ -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;
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/resolver.service.ts b/Open-ILS/src/eg2/src/app/staff/catalog/resolver.service.ts
index 1dac53644e..1b6dac1d4a 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/resolver.service.ts
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/resolver.service.ts
@@ -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']);
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.html
index 8d6e34878a..72386f2621 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.html
@@ -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>
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.ts
index c8cee02105..0e010eb5ca 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.ts
@@ -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
index 0000000000..2698bef7fc
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/search-templates.component.html
@@ -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">×</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
index 0000000000..67b024f893
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/search-templates.component.ts
@@ -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;
+    }
+}
+
+ at 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();
+        });
+    }
+}
+
+
diff --git a/Open-ILS/src/sql/Pg/950.data.seed-values.sql b/Open-ILS/src/sql/Pg/950.data.seed-values.sql
index 67b04715fd..e8dc76a1fa 100644
--- a/Open-ILS/src/sql/Pg/950.data.seed-values.sql
+++ b/Open-ILS/src/sql/Pg/950.data.seed-values.sql
@@ -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
index 0000000000..9526bd6ba9
--- /dev/null
+++ b/Open-ILS/src/sql/Pg/upgrade/XXXX.data.search-templates.sql
@@ -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;
+

-----------------------------------------------------------------------

Summary of changes:
 .../src/app/share/catalog/catalog-url.service.ts   |   8 +-
 .../eg2/src/app/share/catalog/catalog.service.ts   |   9 +-
 .../eg2/src/app/share/catalog/search-context.ts    | 196 +++++++++++-
 Open-ILS/src/eg2/src/app/share/util/array.spec.ts  |  29 ++
 Open-ILS/src/eg2/src/app/share/util/array.ts       |  39 +++
 .../staff/catalog/basket-actions.component.html    |  36 ++-
 .../app/staff/catalog/basket-actions.component.ts  |   3 +-
 .../eg2/src/app/staff/catalog/catalog.module.ts    |   2 +
 .../eg2/src/app/staff/catalog/catalog.service.ts   |   2 +
 .../eg2/src/app/staff/catalog/resolver.service.ts  |   3 +-
 .../app/staff/catalog/search-form.component.html   |   8 +-
 .../src/app/staff/catalog/search-form.component.ts |  55 ++--
 .../staff/catalog/search-templates.component.html  |  99 ++++++
 .../staff/catalog/search-templates.component.ts    | 336 +++++++++++++++++++++
 Open-ILS/src/sql/Pg/002.schema.config.sql          |   2 +-
 Open-ILS/src/sql/Pg/950.data.seed-values.sql       |  11 +
 .../sql/Pg/upgrade/1169.data.search-templates.sql  |  17 ++
 .../Client/ang-staff-cat-templates.adoc            |  25 ++
 18 files changed, 817 insertions(+), 63 deletions(-)
 create mode 100644 Open-ILS/src/eg2/src/app/share/util/array.spec.ts
 create mode 100644 Open-ILS/src/eg2/src/app/share/util/array.ts
 create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/search-templates.component.html
 create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/search-templates.component.ts
 create mode 100644 Open-ILS/src/sql/Pg/upgrade/1169.data.search-templates.sql
 create mode 100644 docs/RELEASE_NOTES_NEXT/Client/ang-staff-cat-templates.adoc


hooks/post-receive
-- 
Evergreen ILS




More information about the open-ils-commits mailing list