LP#1806087 Move browse to tab; context segregation
authorBill Erickson <berickxx@gmail.com>
Mon, 10 Dec 2018 16:53:44 +0000 (11:53 -0500)
committerBill Erickson <berickxx@gmail.com>
Mon, 7 Jan 2019 14:58:44 +0000 (09:58 -0500)
Signed-off-by: Bill Erickson <berickxx@gmail.com>
14 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/staff/catalog/basket-actions.component.html
Open-ILS/src/eg2/src/app/staff/catalog/browse.component.html
Open-ILS/src/eg2/src/app/staff/catalog/browse/form.component.html [deleted file]
Open-ILS/src/eg2/src/app/staff/catalog/browse/form.component.ts [deleted file]
Open-ILS/src/eg2/src/app/staff/catalog/browse/results.component.ts
Open-ILS/src/eg2/src/app/staff/catalog/catalog.module.ts
Open-ILS/src/eg2/src/app/staff/catalog/catalog.service.ts
Open-ILS/src/eg2/src/app/staff/catalog/result/facets.component.ts
Open-ILS/src/eg2/src/app/staff/catalog/result/record.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 831c5d3..42432ee 100644 (file)
@@ -1,7 +1,8 @@
 import {Injectable} from '@angular/core';
 import {ParamMap} from '@angular/router';
 import {OrgService} from '@eg/core/org.service';
-import {CatalogSearchContext, FacetFilter} from './search-context';
+import {CatalogSearchContext, CatalogBrowseContext, CatalogMarcContext, 
+   CatalogTermContext, FacetFilter} from './search-context';
 import {CATALOG_CCVM_FILTERS} from './catalog.service';
 
 @Injectable()
@@ -19,35 +20,22 @@ export class CatalogUrlService {
     toUrlParams(context: CatalogSearchContext):
             {[key: string]: string | string[]} {
 
-        const params = {
-            query: [],
-            fieldClass: [],
-            joinOp: [],
-            matchOp: [],
-            facets: [],
-            identQuery: null,
-            identQueryType: null,
-            org: null,
-            limit: null,
-            offset: null,
-            copyLocations: null,
-            browsePivot: null,
-            hasBrowseEntry: null,
-            marcTag: [''],
-            marcSubfield: [''],
-            marcValue: ['']
-        };
-
-        params.org = context.searchOrg.id();
-
-        params.limit = context.pager.limit;
+        const params: any = {};
+
+        if (context.searchOrg) {
+            params.org = context.searchOrg.id();
+        }
+
+        if (context.pager.limit) {
+            params.limit = context.pager.limit;
+        }
+
         if (context.pager.offset) {
             params.offset = context.pager.offset;
         }
 
         // These fields can be copied directly into place
-        ['format', 'sort', 'available', 'global', 'identQuery', 
-            'identQueryType', 'basket', 'browsePivot', 'hasBrowseEntry']
+        ['limit', 'offset', 'sort', 'global', 'showBasket', 'sort']
         .forEach(field => {
             if (context[field]) {
                 // Only propagate applied values to the URL.
@@ -55,48 +43,87 @@ 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;
+        if (context.marcSearch.isSearchable()) {
+            const ms = context.marcSearch;
+            params.marcTag = [];
+            params.marcSubfield = [];
+            params.marcValue = [];
+
+            ms.values.forEach((val, idx) => {
+                if (val !== '') {
+                    params.marcTag.push(ms.tags[idx]);
+                    params.marcSubfield.push(ms.subfields[idx]);
+                    params.marcValue.push(ms.values[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
-                params[field][idx] = context[field][idx];
-            });
-        });
+        if (context.identSearch.isSearchable()) {
+            params.identQuery = context.identSearch.value;
+            params.identQueryType = context.identSearch.queryType;
+        }
 
-        context.marcValue.filter(v => v !== '').forEach((val, idx) => {
-            ['marcValue', 'marcTag', 'marcSubfield'].forEach(field => {
-                params[field][idx] = context[field][idx];
-            });
-        });
+        if (context.browseSearch.isSearchable()) {
+            params.browseTerm = context.browseSearch.value;
+            params.browseClass = context.browseSearch.fieldClass;
+            if (context.browseSearch.pivot) {
+                params.browsePivot = context.browseSearch.pivot;
+            }
+        }
+
+        if (context.termSearch.isSearchable()) {
+
+            const ts = context.termSearch;
 
-        // CCVM filters are encoded as comma-separated lists
-        Object.keys(context.ccvmFilters).forEach(code => {
-            if (context.ccvmFilters[code] &&
-                context.ccvmFilters[code][0] !== '') {
-                params[code] = context.ccvmFilters[code].join(',');
+            params.query = [];
+            params.fieldClass = [];
+            params.joinOp = [];
+            params.matchOp = [];
+
+            if (ts.format) {
+                params.format = ts.format;
             }
-        });
 
-        // Each facet is a JSON encoded blob of class, name, and value
-        context.facetFilters.forEach(facet => {
-            params.facets.push(JSON.stringify({
-                c : facet.facetClass,
-                n : facet.facetName,
-                v : facet.facetValue
-            }));
-        });
+            if (ts.available) {
+                params.available = ts.available;
+            }
+
+            if (ts.hasBrowseEntry) {
+                params.hasBrowseEntry = ts.hasBrowseEntry;
+            }
+
+            ts.query.forEach((val, idx) => {
+                if (val !== '') {
+                    params.query.push(ts.query[idx]);
+                    params.fieldClass.push(ts.fieldClass[idx]);
+                    params.joinOp.push(ts.joinOp[idx]);
+                    params.matchOp.push(ts.matchOp[idx]);
+                }
+            });
 
-        if (context.copyLocations.length && context.copyLocations[0] !== '') {
-            params.copyLocations = context.copyLocations.join(',');
+            // CCVM filters are encoded as comma-separated lists
+            Object.keys(ts.ccvmFilters).forEach(code => {
+                if (ts.ccvmFilters[code] &&
+                    ts.ccvmFilters[code][0] !== '') {
+                    params[code] = ts.ccvmFilters[code].join(',');
+                }
+            });
+
+            // Each facet is a JSON encoded blob of class, name, and value
+            if (ts.facetFilters.length) {
+                params.facets = [];
+                ts.facetFilters.forEach(facet => {
+                    params.facets.push(JSON.stringify({
+                        c : facet.facetClass,
+                        n : facet.facetName,
+                        v : facet.facetValue
+                    }));
+                });
+            }
+        
+            if (ts.copyLocations.length && ts.copyLocations[0] !== '') {
+                params.copyLocations = ts.copyLocations.join(',');
+            }
         }
 
         return params;
@@ -117,59 +144,92 @@ export class CatalogUrlService {
 
         // Reset query/filter args.  The will be reconstructed below.
         context.reset();
+        let val;
 
-        // These fields can be copied directly into place
-        ['format', 'sort', 'available', 'global', 'identQuery', 
-            'identQueryType', 'basket', 'browsePivot', 'hasBrowseEntry']
-        .forEach(field => {
-            const val = params.get(field);
-            if (val !== null) {
-                context[field] = val;
-            }
-        });
+        if (params.get('org')) {
+            context.searchOrg = this.org.get(+params.get('org'));
+        }
+
+        if (val = params.get('limit')) {
+            context.pager.limit = +val;
+        }
+
+        if (val = params.get('offset')) {
+            context.pager.offset = +val;
+        }
+
+        if (val = params.get('sort')) {
+            context.sort = val;
+        }
+
+        if (val = params.get('global')) {
+            context.global = val;
+        }
+
+        if (val = params.get('showBasket')) {
+            context.showBasket = val;
+        }
 
-        if (params.get('limit')) {
-            context.pager.limit = +params.get('limit');
+        if (params.get('marcValue')) {
+            context.marcSearch.tags = params.getAll('marcTag');
+            context.marcSearch.subfields = params.getAll('marcSubfield');
+            context.marcSearch.values = params.getAll('marcValue');
         }
 
-        if (params.get('offset')) {
-            context.pager.offset = +params.get('offset');
+        if (params.get('identQuery')) {
+            context.identSearch.value = params.get('identQuery');
+            context.identSearch.queryType = params.get('identQueryType');
         }
 
-        ['query', 'fieldClass', 'joinOp', 'matchOp'].forEach(field => {
-            const arr = params.getAll(field);
-            if (arr && arr.length) {
-                context[field] = arr;
+        if (params.get('browseTerm')) {
+            context.browseSearch.value = params.get('browseTerm');
+            context.browseSearch.fieldClass = params.get('browseClass');
+            if (params.has('browsePivot')) {
+                context.browseSearch.pivot = +params.get('browsePivot');
             }
-        });
+        }
+
+        const ts = context.termSearch;
 
-        ['marcValue', 'marcTag', 'marcSubfield'].forEach(field => {
-            const arr = params.getAll(field);
-            if (arr && arr.length) {
-                context[field] = arr;
+        if (params.has('hasBrowseEntry')) {
+            ts.hasBrowseEntry = params.get('hasBrowseEntry');
+
+        } else if (params.has('query')) {
+
+            if (params.has('format')) {
+                ts.format = params.get('format');
             }
-        });
 
-        CATALOG_CCVM_FILTERS.forEach(code => {
-            const val = params.get(code);
-            if (val) {
-                context.ccvmFilters[code] = val.split(/,/);
-            } else {
-                context.ccvmFilters[code] = [''];
+            if (params.get('available')) {
+                ts.available = Boolean(params.get('available'));
             }
-        });
 
-        params.getAll('facets').forEach(blob => {
-            const facet = JSON.parse(blob);
-            context.addFacet(new FacetFilter(facet.c, facet.n, facet.v));
-        });
+            ['query', 'fieldClass', 'joinOp', 'matchOp'].forEach(field => {
+                const arr = params.getAll(field);
+                if (params.has(field)) {
+                    ts[field] = params.getAll(field); // array
+                }
+            });
 
-        if (params.get('org')) {
-            context.searchOrg = this.org.get(+params.get('org'));
-        }
+            CATALOG_CCVM_FILTERS.forEach(code => {
+                const val = params.get(code);
+                if (val) {
+                    ts.ccvmFilters[code] = val.split(/,/);
+                } else {
+                    ts.ccvmFilters[code] = [''];
+                }
+            });
+
+            params.getAll('facets').forEach(blob => {
+                const facet = JSON.parse(blob);
+                ts.addFacet(new FacetFilter(facet.c, facet.n, facet.v));
+            });
 
-        if (params.get('copyLocations')) {
-            context.copyLocations = params.get('copyLocations').split(/,/);
+            if (params.get('copyLocations')) {
+                ts.copyLocations = params.get('copyLocations').split(/,/);
+            }
         }
     }
 }
+
+
index ccf9284..22a6744 100644 (file)
@@ -59,11 +59,12 @@ export class CatalogService {
     search(ctx: CatalogSearchContext): Promise<void> {
         ctx.searchState = CatalogSearchState.SEARCHING;
 
-        if (ctx.basket) {
+        if (ctx.showBasket) {
             return this.basketSearch(ctx);
-        } else if (ctx.marcValue[0] !== '') {
+        } else if (ctx.marcSearch.isSearchable()) {
             return this.marcSearch(ctx);
-        } else if (ctx.identQueryType === 'item_barcode') {
+        } else if (ctx.identSearch.isSearchable() && 
+            ctx.identSearch.queryType === 'item_barcode') {
             return this.barcodeSearch(ctx);
         } else {
             return this.querySearch(ctx);
@@ -74,7 +75,7 @@ export class CatalogService {
         return this.net.request(
             'open-ils.search',
             'open-ils.search.multi_home.bib_ids.by_barcode',
-            ctx.identQuery
+            ctx.identSearch.value
         ).toPromise().then(ids => {
             const result = {
                 count: ids.length,
@@ -110,7 +111,7 @@ export class CatalogService {
         let method = 'open-ils.search.biblio.marc';
         if (ctx.isStaff) { method += '.staff'; }
 
-        const queryStruct = ctx.compileMarcSearch();
+        const queryStruct = ctx.compileMarcSearchArgs();
 
         return this.net.request('open-ils.search', method, queryStruct)
         .toPromise().then(result => {
@@ -124,7 +125,14 @@ export class CatalogService {
     }
 
     querySearch(ctx: CatalogSearchContext): Promise<void> {
-        const fullQuery = ctx.compileSearch();
+        let fullQuery;
+
+        if (ctx.identSearch.isSearchable()) {
+            console.log('IDENT IS SEARCHABLE');
+            fullQuery = ctx.compileIdentSearchQuery();
+        } else {
+            fullQuery = ctx.compileTermSearchQuery();
+        }
 
         console.debug(`search query: ${fullQuery}`);
 
@@ -190,6 +198,10 @@ export class CatalogService {
             return Promise.reject('Cannot fetch facets without results');
         }
 
+        if (!ctx.result.facet_key) {
+            return Promise.resolve();
+        }
+
         if (this.lastFacetKey === ctx.result.facet_key) {
             ctx.result.facetData = this.lastFacetData;
             return Promise.resolve();
@@ -307,6 +319,7 @@ export class CatalogService {
 
     browse(ctx: CatalogSearchContext): Observable<any> {
         ctx.searchState = CatalogSearchState.SEARCHING;
+        const bs = ctx.browseSearch;
 
         let method = 'open-ils.search.browse';
         if (ctx.isStaff) {
@@ -316,10 +329,10 @@ export class CatalogService {
         return this.net.request(
             'open-ils.search',
             'open-ils.search.browse.staff', {
-                browse_class: ctx.fieldClass[0],
-                term: ctx.query[0],
+                browse_class: bs.fieldClass,
+                term: bs.value,
                 limit : ctx.pager.limit,
-                pivot: ctx.browsePivot,
+                pivot: bs.pivot,
                 org_unit: ctx.searchOrg.id()
             }
         ).pipe(tap(result => {
index b1b7e7a..51aa72d 100644 (file)
@@ -29,40 +29,156 @@ export class FacetFilter {
     }
 }
 
-// Not an angular service.
-// It's conceviable there could be multiple contexts.
-export class CatalogSearchContext {
+export class CatalogSearchResults {
+    ids: number[];
+    count: number;
+    [misc: string]: any;
 
-    // Search options and filters
-    available = false;
-    global = false;
-    sort: string;
+    constructor() {
+        this.ids = [];
+        this.count = 0;
+    }
+}
+
+export class CatalogBrowseContext {
+    value: string;
+    pivot: number;
+    fieldClass: string;
+
+    reset() {
+        this.value = '';
+        this.pivot = null;
+        this.fieldClass = 'title';
+    }
+
+    isSearchable(): boolean {
+        return (
+            this.value !== '' &&
+            this.fieldClass !== ''
+        );
+    }
+}
+
+export class CatalogMarcContext {
+    tags: string[];
+    subfields: string[];
+    values: string[];
+
+    reset() {
+        this.tags = [''];
+        this.values = [''];
+        this.subfields = [''];
+    }
+
+    isSearchable() {
+        return (
+            this.tags[0] !== '' &&
+            this.values[0] !== ''
+        );
+    }
+
+}
+
+export class CatalogIdentContext {
+    value: string;
+    queryType: string; 
+
+    reset() {
+        this.value = '';
+        this.queryType = '';
+    }
+
+    isSearchable() {
+        return (
+            this.value !== '' 
+            && this.queryType !== ''
+        );
+    }
+
+}
+
+export class CatalogTermContext {
     fieldClass: string[];
     query: string[];
-    identQuery: string;
-    identQueryType: string; // isbn, issn, etc.
     joinOp: string[];
     matchOp: string[];
     format: string;
-    searchOrg: IdlObject;
+    available = false;
     ccvmFilters: {[ccvmCode: string]: string[]};
     facetFilters: FacetFilter[];
-    isStaff: boolean;
-    basket = false;
     copyLocations: string[]; // ID's, but treated as strings in the UI.
-    browsePivot: number;
-    hasBrowseEntry: string; // "entryId,fieldId"
-    marcTag: string[];
-    marcSubfield: string[];
-    marcValue: string[];
     isMetarecord: boolean; // TODO
+    hasBrowseEntry: string; // "entryId,fieldId"
+
+    reset() {
+        this.query = [''];
+        this.fieldClass  = ['keyword'];
+        this.matchOp = ['contains'];
+        this.joinOp = [''];
+        this.ccvmFilters = {};
+        this.facetFilters = [];
+        this.copyLocations = [''];
+        this.format = '';
+        this.hasBrowseEntry = '';
+    }
+
+    isSearchable(): boolean {
+        return (
+            this.query[0] !== ''
+            || this.hasBrowseEntry !== ''
+        );
+    }
+
+    hasFacet(facet: FacetFilter): boolean {
+        return Boolean(
+            this.facetFilters.filter(f => f.equals(facet))[0]
+        );
+    }
+
+    removeFacet(facet: FacetFilter): void {
+        this.facetFilters = this.facetFilters.filter(f => !f.equals(facet));
+    }
+
+    addFacet(facet: FacetFilter): void {
+        if (!this.hasFacet(facet)) {
+            this.facetFilters.push(facet);
+        }
+    }
+
+    toggleFacet(facet: FacetFilter): void {
+        if (this.hasFacet(facet)) {
+            this.removeFacet(facet);
+        } else {
+            this.facetFilters.push(facet);
+        }
+    }
+
+}
+
+
+
+// Not an angular service.
+// It's conceviable there could be multiple contexts.
+export class CatalogSearchContext {
+
+    // Attributes that are used across different contexts.
+    sort: string;
+    isStaff: boolean;
+    showBasket: boolean;
+    searchOrg: IdlObject;
+    global: boolean;
+
+    termSearch: CatalogTermContext;
+    marcSearch: CatalogMarcContext;
+    identSearch: CatalogIdentContext;
+    browseSearch: CatalogBrowseContext;
 
     // Result from most recent search.
-    result: any = {};
+    result: CatalogSearchResults;
     searchState: CatalogSearchState = CatalogSearchState.PENDING;
 
     // List of IDs in page/offset context.
-    resultIds: number[] = [];
+    resultIds: number[];
 
     // Utility stuff
     pager: Pager;
@@ -70,9 +186,40 @@ export class CatalogSearchContext {
 
     constructor() {
         this.pager = new Pager();
+        this.termSearch = new CatalogTermContext();
+        this.marcSearch = new CatalogMarcContext();
+        this.identSearch = new CatalogIdentContext();
+        this.browseSearch = new CatalogBrowseContext();
         this.reset();
     }
 
+    /**
+     * Return search context to its default state, resetting search
+     * parameters and clearing any cached result data.
+     */
+    reset(): void {
+        this.pager.offset = 0;
+        this.sort = '';
+        this.showBasket = false;
+        this.result = new CatalogSearchResults();
+        this.resultIds = [];
+        this.searchState = CatalogSearchState.PENDING;
+        this.termSearch.reset();
+        this.marcSearch.reset();
+        this.identSearch.reset();
+        this.browseSearch.reset();
+    }
+
+    isSearchable(): boolean {
+        return (
+            this.showBasket ||
+            this.termSearch.isSearchable() ||
+            this.marcSearch.isSearchable() ||
+            this.identSearch.isSearchable() ||
+            this.browseSearch.isSearchable()
+        );
+    }
+
     // List of result IDs for the current page of data.
     currentResultIds(): number[] {
         const ids = [];
@@ -105,95 +252,20 @@ export class CatalogSearchContext {
         return null;
     }
 
-    /**
-     * Return search context to its default state, resetting search
-     * parameters and clearing any cached result data.
-     * This does not reset global filters like limit-to-available
-     * search-global, or search-org.
-     */
-    reset(): void {
-        this.pager.offset = 0;
-        this.format = '';
-        this.sort = '';
-        this.query = [''];
-        this.identQuery = null;
-        this.identQueryType = 'identifier|isbn';
-        this.fieldClass  = ['keyword'];
-        this.matchOp = ['contains'];
-        this.joinOp = [''];
-        this.ccvmFilters = {};
-        this.facetFilters = [];
-        this.result = {};
-        this.resultIds = [];
-        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 'basket';
-        }
-
-        if (this.identQuery && this.identQueryType) {
-            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 null;
-        }
-
-        if (this.hasBrowseEntry) {
-            // Limit results by records that link to browse entry
-            return 'browse';
-        }
-
-        // Query search
-        if (this.query.length && this.query[0] !== '') {
-            return 'query';
-        }
-
-        return null;
-    }
-
-    // Returns true if we have enough information to perform a browse.
-    isBrowsable(): boolean {
-        return this.fieldClass.length
-            && this.fieldClass[0] !== ''
-            && this.query.length
-            && this.query[0] !== ''
-            && this.searchOrg !== null;
-    }
-
-    compileMarcSearch(): any {
+    compileMarcSearchArgs(): 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 ms = this.marcSearch;
+
+        ms.values.forEach((val, idx) => {
+            if (val !== '') {
+                searches.push({
+                    restrict: [{
+                        subfield: ms.subfields[idx],
+                        tag: ms.tags[idx]
+                    }],
+                    term: ms.values[idx]
+                });
+            }
         });
 
         const args: any = {
@@ -212,94 +284,20 @@ export class CatalogSearchContext {
         return args;
     }
 
-    compileSearch(): string {
-        let str = '';
-
-        if (this.available) {
-            str += '#available';
-        }
-
-        if (this.sort) {
-            // e.g. title, title.descending
-            const parts = this.sort.split(/\./);
-            if (parts[1]) { str += ' #descending'; }
-            str += ' sort(' + parts[0] + ')';
-        }
-
-        if (this.identQuery && this.identQueryType) {
-            if (str) { str += ' '; }
-            str += this.identQueryType + ':' + this.identQuery;
-
-        } else {
-
-            // -------
-            // Compile boolean sub-query components
-            if (str.length) { str += ' '; }
-            const qcount = this.query.length;
-
-            // if we multiple boolean query components, wrap them in parens.
-            if (qcount > 1) { str += '('; }
-            this.query.forEach((q, idx) => {
-                str += this.compileBoolQuerySet(idx);
-            });
-            if (qcount > 1) { str += ')'; }
-            // -------
-        }
-
-        if (this.hasBrowseEntry) { 
-            // stored as a comma-separated string of "entryId,fieldId"
-            str += ` has_browse_entry(${this.hasBrowseEntry})`;
-        }
-
-        if (this.format) {
-            str += ' format(' + this.format + ')';
-        }
-
-        if (this.global) {
-            str += ' depth(' +
-                this.org.root().ou_type().depth() + ')';
-        }
-
-        if (this.copyLocations[0] !== '') {
-            str += ' locations(' + this.copyLocations + ')';
-        }
-
-        str += ' site(' + this.searchOrg.shortname() + ')';
+    compileIdentSearchQuery(): string {
 
-        Object.keys(this.ccvmFilters).forEach(field => {
-            if (this.ccvmFilters[field][0] !== '') {
-                str += ' ' + field + '(' + this.ccvmFilters[field] + ')';
-            }
-        });
-
-        this.facetFilters.forEach(f => {
-            str += ' ' + f.facetClass + '|'
-                + f.facetName + '[' + f.facetValue + ']';
-        });
-
-        return str;
+        let str = ' site(' + this.searchOrg.shortname() + ')';
+        return str + ' ' + 
+            this.identSearch.queryType + ':' + this.identSearch.value;
     }
 
-    stripQuotes(query: string): string {
-        return query.replace(/"/g, '');
-    }
-
-    stripAnchors(query: string): string {
-        return query.replace(/[\^\$]/g, '');
-    }
-
-    addQuotes(query: string): string {
-        if (query.match(/ /)) {
-            return '"' + query + '"';
-        }
-        return query;
-    }
 
     compileBoolQuerySet(idx: number): string {
-        let query = this.query[idx];
-        const joinOp = this.joinOp[idx];
-        const matchOp = this.matchOp[idx];
-        const fieldClass = this.fieldClass[idx];
+        const ts = this.termSearch;
+        let query = ts.query[idx];
+        const joinOp = ts.joinOp[idx];
+        const matchOp = ts.matchOp[idx];
+        const fieldClass = ts.fieldClass[idx];
 
         let str = '';
         if (!query) { return str; }
@@ -328,29 +326,81 @@ export class CatalogSearchContext {
         return str + query + ')';
     }
 
-    hasFacet(facet: FacetFilter): boolean {
-        return Boolean(
-            this.facetFilters.filter(f => f.equals(facet))[0]
-        );
+    stripQuotes(query: string): string {
+        return query.replace(/"/g, '');
     }
 
-    removeFacet(facet: FacetFilter): void {
-        this.facetFilters = this.facetFilters.filter(f => !f.equals(facet));
+    stripAnchors(query: string): string {
+        return query.replace(/[\^\$]/g, '');
     }
 
-    addFacet(facet: FacetFilter): void {
-        if (!this.hasFacet(facet)) {
-            this.facetFilters.push(facet);
+    addQuotes(query: string): string {
+        if (query.match(/ /)) {
+            return '"' + query + '"';
         }
+        return query;
     }
 
-    toggleFacet(facet: FacetFilter): void {
-        if (this.hasFacet(facet)) {
-            this.removeFacet(facet);
-        } else {
-            this.facetFilters.push(facet);
+    compileTermSearchQuery(): string {
+        const ts = this.termSearch;
+        let str = '';
+
+        if (ts.available) {
+            str += '#available';
         }
+
+        if (this.sort) {
+            // e.g. title, title.descending
+            const parts = this.sort.split(/\./);
+            if (parts[1]) { str += ' #descending'; }
+            str += ' sort(' + parts[0] + ')';
+        }
+
+        // -------
+        // Compile boolean sub-query components
+        if (str.length) { str += ' '; }
+        const qcount = ts.query.length;
+
+        // if we multiple boolean query components, wrap them in parens.
+        if (qcount > 1) { str += '('; }
+        ts.query.forEach((q, idx) => {
+            str += this.compileBoolQuerySet(idx);
+        });
+        if (qcount > 1) { str += ')'; }
+        // -------
+
+        if (ts.hasBrowseEntry) { 
+            // stored as a comma-separated string of "entryId,fieldId"
+            str += ` has_browse_entry(${ts.hasBrowseEntry})`;
+        }
+
+        if (ts.format) {
+            str += ' format(' + ts.format + ')';
+        }
+
+        if (this.global) {
+            str += ' depth(' +
+                this.org.root().ou_type().depth() + ')';
+        }
+
+        if (ts.copyLocations[0] !== '') {
+            str += ' locations(' + ts.copyLocations + ')';
+        }
+
+        str += ' site(' + this.searchOrg.shortname() + ')';
+
+        Object.keys(ts.ccvmFilters).forEach(field => {
+            if (ts.ccvmFilters[field][0] !== '') {
+                str += ' ' + field + '(' + ts.ccvmFilters[field] + ')';
+            }
+        });
+
+        ts.facetFilters.forEach(f => {
+            str += ' ' + f.facetClass + '|'
+                + f.facetName + '[' + f.facetValue + ']';
+        });
+
+        return str;
     }
 }
 
-
index 86d8099..5837ace 100644 (file)
@@ -5,7 +5,7 @@
   <div class="col-lg-4 pr-1">
     <div class="float-right">
       <!-- note basket view link does not propagate search params -->
-      <a routerLink="/staff/catalog/search" [queryParams]="{basket: true}" 
+      <a routerLink="/staff/catalog/search" [queryParams]="{showBasket: true}" 
         class="label-with-material-icon">
         <span class="material-icons">shopping_basket</span>
         <span i18n>({{basketCount()}})</span>
index 8412143..97742f7 100644 (file)
@@ -1,5 +1,5 @@
 
-<eg-catalog-browse-form></eg-catalog-browse-form>
+<eg-catalog-search-form></eg-catalog-search-form>
 
 <eg-catalog-browse-results><eg-catalog-browse-results>
 
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/browse/form.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/browse/form.component.html
deleted file mode 100644 (file)
index 6dba250..0000000
+++ /dev/null
@@ -1,36 +0,0 @@
-<div id='staffcat-browse-form' class='pb-2 mb-3 row'>
-  <div class="col-lg-10 form-inline">
-    <label for="field-class" i18n>Browse for</label>
-    <select class="form-control ml-2" name="field-class"
-      [(ngModel)]="searchContext.fieldClass[0]">
-      <option i18n value='title'>Title</option>
-      <option i18n value='author'>Author</option>
-      <option i18n value='subject'>Subject</option>
-      <option i18n value='series'>Series</option>
-    </select>
-    <label for="query" class="ml-2"> starting with </label>
-    <input type="text" class="form-control ml-2"
-      id='browse-term-input'
-      [(ngModel)]="searchContext.query[0]"
-      (keyup.enter)="formEnter('query')"
-      placeholder="Browse for..."/>
-    <label for="browse-org" class="ml-2"> in </label>
-    <eg-org-select name="browse-org" class="ml-2"
-       (onChange)="orgOnChange($event)"
-       [initialOrg]="searchContext.searchOrg"
-       [placeholder]="'Library'" >
-    </eg-org-select>
-    <button class="btn btn-success ml-2" type="button"
-      [disabled]="searchIsActive()"
-      (click)="searchContext.pager.offset=0; browseByForm()" i18n>
-       Browse 
-    </button>
-  </div>
-  <div class="col-lg-2">
-    <div class="float-right">
-      <button class="btn btn-info" 
-        type="button" (click)="goToSearch()" i18n>Search</button>
-    </div>
-  </div>
-</div>
-
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/browse/form.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/browse/form.component.ts
deleted file mode 100644 (file)
index b9c4c8e..0000000
+++ /dev/null
@@ -1,64 +0,0 @@
-import {Component, OnInit, AfterViewInit, Renderer2} from '@angular/core';
-import {Router} 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';
-import {CatalogSearchContext, CatalogSearchState} from '@eg/share/catalog/search-context';
-import {StaffCatalogService} from '../catalog.service';
-
-@Component({
-  selector: 'eg-catalog-browse-form',
-  templateUrl: 'form.component.html'
-})
-export class BrowseFormComponent implements OnInit, AfterViewInit {
-
-    searchContext: CatalogSearchContext;
-    ccvmMap: {[ccvm: string]: IdlObject[]} = {};
-    cmfMap: {[cmf: string]: IdlObject} = {};
-
-    constructor(
-        private renderer: Renderer2,
-        private router: Router,
-        private org: OrgService,
-        private cat: CatalogService,
-        private staffCat: StaffCatalogService
-    ) {
-    }
-
-    ngOnInit() {
-        this.ccvmMap = this.cat.ccvmMap;
-        this.cmfMap = this.cat.cmfMap;
-        this.searchContext = this.staffCat.searchContext;
-    }
-
-    ngAfterViewInit() {
-        this.renderer.selectRootElement('#browse-term-input').focus();
-    }
-
-    orgName(orgId: number): string {
-        return this.org.get(orgId).shortname();
-    }
-
-    formEnter(source) {
-        this.searchContext.pager.offset = 0;
-        this.browseByForm();
-    }
-
-    browseByForm(): void {
-        this.staffCat.browse();
-    }
-
-    searchIsActive(): boolean {
-        return this.searchContext.searchState === CatalogSearchState.SEARCHING;
-    }
-
-    goToSearch() {
-        this.router.navigate(['/staff/catalog/search']);
-    }
-
-    orgOnChange = (org: IdlObject): void => {
-        this.searchContext.searchOrg = org;
-    }
-}
-
-
index f706cd5..8fcbce1 100644 (file)
@@ -38,14 +38,15 @@ export class BrowseResultsComponent implements OnInit {
 
     browseByUrl(params: ParamMap): void {
         this.catUrl.applyUrlParams(this.searchContext, params);
+        const bs = this.searchContext.browseSearch;
 
         // SearchContext applies a default fieldClass value of 'keyword'.
         // Replace with 'title', since there is no 'keyword' browse.
-        if (this.searchContext.fieldClass[0] === 'keyword') {
-            this.searchContext.fieldClass = ['title'];
+        if (bs.fieldClass === 'keyword') {
+            bs.fieldClass = 'title';
         }
 
-        if (this.searchContext.isBrowsable()) {
+        if (bs.isSearchable()) {
             this.results = [];
             this.cat.browse(this.searchContext)
                 .subscribe(result => this.addResult(result))
@@ -105,7 +106,7 @@ export class BrowseResultsComponent implements OnInit {
     prevPage() {
         const firstResult = this.results[0];
         if (firstResult) {
-            this.searchContext.browsePivot = firstResult.pivot_point;
+            this.searchContext.browseSearch.pivot = firstResult.pivot_point;
             this.staffCat.browse();
         }
     }
@@ -113,17 +114,17 @@ export class BrowseResultsComponent implements OnInit {
     nextPage() {
         const lastResult = this.results[this.results.length - 1];
         if (lastResult) {
-            this.searchContext.browsePivot = lastResult.pivot_point;
+            this.searchContext.browseSearch.pivot = lastResult.pivot_point;
             this.staffCat.browse();
         }
     }
 
     searchByBrowseEntry(result) { 
 
-        // avoid propagating the browse query to the search form
-        this.searchContext.query[0] = '';
+        // Avoid propagating browse values to term search.
+        this.searchContext.browseSearch.reset();
 
-        this.searchContext.hasBrowseEntry = 
+        this.searchContext.termSearch.hasBrowseEntry = 
             result.browse_entry + ',' + result.fields;
         this.staffCat.search();
     }
@@ -131,7 +132,7 @@ export class BrowseResultsComponent implements OnInit {
     // NOTE: to test unauthorized heading display in concerto
     // browse for author = kab
     newBrowseFromHeading(heading) {
-        this.searchContext.query[0] = heading.heading;
+        this.searchContext.browseSearch.value = heading.heading;
         this.staffCat.browse();
     }
 }
index b083f67..2d30199 100644 (file)
@@ -20,7 +20,6 @@ import {HoldService} from '@eg/staff/share/hold.service';
 import {PartsComponent} from './record/parts.component';
 import {PartMergeDialogComponent} from './record/part-merge-dialog.component';
 import {BrowseComponent} from './browse.component';
-import {BrowseFormComponent} from './browse/form.component';
 import {BrowseResultsComponent} from './browse/results.component';
 
 @NgModule({
@@ -40,7 +39,6 @@ import {BrowseResultsComponent} from './browse/results.component';
     PartsComponent,
     PartMergeDialogComponent,
     BrowseComponent,
-    BrowseFormComponent,
     BrowseResultsComponent
   ],
   imports: [
index 681e159..cf0a36c 100644 (file)
@@ -88,7 +88,7 @@ export class StaffCatalogService {
      * execute the actual browse.
      */
     browse(): void {
-        if (!this.searchContext.isBrowsable()) { return; }
+        if (!this.searchContext.browseSearch.isSearchable()) { return; }
 
         const params = this.catUrl.toUrlParams(this.searchContext);
 
index 44583b8..f16215a 100644 (file)
@@ -35,11 +35,11 @@ export class ResultFacetsComponent implements OnInit {
     }
 
     facetIsApplied(cls: string, name: string, value: string): boolean {
-        return this.searchContext.hasFacet(new FacetFilter(cls, name, value));
+        return this.searchContext.termSearch.hasFacet(new FacetFilter(cls, name, value));
     }
 
     applyFacet(cls: string, name: string, value: string): void {
-        this.searchContext.toggleFacet(new FacetFilter(cls, name, value));
+        this.searchContext.termSearch.toggleFacet(new FacetFilter(cls, name, value));
         this.searchContext.pager.offset = 0;
         this.staffCat.search();
     }
index b05d6b4..5224c38 100644 (file)
@@ -68,8 +68,8 @@ export class ResultRecordComponent implements OnInit, OnDestroy {
 
     searchAuthor(summary: any) {
         this.searchContext.reset();
-        this.searchContext.fieldClass = ['author'];
-        this.searchContext.query = [summary.display.author];
+        this.searchContext.termSearch.fieldClass = ['author'];
+        this.searchContext.termSearch.query = [summary.display.author];
         this.staffCat.search();
     }
 
index 01bb3bd..374df5b 100644 (file)
@@ -4,14 +4,14 @@ TODO focus search input
 <div id='staffcat-search-form' class="row pb-3 mb-3 ">
   <div class="col-lg-8">
     <ngb-tabset #searchTabs [activeId]="searchTab" (tabChange)="onTabChange($event)">
-      <ngb-tab title="Term Search" i18n-title id="term">
+      <ngb-tab title="Keyword Search" i18n-title id="term">
         <ng-template ngbTabContent>
           <div class="row"
             [ngClass]="{'mt-4': idx == 0, 'mt-1': idx > 0}"
-            *ngFor="let q of searchContext.query; let idx = index; trackBy:trackByIdx">
+            *ngFor="let q of context.termSearch.query; let idx = index; trackBy:trackByIdx">
             <div class="col-lg-2 pr-1">
               <div *ngIf="idx == 0">
-                <select class="form-control" [(ngModel)]="searchContext.format">
+                <select class="form-control" [(ngModel)]="context.termSearch.format">
                   <option i18n value=''>All Formats</option>
                   <option *ngFor="let fmt of ccvmMap.search_format"
                     value="{{fmt.code()}}">{{fmt.value()}}</option>
@@ -19,7 +19,7 @@ TODO focus search input
               </div>
               <div *ngIf="idx > 0">
                 <select class="form-control"
-                  [(ngModel)]="searchContext.joinOp[idx]">
+                  [(ngModel)]="context.termSearch.joinOp[idx]">
                   <option i18n value='&&'>And</option>
                   <option i18n value='||'>Or</option>
                 </select>
@@ -27,7 +27,7 @@ TODO focus search input
             </div>
             <div class="col-lg-2 pl-0 pr-2">
               <select class="form-control" 
-                [(ngModel)]="searchContext.fieldClass[idx]">
+                [(ngModel)]="context.termSearch.fieldClass[idx]">
                 <option i18n value='keyword'>Keyword</option>
                 <option i18n value='title'>Title</option>
                 <option i18n value='jtitle'>Journal Title</option>
@@ -38,7 +38,7 @@ TODO focus search input
             </div>
             <div class="col-lg-2 pl-0 pr-2">
               <select class="form-control" 
-                [(ngModel)]="searchContext.matchOp[idx]">
+                [(ngModel)]="context.termSearch.matchOp[idx]">
                 <option i18n value='contains'>Contains</option>
                 <option i18n value='nocontains'>Does not contain</option>
                 <option i18n value='phrase'>Contains phrase</option>
@@ -51,13 +51,13 @@ TODO focus search input
                 <div *ngIf="idx == 0">
                   <input type="text" class="form-control"
                     id='first-query-input'
-                    [(ngModel)]="searchContext.query[idx]"
+                    [(ngModel)]="context.termSearch.query[idx]"
                     (keyup.enter)="searchByForm()"
                     placeholder="Query..."/>
                 </div>
                 <div *ngIf="idx > 0">
                   <input type="text" class="form-control"
-                    [(ngModel)]="searchContext.query[idx]"
+                    [(ngModel)]="context.termSearch.query[idx]"
                     (keyup.enter)="searchByForm()"
                     placeholder="Query..."/>
                 </div>
@@ -70,7 +70,7 @@ TODO focus search input
                 <span class="material-icons">add_circle_outline</span>
               </button>
               <button class="btn btn-sm material-icon-button"
-                [disabled]="searchContext.query.length < 2"
+                [disabled]="context.termSearch.query.length < 2"
                 (click)="delSearchRow(idx)"
                 i18n-title title="Remove Search Row">
                 <span class="material-icons">remove_circle_outline</span>
@@ -85,7 +85,7 @@ TODO focus search input
           </div>
           <div class="row">
             <div class="col-lg-12 form-inline">
-                <select class="form-control mr-2" [(ngModel)]="searchContext.sort">
+                <select class="form-control mr-2" [(ngModel)]="context.sort">
                   <option value='' i18n>Sort by Relevance</option>
                   <optgroup label="Sort by Title" i18n-label>
                     <option value='titlesort' i18n>Title: A to Z</option>
@@ -106,21 +106,21 @@ TODO focus search input
                 </select>
                 <div class="checkbox pl-2 ml-2">
                   <label>
-                    <input type="checkbox" [(ngModel)]="searchContext.available"/>
+                    <input type="checkbox" [(ngModel)]="context.termSearch.available"/>
                     <span class="pl-1" i18n>Limit to Available</span>
                   </label>
                 </div>
                 <div class="checkbox pl-3">
                   <label>
                     <input type="checkbox" [disabled]="true" 
-                      [(ngModel)]="searchContext.isMetarecord"/>
+                      [(ngModel)]="context.termSearch.isMetarecord"/>
                     <span class="pl-1" i18n>Group Formats/Editions</span>
                   </label>
                 </div>
                 <div class="checkbox pl-3">
                   <label>
-                    <input type="checkbox" [(ngModel)]="searchContext.global"/>
-                    <span class="pl-1" i18n>Show Results from All Libraries</span>
+                    <input type="checkbox" [(ngModel)]="context.termSearch.global"/>
+                    <span class="pl-1" i18n>Results from All Libraries</span>
                   </label>
                 </div>
               </div>
@@ -128,7 +128,7 @@ TODO focus search input
           <div class="row mt-3" *ngIf="showFilters()">
             <div class="col-lg-3">
               <select class="form-control"  multiple="true"
-                [(ngModel)]="searchContext.ccvmFilters.item_type">
+                [(ngModel)]="context.termSearch.ccvmFilters.item_type">
                 <option value='' i18n>All Item Types</option>
                 <option *ngFor="let itemType of ccvmMap.item_type"
                   value="{{itemType.code()}}">{{itemType.value()}}</option>
@@ -136,7 +136,7 @@ TODO focus search input
             </div>
             <div class="col-lg-3">
               <select class="form-control" multiple="true"
-                [(ngModel)]="searchContext.ccvmFilters.item_form">
+                [(ngModel)]="context.termSearch.ccvmFilters.item_form">
                 <option value='' i18n>All Item Forms</option>
                 <option *ngFor="let itemForm of ccvmMap.item_form"
                   value="{{itemForm.code()}}">{{itemForm.value()}}</option>
@@ -144,7 +144,7 @@ TODO focus search input
             </div>
             <div class="col-lg-3">
               <select class="form-control" 
-                [(ngModel)]="searchContext.ccvmFilters.item_lang" multiple="true">
+                [(ngModel)]="context.termSearch.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>
@@ -152,7 +152,7 @@ TODO focus search input
             </div>
             <div class="col-lg-3">
               <select class="form-control" 
-                [(ngModel)]="searchContext.ccvmFilters.audience" multiple="true">
+                [(ngModel)]="context.termSearch.ccvmFilters.audience" multiple="true">
                 <option value='' i18n>All Audiences</option>
                 <option *ngFor="let audience of ccvmMap.audience"
                   value="{{audience.code()}}">{{audience.value()}}</option>
@@ -162,7 +162,7 @@ TODO focus search input
           <div class="row mt-3" *ngIf="showFilters()">
             <div class="col-lg-3">
               <select class="form-control" 
-                [(ngModel)]="searchContext.ccvmFilters.vr_format" multiple="true">
+                [(ngModel)]="context.termSearch.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>
@@ -170,7 +170,7 @@ TODO focus search input
             </div>
             <div class="col-lg-3">
               <select class="form-control" 
-                [(ngModel)]="searchContext.ccvmFilters.bib_level" multiple="true">
+                [(ngModel)]="context.termSearch.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>
@@ -178,7 +178,7 @@ TODO focus search input
             </div>
             <div class="col-lg-3">
               <select class="form-control" 
-                [(ngModel)]="searchContext.ccvmFilters.lit_form" multiple="true">
+                [(ngModel)]="context.termSearch.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>
@@ -186,7 +186,7 @@ TODO focus search input
             </div>
             <div class="col-lg-3">
               <select class="form-control" 
-                [(ngModel)]="searchContext.copyLocations" multiple="true">
+                [(ngModel)]="context.termSearch.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())}})
@@ -203,7 +203,7 @@ TODO focus search input
               <div class="form-inline">
                 <label for="ident-type" i18n>Query Type</label>
                 <select class="form-control ml-2" name="ident-type"
-                  [(ngModel)]="searchContext.identQueryType">
+                  [(ngModel)]="context.identSearch.queryType">
                   <option i18n value="identifier|isbn">ISBN</option>
                   <option i18n value="identifier|issn">ISSN</option>
                   <option i18n disabled value="cnbrowse">Call Number (Shelf Browse)</option>
@@ -214,12 +214,9 @@ TODO focus search input
                 <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"
+                  [(ngModel)]="context.identSearch.value"
                   (keyup.enter)="searchByForm()"
                   placeholder="Numeric Query..."/>
-                <button class="btn btn-success ml-2" type="button"
-                  [disabled]="searchIsActive()"
-                  (click)="searchByForm()" i18n>Search</button>
               </div>
             </div>
           </div>
@@ -230,60 +227,82 @@ TODO focus search input
           <div class="row mt-4">
             <div class="col-lg-12">
               <div class="form-inline mt-2" 
-                *ngFor="let q of searchContext.marcValue; let idx = index; trackBy:trackByIdx">
+                *ngFor="let q of context.marcSearch.values; 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]"
+                  [(ngModel)]="context.marcSearch.tags[idx]"
                   (keyup.enter)="searchByForm()"/>
                 <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]"
+                  [(ngModel)]="context.marcSearch.subfields[idx]"
                   (keyup.enter)="searchByForm()"/>
                 <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]" 
+                  [(ngModel)]="context.marcSearch.values[idx]" 
                   (keyup.enter)="searchByForm()"/>
                 <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"
+                  [disabled]="context.marcSearch.values.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)="searchByForm()" i18n>Submit</button>
               </div>
             </div>
           </div>
         </ng-template>
       </ngb-tab>
+      <ngb-tab title="Browse" i18n-title id="browse">
+        <ng-template ngbTabContent>
+          <div class="row mt-4">
+            <div class="col-lg-12 form-inline">
+              <label for="field-class" i18n>Browse for</label>
+              <select class="form-control ml-2" name="field-class"
+                [(ngModel)]="context.browseSearch.fieldClass">
+                <option i18n value='title'>Title</option>
+                <option i18n value='author'>Author</option>
+                <option i18n value='subject'>Subject</option>
+                <option i18n value='series'>Series</option>
+              </select>
+              <label for="query" class="ml-2"> starting with </label>
+              <input type="text" class="form-control ml-2" 
+                id='browse-term-input' name="query"
+                [(ngModel)]="context.browseSearch.value"
+                (keyup.enter)="searchByForm()"
+                placeholder="Browse for..."/>
+            </div>
+          </div>
+        </ng-template>
+      </ngb-tab>
     </ngb-tabset>
   </div>
   <div class="col-lg-4">
     <div class="row">
       <div class="col-lg-12">
-        <div class="float-right d-flex">
-          <eg-org-select 
-            (onChange)="orgOnChange($event)"
-            [initialOrg]="searchContext.searchOrg"
-            [placeholder]="'Library'" >
-          </eg-org-select>
-          <button class="btn btn-success mr-1 ml-1" type="button"
-            [disabled]="searchIsActive()"
-            (click)="searchContext.pager.offset=0;searchByForm()" i18n>
-            Search
-          </button>
-          <button class="btn btn-warning mr-1" type="button"
-            [disabled]="searchIsActive()"
-            (click)="searchContext.reset()" i18n>
-            Reset Form
-          </button>
-          <button class="btn btn-info ml-1" type="button" 
-            (click)="goToBrowse()" i18n>Browse</button>
+        <div class="card">
+          <div class="card-body">
+            <div class="float-right d-flex">
+              <eg-org-select 
+                (onChange)="orgOnChange($event)"
+                [initialOrg]="context.searchOrg"
+                [placeholder]="'Library'" >
+              </eg-org-select>
+              <button class="btn btn-success mr-1 ml-1" type="button"
+                [disabled]="searchIsActive()"
+                (click)="context.pager.offset=0;searchByForm()" i18n>
+                Search
+              </button>
+              <button class="btn btn-warning mr-1" type="button"
+                [disabled]="searchIsActive()"
+                (click)="context.reset()" i18n>
+                Reset Form
+              </button>
+            </div>
+          </div>
         </div>
       </div>
     </div>
index fbe92b8..1944a3c 100644 (file)
@@ -14,13 +14,12 @@ import {NgbTabset, NgbTabChangeEvent} from '@ng-bootstrap/ng-bootstrap';
 })
 export class SearchFormComponent implements OnInit, AfterViewInit {
 
-    searchContext: CatalogSearchContext;
+    context: CatalogSearchContext;
     ccvmMap: {[ccvm: string]: IdlObject[]} = {};
     cmfMap: {[cmf: string]: IdlObject} = {};
     showSearchFilters = false;
     copyLocations: IdlObject[];
     searchTab: string;
-    //@ViewChild('searchTabs') searchTabs: NgbTabset;
 
     constructor(
         private renderer: Renderer2,
@@ -36,7 +35,7 @@ export class SearchFormComponent implements OnInit, AfterViewInit {
     ngOnInit() {
         this.ccvmMap = this.cat.ccvmMap;
         this.cmfMap = this.cat.cmfMap;
-        this.searchContext = this.staffCat.searchContext;
+        this.context = this.staffCat.searchContext;
 
         // Start with advanced search options open
         // if any filters are active.
@@ -50,15 +49,20 @@ export class SearchFormComponent implements OnInit, AfterViewInit {
 
         // Avoid changing the tab in the lifecycle hook thread.
         setTimeout(() => {
-            const st = this.searchContext.searchType();
-            if (st === 'marc' || st === 'ident') {
-                this.searchTab = st;
+            // Assumes that only one type of search will be searchable
+            // at any given time.
+            if (this.context.marcSearch.isSearchable()) {
+                this.searchTab = 'marc';
+            } else if (this.context.identSearch.isSearchable()) {
+                this.searchTab = 'ident';
+            } else if (this.context.browseSearch.isSearchable()) {
+                this.searchTab = 'browse';
             } else {
+                // Default tab
                 this.searchTab = 'term';
+                this.refreshCopyLocations();
             }
         });
-
-        this.refreshCopyLocations();
     }
 
     onTabChange(evt: NgbTabChangeEvent) {
@@ -73,7 +77,11 @@ export class SearchFormComponent implements OnInit, AfterViewInit {
             case 'marc':
                 selector = '#first-marc-tag';
                 break;
+            case 'browse':
+                selector = '#browse-term-input';
+                break;
             default:
+                this.refreshCopyLocations();
                 selector = '#first-query-input';
         }
 
@@ -98,13 +106,13 @@ export class SearchFormComponent implements OnInit, AfterViewInit {
 
     filtersActive(): boolean {
 
-        if (this.searchContext.copyLocations[0] !== '') { return true; }
+        if (this.context.termSearch.copyLocations[0] !== '') { return true; }
 
         // ccvm filters may be present without any filters applied.
         // e.g. if filters were applied then removed.
         let show = false;
-        Object.keys(this.searchContext.ccvmFilters).forEach(ccvm => {
-            if (this.searchContext.ccvmFilters[ccvm][0] !== '') {
+        Object.keys(this.context.termSearch.ccvmFilters).forEach(ccvm => {
+            if (this.context.termSearch.ccvmFilters[ccvm][0] !== '') {
                 show = true;
             }
         });
@@ -113,7 +121,7 @@ export class SearchFormComponent implements OnInit, AfterViewInit {
     }
 
     orgOnChange = (org: IdlObject): void => {
-        this.searchContext.searchOrg = org;
+        this.context.searchOrg = org;
         this.refreshCopyLocations();
     }
 
@@ -121,7 +129,7 @@ export class SearchFormComponent implements OnInit, AfterViewInit {
         if (!this.showFilters()) { return; }
 
         // TODO: is this how we avoid displaying too many locations?
-        const org = this.searchContext.searchOrg;
+        const org = this.context.searchOrg;
         if (org.id() === this.org.root().id()) { 
             this.copyLocations = [];
             return; 
@@ -137,71 +145,69 @@ export class SearchFormComponent implements OnInit, AfterViewInit {
     }
 
     addSearchRow(index: number): void {
-        this.searchContext.query.splice(index, 0, '');
-        this.searchContext.fieldClass.splice(index, 0, 'keyword');
-        this.searchContext.joinOp.splice(index, 0, '&&');
-        this.searchContext.matchOp.splice(index, 0, 'contains');
+        this.context.termSearch.query.splice(index, 0, '');
+        this.context.termSearch.fieldClass.splice(index, 0, 'keyword');
+        this.context.termSearch.joinOp.splice(index, 0, '&&');
+        this.context.termSearch.matchOp.splice(index, 0, 'contains');
     }
 
     delSearchRow(index: number): void {
-        this.searchContext.query.splice(index, 1);
-        this.searchContext.fieldClass.splice(index, 1);
-        this.searchContext.joinOp.splice(index, 1);
-        this.searchContext.matchOp.splice(index, 1);
+        this.context.termSearch.query.splice(index, 1);
+        this.context.termSearch.fieldClass.splice(index, 1);
+        this.context.termSearch.joinOp.splice(index, 1);
+        this.context.termSearch.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, '');
+        this.context.marcSearch.tags.splice(index, 0, '');
+        this.context.marcSearch.subfields.splice(index, 0, '');
+        this.context.marcSearch.values.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);
+        this.context.marcSearch.tags.splice(index, 1);
+        this.context.marcSearch.subfields.splice(index, 1);
+        this.context.marcSearch.values.splice(index, 1);
     }
 
     searchByForm(): void {
-        // Starting a new search
-        this.searchContext.pager.offset = 0;
+        this.context.pager.offset = 0; // New search
 
-        // Trim the search context to necessary data only depending
-        // on which tab the user is on, i.e. which type of search
-        // the user is requesting.
-
-        // A form search overrides an existing basket display request
-        this.searchContext.basket = null;
+        // Form search overrides basket display
+        this.context.showBasket = false; 
 
         switch (this.searchTab) {
 
-            case 'term': // main search form query input
-
-                // Be sure a previous ident search does not take precedence
-                // over the new term query submitted via Enter within
-                // the search term/query box.
-                this.searchContext.marcValue[0] = '';
-                this.searchContext.identQuery = null;
+            case 'term': // AKA keyword search
+                this.context.marcSearch.reset();
+                this.context.browseSearch.reset();
+                this.context.identSearch.reset();
+                this.context.termSearch.hasBrowseEntry = '';
+                this.staffCat.search();
                 break;
 
-            case 'ident': // identifier query input
-                const iq = this.searchContext.identQuery;
-                const qt = this.searchContext.identQueryType;
-                if (iq) {
-                    // Ident queries ignore search-specific filters.
-                    this.searchContext.reset();
-                    this.searchContext.identQuery = iq;
-                    this.searchContext.identQueryType = qt;
-                }
+            case 'ident': 
+                this.context.marcSearch.reset();
+                this.context.browseSearch.reset();
+                this.context.termSearch.reset();
+                this.staffCat.search();
                 break;
 
             case 'marc':
-                this.searchContext.identQuery = null;
-                this.searchContext.query[0] = ''; // prevent term queries
+                this.context.browseSearch.reset();
+                this.context.termSearch.reset();
+                this.context.identSearch.reset();
+                this.staffCat.search();
                 break;
-        }
 
-        this.staffCat.search();
+            case 'browse':
+                this.context.marcSearch.reset();
+                this.context.termSearch.reset();
+                this.context.identSearch.reset();
+                this.context.browseSearch.pivot = null;
+                this.staffCat.browse();
+                break;
+        }
     }
 
     // https://stackoverflow.com/questions/42322968/angular2-dynamic-input-field-lose-focus-when-input-changes
@@ -209,9 +215,8 @@ export class SearchFormComponent implements OnInit, AfterViewInit {
        return index;
     }
 
-
     searchIsActive(): boolean {
-        return this.searchContext.searchState === CatalogSearchState.SEARCHING;
+        return this.context.searchState === CatalogSearchState.SEARCHING;
     }
 
     goToBrowse() {