LP1806087 Angular staff catalog phase II.
authorBill Erickson <berickxx@gmail.com>
Wed, 7 Nov 2018 15:18:31 +0000 (10:18 -0500)
committerDan Wells <dbw2@calvin.edu>
Wed, 20 Feb 2019 21:59:27 +0000 (16:59 -0500)
* Record detail tabs redirect to AngJS catalog where needed.
* Initial holds placement UI.
* Record baskets, actions, and UI.
* Ported MonographParts tab to Angular
* Set default catalog tab
* Browse
* MARC search
* Identifier search
* pub date filter
* Record detail 'View in Catalog' button
* Group formats and editions

Signed-off-by: Bill Erickson <berickxx@gmail.com>
Signed-off-by: Dan Wells <dbw2@calvin.edu>

60 files changed:
Open-ILS/examples/fm_IDL.xml
Open-ILS/src/eg2/src/app/core/org.service.ts
Open-ILS/src/eg2/src/app/core/perm.service.ts
Open-ILS/src/eg2/src/app/core/server-store.service.ts
Open-ILS/src/eg2/src/app/share/catalog/basket.service.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/catalog/bib-record.service.ts
Open-ILS/src/eg2/src/app/share/catalog/catalog-common.module.ts
Open-ILS/src/eg2/src/app/share/catalog/catalog-url.service.ts
Open-ILS/src/eg2/src/app/share/catalog/catalog.service.ts
Open-ILS/src/eg2/src/app/share/catalog/search-context.ts
Open-ILS/src/eg2/src/app/share/date-select/date-select.component.html
Open-ILS/src/eg2/src/app/share/date-select/date-select.component.ts
Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.ts
Open-ILS/src/eg2/src/app/share/grid/grid-body.component.html
Open-ILS/src/eg2/src/app/share/grid/grid-body.component.ts
Open-ILS/src/eg2/src/app/share/grid/grid-header.component.html
Open-ILS/src/eg2/src/app/share/grid/grid.component.ts
Open-ILS/src/eg2/src/app/share/grid/grid.ts
Open-ILS/src/eg2/src/app/share/util/anon-cache.service.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/catalog/basket-actions.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/catalog/basket-actions.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/catalog/browse.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/catalog/browse.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/catalog/browse/results.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/catalog/browse/results.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/catalog/catalog.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/hold/hold.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/catalog/record/actions.component.html
Open-ILS/src/eg2/src/app/staff/catalog/record/copies.component.html
Open-ILS/src/eg2/src/app/staff/catalog/record/part-merge-dialog.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/catalog/record/part-merge-dialog.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/catalog/record/parts.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/catalog/record/parts.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html
Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.ts
Open-ILS/src/eg2/src/app/staff/catalog/resolver.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.css [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.html
Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.ts
Open-ILS/src/eg2/src/app/staff/catalog/result/results.component.html
Open-ILS/src/eg2/src/app/staff/catalog/result/results.component.ts
Open-ILS/src/eg2/src/app/staff/catalog/routing.module.ts
Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.css
Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.html
Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.ts
Open-ILS/src/eg2/src/app/staff/nav.component.html
Open-ILS/src/eg2/src/app/staff/share/bib-summary/bib-summary.component.ts
Open-ILS/src/eg2/src/app/staff/share/buckets/record-bucket-dialog.component.html
Open-ILS/src/eg2/src/app/staff/share/buckets/record-bucket-dialog.component.ts
Open-ILS/src/eg2/src/app/staff/share/hold.service.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/holdings.service.ts
Open-ILS/src/eg2/src/styles.css
Open-ILS/src/perlmods/lib/OpenILS/Application/Actor/Container.pm
Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Holds.pm
Open-ILS/src/perlmods/lib/OpenILS/Application/Search.pm
Open-ILS/src/perlmods/lib/OpenILS/Application/Search/Browse.pm [new file with mode: 0644]

index b8d0eff..db65187 100644 (file)
@@ -3931,18 +3931,25 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA
                        <link field="record" reltype="has_a" key="id" map="" class="bre"/>
                </links>
        </class>
-       <class id="mmr" controller="open-ils.cstore" oils_obj:fieldmapper="metabib::metarecord" oils_persist:tablename="metabib.metarecord" reporter:label="Metarecord">
+       <class id="mmr" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="metabib::metarecord" oils_persist:tablename="metabib.metarecord" reporter:label="Metarecord">
                <fields oils_persist:primary="id" oils_persist:sequence="metabib.metarecord_id_seq">
                        <field name="fingerprint"  reporter:datatype="text"/>
                        <field name="id" reporter:datatype="id" />
                        <field name="master_record" reporter:datatype="link"/>
                        <field name="mods"  reporter:datatype="text"/>
                        <field name="source_records" oils_persist:virtual="true" reporter:datatype="link"/>
+                       <field name="source_maps" oils_persist:virtual="true" reporter:datatype="link"/>
                </fields>
                <links>
                        <link field="master_record" reltype="has_a" key="id" map="" class="bre"/>
                        <link field="source_records" reltype="has_many" key="metarecord" map="source" class="mmrsm"/>
+                       <link field="source_maps" reltype="has_many" key="metarecord" class="mmrsm"/>
                </links>
+               <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+                       <actions>
+                               <retrieve/>
+                       </actions>
+               </permacrud>
        </class>
        <class id="cnal" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="config::net_access_level" oils_persist:tablename="config.net_access_level" reporter:label="Net Access Level">
                <fields oils_persist:primary="id" oils_persist:sequence="config.net_access_level_id_seq">
@@ -3975,7 +3982,7 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA
             </actions>
         </permacrud>
        </class>
-       <class id="mmrsm" controller="open-ils.cstore" oils_obj:fieldmapper="metabib::metarecord_source_map" oils_persist:tablename="metabib.metarecord_source_map" oils_persist:field_safe="true" reporter:label="Metarecord Source Map">
+       <class id="mmrsm" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="metabib::metarecord_source_map" oils_persist:tablename="metabib.metarecord_source_map" oils_persist:field_safe="true" reporter:label="Metarecord Source Map">
                <fields oils_persist:primary="id" oils_persist:sequence="metabib.metarecord_source_map_id_seq">
                        <field name="id" reporter:datatype="id" />
                        <field name="metarecord" reporter:datatype="link"/>
@@ -3985,6 +3992,11 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA
                        <link field="source" reltype="has_a" key="id" map="" class="bre"/>
                        <link field="metarecord" reltype="has_a" key="id" map="" class="mmr"/>
                </links>
+               <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+                       <actions>
+                               <retrieve/>
+                       </actions>
+               </permacrud>
        </class>
        <class id="mde" controller="open-ils.cstore open-ils.pcrud" 
                        oils_obj:fieldmapper="metabib::display_entry" 
@@ -4140,6 +4152,11 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA
                <links>
                        <link field="def_maps" reltype="has_many" key="entry" map="" class="mbedm"/>
                </links>
+               <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+                       <actions>
+                               <retrieve/>
+                       </actions>
+               </permacrud>
        </class>
        <class id="mbedm" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="metabib::browse_entry_def_map" oils_persist:tablename="metabib.browse_entry_def_map" reporter:label="Combined Browse Entry Definition Map" oils_persist:readonly="true">
                <fields oils_persist:primary="id" oils_persist:sequence="metabib.browse_entry_def_map_id_seq">
index 530e3cb..71dba93 100644 (file)
@@ -231,9 +231,10 @@ export class OrgService {
     /**
      *
      */
-    settings(names: string[],
+    settings(name: string | string[],
         orgId?: number, anonymous?: boolean): Promise<OrgSettingsBatch> {
 
+        let names = [].concat(name);
         const settings = {};
         let auth: string = null;
         let useCache = false;
index 44d3c63..2b3a471 100644 (file)
@@ -41,7 +41,8 @@ export class PermService {
     }
 
     // workstation required
-    hasWorkPermHere(permNames: string[]): Promise<HasPermHereResult> {
+    hasWorkPermHere(permNames: string | string[]): Promise<HasPermHereResult> {
+        permNames = [].concat(permNames);
         const wsId: number = +this.auth.user().wsid();
 
         if (!wsId) {
index 43415c1..ea2d93d 100644 (file)
@@ -65,7 +65,7 @@ export class ServerStoreService {
 
         const values: any = {};
         keys.forEach(key => {
-            if (this.cache[key]) {
+            if (key in this.cache) {
                 values[key] = this.cache[key];
             }
         });
diff --git a/Open-ILS/src/eg2/src/app/share/catalog/basket.service.ts b/Open-ILS/src/eg2/src/app/share/catalog/basket.service.ts
new file mode 100644 (file)
index 0000000..99c8c24
--- /dev/null
@@ -0,0 +1,103 @@
+import {Injectable, EventEmitter} from '@angular/core';
+import {Observable} from 'rxjs/Observable';
+import {StoreService} from '@eg/core/store.service';
+import {NetService} from '@eg/core/net.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {AnonCacheService} from '@eg/share/util/anon-cache.service';
+
+// Baskets are stored in an anonymous cache using the cache key stored
+// in a LoginSessionItem (i.e. cookie) at name BASKET_CACHE_KEY_COOKIE.
+// The list is stored under attribute BASKET_CACHE_ATTR.
+// Avoid conflicts with the AngularJS embedded catalog basket by
+// using a different value for the cookie name, since our version
+// stores all cookies as JSON, unlike the TPAC.
+const BASKET_CACHE_KEY_COOKIE = 'basket';
+const BASKET_CACHE_ATTR = 'recordIds';
+
+@Injectable()
+export class BasketService {
+
+    idList: number[];
+
+    // Fired every time our list of ID's are updated.
+    onChange: EventEmitter<number[]>;
+
+    constructor(
+        private net: NetService,
+        private pcrud: PcrudService,
+        private store: StoreService,
+        private anonCache: AnonCacheService
+    ) { 
+        this.idList = []; 
+        this.onChange = new EventEmitter<number[]>();
+    }
+
+    hasRecordId(id: number): boolean {
+        return this.idList.indexOf(Number(id)) > -1;
+    }
+
+    recordCount(): number {
+        return this.idList.length;
+    }
+
+    // TODO: Add server-side API for sorting a set of bibs by ID.
+    // See EGCatLoader/Container::fetch_mylist
+    getRecordIds(): Promise<number[]> {
+        const cacheKey = this.store.getLoginSessionItem(BASKET_CACHE_KEY_COOKIE);
+        this.idList = [];
+
+        if (!cacheKey) { return Promise.resolve(this.idList); }
+
+        return this.anonCache.getItem(cacheKey, BASKET_CACHE_ATTR).then(
+            list => {
+                if (!list) {return this.idList};
+                this.idList = list.map(id => Number(id));
+                return this.idList;
+            }
+        );
+    }
+
+    setRecordIds(ids: number[]): Promise<number[]> {
+        this.idList = ids;
+
+        // If we have no cache key, that's OK, assume this is the first
+        // attempt at adding a value and let the server create the cache
+        // key for us, then store the value in our cookie.
+        const cacheKey = this.store.getLoginSessionItem(BASKET_CACHE_KEY_COOKIE);
+
+        return this.anonCache.setItem(cacheKey, BASKET_CACHE_ATTR, this.idList)
+        .then(cacheKey => {
+            this.store.setLoginSessionItem(BASKET_CACHE_KEY_COOKIE, cacheKey);
+            this.onChange.emit(this.idList);
+            return this.idList;
+        });
+    }
+
+    addRecordIds(ids: number[]): Promise<number[]> {
+        ids = ids.filter(id => !this.hasRecordId(id)); // avoid dupes
+
+        if (ids.length === 0) { 
+            return Promise.resolve(this.idList); 
+        }
+        return this.setRecordIds(
+            this.idList.concat(ids.map(id => Number(id))));
+    }
+
+    removeRecordIds(ids: number[]): Promise<number[]> {
+
+        if (this.idList.length === 0) {
+            return Promise.resolve(this.idList);
+        }
+
+        const wantedIds = this.idList.filter(
+            id => ids.indexOf(Number(id)) < 0);
+
+        return this.setRecordIds(wantedIds); // OK if empty
+    }
+
+    removeAllRecordIds(): Promise<number[]> {
+        return this.setRecordIds([]);
+    }
+}
+
+
index e9fbb61..5602bbb 100644 (file)
@@ -1,6 +1,6 @@
 import {Injectable} from '@angular/core';
 import {Observable, from} from 'rxjs';
-import {mergeMap, map} from 'rxjs/operators';
+import {mergeMap, map, tap} 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';
@@ -20,6 +20,8 @@ export const HOLDINGS_XPATH =
 
 export class BibRecordSummary {
     id: number; // == record.id() for convenience
+    metabibId: number; // If present, this is a metabib summary
+    metabibRecords: number[]; // all constituent bib records
     orgId: number;
     orgDepth: number;
     record: IdlObject;
@@ -38,6 +40,7 @@ export class BibRecordSummary {
         this.display = {};
         this.attributes = {};
         this.bibCallNumber = null;
+        this.metabibRecords = [];
     }
 
     ingest() {
@@ -67,7 +70,10 @@ export class BibRecordSummary {
         // Any attr can be multi-valued.
         this.record.mattrs().forEach(attr => {
             if (this.attributes[attr.attr()]) {
-                this.attributes[attr.attr()].push(attr.value());
+                // Avoid dupes
+                if (this.attributes[attr.attr()].indexOf(attr.value()) < 0) {
+                    this.attributes[attr.attr()].push(attr.value());
+                }
             } else {
                 this.attributes[attr.attr()] = [attr.value()];
             }
@@ -81,9 +87,16 @@ export class BibRecordSummary {
             return Promise.resolve(this.holdCount);
         }
 
+        let method = 'open-ils.circ.bre.holds.count';
+        let target = this.id;
+
+        if (this.metabibId) {
+            method = 'open-ils.circ.mmr.holds.count';
+            target = this.metabibId;
+        }
+
         return this.net.request(
-            'open-ils.circ',
-            'open-ils.circ.bre.holds.count', this.id
+            'open-ils.circ', method, target
         ).toPromise().then(count => this.holdCount = count);
     }
 
@@ -131,7 +144,7 @@ export class BibRecordService {
     }
 
     // Avoid fetching the MARC blob by specifying which fields on the
-    // bre to select.  Note that fleshed fields are explicitly selected.
+    // bre to select.  Note that fleshed fields are implicitly selected.
     fetchableBreFields(): string[] {
         return this.idl.classes.bre.fields
             .filter(f => !f.virtual && f.name !== 'marc')
@@ -167,6 +180,83 @@ export class BibRecordService {
         }));
     }
 
+    // A Metabib Summary is a BibRecordSummary with the lead record as 
+    // its core bib record plus attributes (e.g. formats) from related 
+    // records.
+    getMetabibSummary(metabibIds: number | number[], 
+        orgId?: number, orgDepth?: number): Observable<BibRecordSummary> {
+
+        const ids = [].concat(metabibIds);
+
+        if (ids.length === 0) {
+            return from([]);
+        }
+
+        return this.pcrud.search('mmr', {id: ids}, 
+            {flesh: 1, flesh_fields: {mmr: ['source_maps']}}, 
+            {anonymous: true}
+        ).pipe(mergeMap(mmr => this.compileMetabib(mmr, orgId, orgDepth)));
+    }
+
+    // 'metabib' must have its "source_maps" field fleshed.
+    // Get bib summaries for all related bib records so we can 
+    // extract data that must be appended to the master record summary.
+    compileMetabib(metabib: IdlObject, 
+        orgId?: number, orgDepth?: number): Observable<BibRecordSummary> {
+
+        // TODO: Create an API similar to the one that builds a combined
+        // mods blob for metarecords, except using display fields, etc.
+        // For now, this seems to get the job done.
+
+        // Non-master records
+        const relatedBibIds = metabib.source_maps()
+            .map(map => map.source())
+            .filter(id => id !== metabib.master_record());
+
+        let observer;
+        const observable = new Observable<BibRecordSummary>(o => observer = o);
+
+        // NOTE: getBibSummary calls getHoldingsSummary against
+        // the bib record unnecessarily.  It's called again below.
+        // Reconsider this approach (see also note above about API).
+        this.getBibSummary(metabib.master_record(), orgId, orgDepth)
+        .subscribe(summary => {
+            summary.metabibId = metabib.id();
+            summary.metabibRecords = 
+                metabib.source_maps().map(map => Number(map.source()))
+
+            let promise;
+
+            if (relatedBibIds.length > 0) {
+
+                // Grab data for MR bib summary augmentation
+                promise = this.pcrud.search('mraf', {id: relatedBibIds})
+                    .pipe(tap(attr => summary.record.mattrs().push(attr)))
+                    .toPromise();
+            } else {
+
+                // Metarecord has only one constituent bib.
+                promise = Promise.resolve();
+            }
+
+            promise.then(() => {
+
+                // Re-compile with augmented data
+                summary.compileRecordAttrs();
+
+                // Fetch holdings data for the metarecord
+                this.getHoldingsSummary(metabib.id(), orgId, orgDepth, true)
+                .then(holdingsSummary => {
+                    summary.holdingsSummary = holdingsSummary;
+                    observer.next(summary);
+                    observer.complete();
+                });
+            });
+        });
+
+        return observable;
+    }
+
     // Flesh the creator and editor fields.
     // Handling this separately lets us pull from the cache and
     // avoids the requirement that the main bib query use a staff
@@ -207,12 +297,12 @@ export class BibRecordService {
     }
 
     getHoldingsSummary(recordId: number,
-        orgId: number, orgDepth: number): Promise<any> {
+        orgId: number, orgDepth: number, isMetarecord?: boolean): Promise<any> {
 
         const holdingsSummary = [];
 
         return this.unapi.getAsXmlDocument({
-            target: 'bre',
+            target: isMetarecord ? 'mmr' : 'bre',
             id: recordId,
             extras: '{holdings_xml}',
             format: 'holdings_xml',
index c370b30..eeaf38a 100644 (file)
@@ -1,6 +1,8 @@
 import {NgModule} from '@angular/core';
 import {EgCommonModule} from '@eg/common.module';
 import {CatalogService} from './catalog.service';
+import {AnonCacheService} from '@eg/share/util/anon-cache.service'
+import {BasketService} from './basket.service';
 import {CatalogUrlService} from './catalog-url.service';
 import {BibRecordService} from './bib-record.service';
 import {UnapiService} from './unapi.service';
@@ -18,10 +20,12 @@ import {MarcHtmlComponent} from './marc-html.component';
         MarcHtmlComponent
     ],
     providers: [
+        AnonCacheService,
         CatalogService,
         CatalogUrlService,
         UnapiService,
-        BibRecordService
+        BibRecordService,
+        BasketService,
     ]
 })
 
index 253e3aa..0f07070 100644 (file)
@@ -1,8 +1,9 @@
 import {Injectable} from '@angular/core';
 import {ParamMap} from '@angular/router';
 import {OrgService} from '@eg/core/org.service';
-import {CatalogSearchContext, FacetFilter} from './search-context';
-import {CATALOG_CCVM_FILTERS} from './catalog.service';
+import {CatalogSearchContext, CatalogBrowseContext, CatalogMarcContext, 
+   CatalogTermContext, FacetFilter} from './search-context';
+import {CATALOG_CCVM_FILTERS} from './search-context';
 
 @Injectable()
 export class CatalogUrlService {
@@ -19,28 +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
-        };
-
-        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']
+        ['limit', 'offset', 'sort', 'global', 'showBasket', 'sort']
         .forEach(field => {
             if (context[field]) {
                 // Only propagate applied values to the URL.
@@ -48,36 +43,84 @@ export class CatalogUrlService {
             }
         });
 
-        if (params.identQuery) {
-            // Ident queries (e.g. tcn search) discards all remaining filters
-            return params;
+        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.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;
+        }
 
-        // 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(',');
+        if (context.browseSearch.isSearchable()) {
+            params.browseTerm = context.browseSearch.value;
+            params.browseClass = context.browseSearch.fieldClass;
+            if (context.browseSearch.pivot) {
+                params.browsePivot = context.browseSearch.pivot;
             }
-        });
+        }
 
-        // 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 (context.termSearch.isSearchable()) {
+
+            const ts = context.termSearch;
+
+            params.query = [];
+            params.fieldClass = [];
+            params.joinOp = [];
+            params.matchOp = [];
+
+            ['format', 'available', 'hasBrowseEntry', 'date1', 
+                'date2', 'dateOp', 'groupByMetarecord', 'fromMetarecord']
+            .forEach(field => {
+                if (ts[field]) {
+                    params[field] = ts[field];
+                }
+            });
+
+            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]);
+                }
+            });
+
+            // 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;
     }
@@ -97,47 +140,96 @@ 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']
-        .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 (params.get('limit')) {
-            context.pager.limit = +params.get('limit');
+        if (val = params.get('limit')) {
+            context.pager.limit = +val;
         }
 
-        if (params.get('offset')) {
-            context.pager.offset = +params.get('offset');
+        if (val = params.get('offset')) {
+            context.pager.offset = +val;
         }
 
-        ['query', 'fieldClass', 'joinOp', 'matchOp'].forEach(field => {
-            const arr = params.getAll(field);
-            if (arr && arr.length) {
-                context[field] = arr;
-            }
-        });
+        if (val = params.get('sort')) {
+            context.sort = val;
+        }
+
+        if (val = params.get('global')) {
+            context.global = val;
+        }
+
+        if (val = params.get('showBasket')) {
+            context.showBasket = val;
+        }
 
-        CATALOG_CCVM_FILTERS.forEach(code => {
-            const val = params.get(code);
-            if (val) {
-                context.ccvmFilters[code] = val.split(/,/);
-            } else {
-                context.ccvmFilters[code] = [''];
+        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('identQuery')) {
+            context.identSearch.value = params.get('identQuery');
+            context.identSearch.queryType = params.get('identQueryType');
+        }
+
+        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;
 
+        // browseEntry and query searches may be facet-limited
         params.getAll('facets').forEach(blob => {
             const facet = JSON.parse(blob);
-            context.addFacet(new FacetFilter(facet.c, facet.n, facet.v));
+            ts.addFacet(new FacetFilter(facet.c, facet.n, facet.v));
         });
 
-        if (params.get('org')) {
-            context.searchOrg = this.org.get(+params.get('org'));
+        if (params.has('hasBrowseEntry')) {
+
+            ts.hasBrowseEntry = params.get('hasBrowseEntry');
+
+        } else if (params.has('query')) {
+
+            // Scalars
+            ['format', 'available', 'date1', 'date2', 
+                'dateOp', 'groupByMetarecord', 'fromMetarecord']
+            .forEach(field => {
+                if (params.has(field)) {
+                    ts[field] = params.get(field);
+                }
+            });
+
+            // Arrays
+            ['query', 'fieldClass', 'joinOp', 'matchOp'].forEach(field => {
+                const arr = params.getAll(field);
+                if (params.has(field)) {
+                    ts[field] = params.getAll(field); 
+                }
+            });
+
+            CATALOG_CCVM_FILTERS.forEach(code => {
+                const val = params.get(code);
+                if (val) {
+                    ts.ccvmFilters[code] = val.split(/,/);
+                } else {
+                    ts.ccvmFilters[code] = [''];
+                }
+            });
+
+            if (params.get('copyLocations')) {
+                ts.copyLocations = params.get('copyLocations').split(/,/);
+            }
         }
     }
 }
+
+
index 7c3a365..b8ffb85 100644 (file)
@@ -1,6 +1,6 @@
-import {Injectable} from '@angular/core';
+import {Injectable, EventEmitter} from '@angular/core';
 import {Observable} from 'rxjs';
-import {mergeMap, map} from 'rxjs/operators';
+import {mergeMap, map, tap} 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';
@@ -8,27 +8,15 @@ import {NetService} from '@eg/core/net.service';
 import {PcrudService} from '@eg/core/pcrud.service';
 import {CatalogSearchContext, CatalogSearchState} from './search-context';
 import {BibRecordService, BibRecordSummary} from './bib-record.service';
-
-// CCVM's we care about in a catalog context
-// Don't fetch them all because there are a lot.
-export const CATALOG_CCVM_FILTERS = [
-    'item_type',
-    'item_form',
-    'item_lang',
-    'audience',
-    'audience_group',
-    'vr_format',
-    'bib_level',
-    'lit_form',
-    'search_format',
-    'icon_format'
-];
+import {BasketService} from './basket.service';
+import {CATALOG_CCVM_FILTERS} from './search-context';
 
 @Injectable()
 export class CatalogService {
 
     ccvmMap: {[ccvm: string]: IdlObject[]} = {};
     cmfMap: {[cmf: string]: IdlObject} = {};
+    copyLocations: IdlObject[];
 
     // Keep a reference to the most recently retrieved facet data,
     // since facet data is consistent across a given search.
@@ -36,27 +24,117 @@ export class CatalogService {
     lastFacetData: any;
     lastFacetKey: string;
 
+    // Allow anyone to watch for completed searches.
+    onSearchComplete: EventEmitter<CatalogSearchContext>;
+
     constructor(
         private idl: IdlService,
         private net: NetService,
         private org: OrgService,
         private unapi: UnapiService,
         private pcrud: PcrudService,
-        private bibService: BibRecordService
-    ) {}
+        private bibService: BibRecordService,
+        private basket: BasketService
+    ) {
+        this.onSearchComplete = new EventEmitter<CatalogSearchContext>();
+        
+    }
 
     search(ctx: CatalogSearchContext): Promise<void> {
         ctx.searchState = CatalogSearchState.SEARCHING;
 
-        const fullQuery = ctx.compileSearch();
+        if (ctx.showBasket) {
+            return this.basketSearch(ctx);
+        } else if (ctx.marcSearch.isSearchable()) {
+            return this.marcSearch(ctx);
+        } else if (ctx.identSearch.isSearchable() && 
+            ctx.identSearch.queryType === 'item_barcode') {
+            return this.barcodeSearch(ctx);
+        } else {
+            return this.termSearch(ctx);
+        }
+    }
 
-        console.debug(`search query: ${fullQuery}`);
+    barcodeSearch(ctx: CatalogSearchContext): Promise<void> {
+        return this.net.request(
+            'open-ils.search',
+            'open-ils.search.multi_home.bib_ids.by_barcode',
+            ctx.identSearch.value
+        ).toPromise().then(ids => {
+            const result = {
+                count: ids.length,
+                ids: ids.map(id => [id])
+            };
+
+            this.applyResultData(ctx, result);
+            ctx.searchState = CatalogSearchState.COMPLETE;
+            this.onSearchComplete.emit(ctx);
+        });
+    }
+
+    // "Search" the basket by loading the IDs and treating
+    // them like a standard query search results set.
+    basketSearch(ctx: CatalogSearchContext): Promise<void> {
+
+        return this.basket.getRecordIds().then(ids => {
+
+            // Map our list of IDs into a search results object
+            // the search context can understand.
+            const result = {
+                count: ids.length,
+                ids: ids.map(id => [id])
+            };
+
+            this.applyResultData(ctx, result);
+            ctx.searchState = CatalogSearchState.COMPLETE;
+            this.onSearchComplete.emit(ctx);
+        });
+    }
+
+    marcSearch(ctx: CatalogSearchContext): Promise<void> {
+        let method = 'open-ils.search.biblio.marc';
+        if (ctx.isStaff) { method += '.staff'; }
+
+        const queryStruct = ctx.compileMarcSearchArgs();
+
+        return this.net.request('open-ils.search', method, queryStruct)
+        .toPromise().then(result => {
+            // Match the query search return format
+            result.ids = result.ids.map(id => [id]);
+
+            this.applyResultData(ctx, result);
+            ctx.searchState = CatalogSearchState.COMPLETE;
+            this.onSearchComplete.emit(ctx);
+        });
+    }
+
+    termSearch(ctx: CatalogSearchContext): Promise<void> {
 
         let method = 'open-ils.search.biblio.multiclass.query';
+        let fullQuery;
+
+        if (ctx.identSearch.isSearchable()) {
+            fullQuery = ctx.compileIdentSearchQuery();
+
+        } else {
+            fullQuery = ctx.compileTermSearchQuery();
+
+            if (ctx.termSearch.groupByMetarecord 
+                && !ctx.termSearch.fromMetarecord) {
+                method = 'open-ils.search.metabib.multiclass.query';
+            }
+
+            if (ctx.termSearch.hasBrowseEntry) {
+                this.fetchBrowseEntry(ctx);
+            }
+        }
+
+        console.debug(`search query: ${fullQuery}`);
+
         if (ctx.isStaff) {
             method += '.staff';
         }
-
+        
         return new Promise((resolve, reject) => {
             this.net.request(
                 'open-ils.search', method, {
@@ -66,9 +144,24 @@ export class CatalogService {
             ).subscribe(result => {
                 this.applyResultData(ctx, result);
                 ctx.searchState = CatalogSearchState.COMPLETE;
+                this.onSearchComplete.emit(ctx);
                 resolve();
             });
         });
+
+    }
+
+    // When showing titles linked to a browse entry, fetch 
+    // the entry data as well so the UI can display it.
+    fetchBrowseEntry(ctx: CatalogSearchContext) {
+        const ts = ctx.termSearch;
+
+        const parts = ts.hasBrowseEntry.split(',');
+        const mbeId = parts[0];
+        const cmfId = parts[1];
+
+        this.pcrud.retrieve('mbe', mbeId)
+        .subscribe(mbe => ctx.termSearch.browseEntry = mbe);
     }
 
     applyResultData(ctx: CatalogSearchContext, result: any): void {
@@ -94,11 +187,27 @@ export class CatalogService {
             ctx.org.root().ou_type().depth() :
             ctx.searchOrg.ou_type().depth();
 
-        return this.bibService.getBibSummary(
-            ctx.currentResultIds(), ctx.searchOrg.id(), depth)
-        .pipe(map(summary => {
+        const isMeta = ctx.termSearch.isMetarecordSearch();
+
+        let observable: Observable<BibRecordSummary>;
+        
+        if (isMeta) {
+            observable = this.bibService.getMetabibSummary(
+                ctx.currentResultIds(), ctx.searchOrg.id(), depth);
+        } else {
+            observable = this.bibService.getBibSummary(
+                ctx.currentResultIds(), ctx.searchOrg.id(), depth);
+        }
+
+        return observable.pipe(map(summary => {
             // Responses are not necessarily returned in request-ID order.
-            const idx = ctx.currentResultIds().indexOf(summary.record.id());
+            let idx;
+            if (isMeta) {
+                idx = ctx.currentResultIds().indexOf(summary.metabibId);
+            } else {
+                idx = ctx.currentResultIds().indexOf(summary.id);
+            }
+
             if (ctx.result.records) {
                 // May be reset when quickly navigating results.
                 ctx.result.records[idx] = summary;
@@ -112,6 +221,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();
@@ -188,6 +301,15 @@ export class CatalogService {
         });
     }
 
+    iconFormatLabel(code: string): string {
+        if (this.ccvmMap) {
+            const ccvm = this.ccvmMap.icon_format.filter(
+                format => format.code() === code)[0];
+            if (ccvm) {
+                return ccvm.search_label();
+            }                                                                  
+        }                                                                      
+    }      
 
     fetchCmfs(): Promise<void> {
         // At the moment, we only need facet CMFs.
@@ -206,4 +328,38 @@ export class CatalogService {
             );
         });
     }
+
+    fetchCopyLocations(contextOrg: number | IdlObject): Promise<any> {
+        const orgIds = this.org.fullPath(contextOrg, true);
+        this.copyLocations = [];
+
+        return this.pcrud.search('acpl', 
+            {deleted: 'f', opac_visible: 't', owning_lib: orgIds},
+            {order_by: {acpl: 'name'}},
+            {anonymous: true}
+        ).pipe(tap(loc => this.copyLocations.push(loc))).toPromise()
+    }
+
+    browse(ctx: CatalogSearchContext): Observable<any> {
+        ctx.searchState = CatalogSearchState.SEARCHING;
+        const bs = ctx.browseSearch;
+
+        let method = 'open-ils.search.browse';
+        if (ctx.isStaff) {
+            method += '.staff';
+        }
+
+        return this.net.request(
+            'open-ils.search',
+            'open-ils.search.browse.staff', {
+                browse_class: bs.fieldClass,
+                term: bs.value,
+                limit : ctx.pager.limit,
+                pivot: bs.pivot,
+                org_unit: ctx.searchOrg.id()
+            }
+        ).pipe(tap(result => {
+            ctx.searchState = CatalogSearchState.COMPLETE;
+        }));
+    }
 }
index e4e64b2..d34d711 100644 (file)
@@ -3,6 +3,21 @@ import {IdlObject} from '@eg/core/idl.service';
 import {Pager} from '@eg/share/util/pager';
 import {Params} from '@angular/router';
 
+// CCVM's we care about in a catalog context
+// Don't fetch them all because there are a lot.
+export const CATALOG_CCVM_FILTERS = [
+    'item_type',
+    'item_form',
+    'item_lang',
+    'audience',
+    'audience_group',
+    'vr_format',
+    'bib_level',
+    'lit_form',
+    'search_format',
+    'icon_format'
+];
+
 export enum CatalogSearchState {
     PENDING,
     SEARCHING,
@@ -29,32 +44,183 @@ 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[];
+    copyLocations: string[]; // ID's, but treated as strings in the UI.
+
+    // True when searching for metarecords
+    groupByMetarecord: boolean;
+
+    // Filter results by records which link to this metarecord ID.
+    fromMetarecord: number;
+
+    hasBrowseEntry: string; // "entryId,fieldId"
+    browseEntry: IdlObject;
+    date1: number;
+    date2: number;
+    dateOp: string; // before, after, between, is
+
+    reset() {
+        this.query = [''];
+        this.fieldClass  = ['keyword'];
+        this.matchOp = ['contains'];
+        this.joinOp = [''];
+        this.facetFilters = [];
+        this.copyLocations = [''];
+        this.format = '';
+        this.hasBrowseEntry = '';
+        this.date1 = null;
+        this.date2 = null;
+        this.dateOp = 'is';
+        this.fromMetarecord = null;
+
+        // Apply empty string values for each ccvm filter
+        this.ccvmFilters = {};
+        CATALOG_CCVM_FILTERS.forEach(code => this.ccvmFilters[code] = ['']);
+    }
+
+    // True when grouping by metarecord but not when displaying the
+    // contents of a metarecord.
+    isMetarecordSearch(): boolean {
+        return (
+            this.isSearchable() && 
+            this.groupByMetarecord && 
+            this.fromMetarecord === null
+        );
+    }
+
+    isSearchable(): boolean {
+        return (
+            this.query[0] !== ''
+            || this.hasBrowseEntry !== ''
+            || this.fromMetarecord !== null
+        );
+    }
+
+    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;
@@ -62,9 +228,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 = [];
@@ -97,119 +294,53 @@ 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;
-    }
-
-    isSearchable(): boolean {
-
-        if (this.identQuery && this.identQueryType) {
-            return true;
-        }
-
-        return this.query.length
-            && this.query[0] !== ''
-            && this.searchOrg !== null;
-    }
-
-    compileSearch(): string {
-        let str = '';
+    compileMarcSearchArgs(): any {
+        const searches: any = [];
+        const ms = this.marcSearch;
+
+        ms.values.forEach((val, idx) => {
+            if (val !== '') {
+                searches.push({
+                    restrict: [{
+                        // "_" is the wildcard subfield for the API.
+                        subfield: ms.subfields[idx] ? ms.subfields[idx] : '_',
+                        tag: ms.tags[idx]
+                    }],
+                    term: ms.values[idx]
+                });
+            }
+        });
 
-        if (this.available) {
-            str += '#available';
-        }
+        const args: any = {
+            searches: searches,
+            limit : this.pager.limit,
+            offset : this.pager.offset,
+            org_unit: this.searchOrg.id()
+        };
 
         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.format) {
-            str += ' format(' + this.format + ')';
-        }
-
-        if (this.global) {
-            str += ' depth(' +
-                this.org.root().ou_type().depth() + ')';
+            args.sort = parts[0]; // title, author, etc.
+            if (parts[1]) { args.sort_dir = 'descending' };
         }
 
-        str += ' site(' + this.searchOrg.shortname() + ')';
-
-        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;
+        return args;
     }
 
-    stripQuotes(query: string): string {
-        return query.replace(/"/g, '');
-    }
+    compileIdentSearchQuery(): string {
 
-    stripAnchors(query: string): string {
-        return query.replace(/[\^\$]/g, '');
+        let str = ' site(' + this.searchOrg.shortname() + ')';
+        return str + ' ' + 
+            this.identSearch.queryType + ':' + this.identSearch.value;
     }
 
-    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; }
@@ -238,29 +369,103 @@ 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] + ')';
+        }
+
+        if (ts.date1 && ts.dateOp) {
+            switch (ts.dateOp) {
+                case 'is':
+                    str += ` date1(${ts.date1})`;
+                    break;
+                case 'before':
+                    str += ` before(${ts.date1})`;
+                    break;
+                case 'after':
+                    str += ` after(${ts.date1})`;
+                    break;
+                case 'between':
+                    if (ts.date2) {
+                        str += ` between(${ts.date1},${ts.date2})`;
+                    }
+            }
+        }
+
+        // -------
+        // 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.fromMetarecord) {
+            str += ` from_metarecord(${ts.fromMetarecord})`;
+        }
+
+        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 a1558b1..575bbde 100644 (file)
@@ -8,11 +8,12 @@
     placeholder="yyyy-mm-dd"
     class="form-control"
     name="{{fieldName}}"
+    [disabled]="_disabled"
     [required]="required"
     [(ngModel)]="current"
     (dateSelect)="onDateSelect($event)">
   <div class="input-group-append">
-    <button class="btn btn-outline-secondary" 
+    <button class="btn btn-outline-secondary" [disabled]="_disabled"
       (click)="datePicker.toggle()" type="button">
       <span title="Select Date" i18n-title                       
         class="material-icons mat-icon-in-button">calendar_today</span>
index 2f8837d..6256290 100644 (file)
@@ -18,9 +18,13 @@ export class DateSelectComponent implements OnInit {
     @Input() initialDate: Date;  // Date object
     @Input() required: boolean;
     @Input() fieldName: string;
-
     @Input() domId = '';
 
+    _disabled: boolean;
+    @Input() set disabled(d: boolean) {
+        this._disabled = d;
+    }
+
     current: NgbDateStruct;
 
     @Output() onChangeAsDate: EventEmitter<Date>;
index 25d0552..17c0e46 100644 (file)
@@ -28,7 +28,9 @@ interface CustomFieldContext {
 
 @Component({
   selector: 'eg-fm-record-editor',
-  templateUrl: './fm-editor.component.html'
+  templateUrl: './fm-editor.component.html',
+  /* align checkboxes when not using class="form-check" */
+  styles: ['input[type="checkbox"] {margin-left: 0px;}']
 })
 export class FmRecordEditorComponent
     extends DialogComponent implements OnInit {
@@ -181,9 +183,12 @@ export class FmRecordEditorComponent
             });
         }
 
-        // create a new record from scratch
+        // create a new record from scratch or from a stub record
+        // provided by the caller.
         this.pkeyIsEditable = !('pkey_sequence' in this.idlDef);
-        this.record = this.idl.create(this.idlClass);
+        if (!this.record) {
+            this.record = this.idl.create(this.idlClass);
+        }
         return this.getFieldList();
     }
 
index b7284fe..8d495aa 100644 (file)
@@ -6,9 +6,11 @@
     [ngClass]="{'selected': context.rowSelector.contains(context.getRowIndex(row))}"
     *ngFor="let row of context.dataSource.getPageOfRows(context.pager); let idx = index">
 
-    <div class="eg-grid-cell eg-grid-checkbox-cell eg-grid-cell-skinny">
-      <input type='checkbox' [(ngModel)]="context.rowSelector.indexes[context.getRowIndex(row)]">
-    </div>
+    <ng-container *ngIf="!context.disableSelect">
+      <div class="eg-grid-cell eg-grid-checkbox-cell eg-grid-cell-skinny">
+        <input type='checkbox' [(ngModel)]="context.rowSelector.indexes[context.getRowIndex(row)]">
+      </div>
+    </ng-container>
     <div class="eg-grid-cell eg-grid-number-cell eg-grid-cell-skinny">
       {{context.pager.rowNumber(idx)}}
     </div>
index e4829ce..15aa2b7 100644 (file)
@@ -50,6 +50,13 @@ export class GridBodyComponent implements OnInit {
     }
 
     onRowClick($event: any, row: any, idx: number) {
+
+        if (this.context.disableSelect) {
+            // Avoid any appearance or click behavior when row
+            // selection is disabled.
+            return;
+        }
+
         const index = this.context.getRowIndex(row);
 
         if (this.context.disableMultiSelect) {
index 58e0c66..0662f54 100644 (file)
@@ -1,8 +1,10 @@
 
 <div class="eg-grid-row eg-grid-header-row">
-  <div class="eg-grid-cell eg-grid-header-cell eg-grid-checkbox-cell eg-grid-cell-skinny">
-    <input type='checkbox' (click)="handleBatchSelect($event)">
-  </div>
+  <ng-container *ngIf="!context.disableSelect">
+    <div class="eg-grid-cell eg-grid-header-cell eg-grid-checkbox-cell eg-grid-cell-skinny">
+      <input type='checkbox' (click)="handleBatchSelect($event)">
+    </div>
+  </ng-container>
   <div class="eg-grid-cell eg-grid-header-cell eg-grid-number-cell eg-grid-cell-skinny">
     <span i18n="number|Row Number Header">#</span>
   </div>
index 3bcc2cb..d48028b 100644 (file)
@@ -44,6 +44,8 @@ export class GridComponent implements OnInit, AfterViewInit, OnDestroy {
     // The value is prefixed with 'eg.grid.'
     @Input() persistKey: string;
 
+    @Input() disableSelect: boolean;
+
     // Prevent selection of multiple rows
     @Input() disableMultiSelect: boolean;
 
@@ -109,6 +111,7 @@ export class GridComponent implements OnInit, AfterViewInit, OnDestroy {
         this.context.isSortable = this.sortable === true;
         this.context.isMultiSortable = this.multiSortable === true;
         this.context.useLocalSort = this.useLocalSort === true;
+        this.context.disableSelect = this.disableSelect === true;
         this.context.disableMultiSelect = this.disableMultiSelect === true;
         this.context.rowFlairIsEnabled = this.rowFlairIsEnabled  === true;
         this.context.rowFlairCallback = this.rowFlairCallback;
index 37bb188..dcffc95 100644 (file)
@@ -421,6 +421,7 @@ export class GridContext {
     useLocalSort: boolean;
     persistKey: string;
     disableMultiSelect: boolean;
+    disableSelect: boolean;
     dataSource: GridDataSource;
     columnSet: GridColumnSet;
     rowSelector: GridRowSelector;
diff --git a/Open-ILS/src/eg2/src/app/share/util/anon-cache.service.ts b/Open-ILS/src/eg2/src/app/share/util/anon-cache.service.ts
new file mode 100644 (file)
index 0000000..29c168d
--- /dev/null
@@ -0,0 +1,59 @@
+/**
+ * Service for communicating with the server-side "anonymous" cache.
+ */
+import {Injectable} from '@angular/core';
+import {Observable} from 'rxjs/Observable';
+import {StoreService} from '@eg/core/store.service';
+import {NetService} from '@eg/core/net.service';
+
+// All anon-cache data is stored in a single blob per user session.
+// Value is generated on the server with the first call to set_value
+// and stored locally as a LoginSession item (cookie).
+
+@Injectable()
+export class AnonCacheService {
+
+    constructor(private store: StoreService, private net: NetService) {}
+
+    getItem(cacheKey: string, attr: string): Promise<any> {
+        return this.net.request(
+            'open-ils.actor',
+            'open-ils.actor.anon_cache.get_value', cacheKey, attr
+        ).toPromise();
+    }
+
+    // Apply 'value' to field 'attr' in the object cached at 'cacheKey'.
+    // If no cacheKey is provided, the server will generate one.
+    // Returns a promised resolved with the cache key.
+    setItem(cacheKey: string, attr: string, value: any): Promise<string> {
+        return this.net.request(
+            'open-ils.actor',
+            'open-ils.actor.anon_cache.set_value',
+            cacheKey, attr, value
+        ).toPromise().then(cacheKey => {
+            if (cacheKey) {
+                return cacheKey;
+            } else {
+                return Promise.reject(
+                    `Could not apply a value for attr=${attr} cacheKey=${cacheKey}`);
+            }
+        })
+    }
+
+    removeItem(cacheKey: string, attr: string): Promise<string> {
+        return this.net.request(
+            'open-ils.actor',
+            'open-ils.actor.anon_cache.set_value',
+            cacheKey, attr, null
+        ).toPromise();
+    }
+
+    clear(cacheKey: string): Promise<string> {
+        return this.net.request(
+            'open-ils.actor',
+            'open-ils.actor.anon_cache.delete_session', cacheKey
+        ).toPromise();
+    }
+}
+
+
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
new file mode 100644 (file)
index 0000000..5837ace
--- /dev/null
@@ -0,0 +1,28 @@
+<eg-record-bucket-dialog #addBasketToBucketDialog>
+</eg-record-bucket-dialog>
+
+<div class="row">
+  <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]="{showBasket: true}" 
+        class="label-with-material-icon">
+        <span class="material-icons">shopping_basket</span>
+        <span i18n>({{basketCount()}})</span>
+      </a>
+    </div>
+  </div>
+  <div class="col-lg-8 pl-1">
+    <select class="form-control" 
+        [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="clear"  i18n>Clear Basket</option>
+    </select>
+  </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
new file mode 100644 (file)
index 0000000..08d02bc
--- /dev/null
@@ -0,0 +1,106 @@
+import {Component, OnInit, ViewChild} from '@angular/core';
+import {BasketService} from '@eg/share/catalog/basket.service';
+import {Subscription} from 'rxjs/Subscription';
+import {Router} from '@angular/router';
+import {NetService} from '@eg/core/net.service';
+import {AuthService} from '@eg/core/auth.service';
+import {PrintService} from '@eg/share/print/print.service';
+import {RecordBucketDialogComponent} 
+    from '@eg/staff/share/buckets/record-bucket-dialog.component';
+
+@Component({
+  selector: 'eg-catalog-basket-actions',
+  templateUrl: 'basket-actions.component.html'
+})
+export class BasketActionsComponent implements OnInit {
+
+    basketAction: string;
+
+    @ViewChild('addBasketToBucketDialog')
+        addToBucketDialog: RecordBucketDialogComponent;
+
+    constructor(
+        private router: Router,
+        private net: NetService,
+        private auth: AuthService,
+        private printer: PrintService,
+        private basket: BasketService
+    ) {
+        this.basketAction = '';
+    }
+
+    ngOnInit() {
+    }
+
+    basketCount(): number {
+        return this.basket.recordCount();
+    }
+
+    // TODO: confirmation dialogs?
+
+    applyAction() {
+        console.debug('Performing basket action', this.basketAction);
+
+        switch(this.basketAction) {
+            case 'view':
+                // This does not propagate search params -- unclear if needed.
+                this.router.navigate(['/staff/catalog/search'], 
+                    {queryParams: {showBasket: true}});
+                break;
+
+            case 'clear':
+                this.basket.removeAllRecordIds();
+                break;
+
+            case 'hold':
+                this.basket.getRecordIds().then(ids => {
+                    this.router.navigate(['/staff/catalog/hold/T'],
+                        {queryParams: {target: ids}});
+                });
+                break; 
+
+            case 'print':
+                this.basket.getRecordIds().then(ids => {
+                    this.net.request(
+                        'open-ils.search',
+                        'open-ils.search.biblio.record.print', ids
+                    ).subscribe(
+                        at_event => {
+                            // check for event..
+                            const html = at_event.template_output().data();
+                            this.printer.print({
+                                text: html,
+                                printContext: 'default'
+                            });
+                        }
+                    );
+                });
+                break;
+
+            case 'email':
+                this.basket.getRecordIds().then(ids => {
+                    this.net.request(
+                        'open-ils.search',
+                        'open-ils.search.biblio.record.email',
+                        this.auth.token(), ids
+                    ).toPromise(); // fire-and-forget
+                });
+                break;
+
+            case 'bucket':
+                this.basket.getRecordIds().then(ids => {
+                    this.addToBucketDialog.recordId = ids;
+                    this.addToBucketDialog.open({size: 'lg'});
+                });
+                break;
+
+        }
+
+        // Resetting basketAction inside its onchange handler
+        // prevents the new value from propagating to Angular
+        // Reset after the current thread.
+        setTimeout(() => this.basketAction = ''); // reset
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/browse.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/browse.component.html
new file mode 100644 (file)
index 0000000..b50a415
--- /dev/null
@@ -0,0 +1,5 @@
+
+<eg-catalog-search-form #searchForm></eg-catalog-search-form>
+
+<eg-catalog-browse-results><eg-catalog-browse-results>
+
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/browse.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/browse.component.ts
new file mode 100644 (file)
index 0000000..67e5eed
--- /dev/null
@@ -0,0 +1,28 @@
+import {Component, OnInit, ViewChild} from '@angular/core';
+import {StaffCatalogService} from './catalog.service';
+import {BasketService} from '@eg/share/catalog/basket.service';
+import {SearchFormComponent} from './search-form.component';
+
+@Component({
+  templateUrl: 'browse.component.html'
+})
+export class BrowseComponent implements OnInit {
+
+    @ViewChild('searchForm') searchForm: SearchFormComponent;
+
+    constructor(
+        private staffCat: StaffCatalogService,
+        private basket: BasketService
+    ) {}
+
+    ngOnInit() {
+        // A SearchContext provides all the data needed for browse.
+        this.staffCat.createContext();
+
+        // Cache the basket on page load.
+        this.basket.getRecordIds();
+
+        this.searchForm.searchTab = 'browse';
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/browse/results.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/browse/results.component.html
new file mode 100644 (file)
index 0000000..fdbb054
--- /dev/null
@@ -0,0 +1,84 @@
+
+<!-- search results progress bar -->
+<div class="row" *ngIf="browseIsActive()">
+  <div class="col-lg-6 offset-lg-3 pt-3">
+    <div class="progress">
+      <div class="progress-bar progress-bar-striped active w-100"
+        role="progressbar" aria-valuenow="100" 
+        aria-valuemin="0" aria-valuemax="100">
+        <span class="sr-only" i18n>Searching..</span>
+      </div>
+    </div>
+  </div>
+</div>
+
+<!-- no items found -->
+<div *ngIf="browseIsDone() && !browseHasResults()">
+  <div class="row pt-3">
+    <div class="col-lg-6 offset-lg-3">
+      <div class="alert alert-warning">
+        <span i18n>No Maching Items Were Found</span>
+      </div>
+    </div>
+  </div>
+</div>
+
+<!-- header, pager, and list of records -->
+<div id="staff-catalog-browse-results-container" *ngIf="browseHasResults()">
+
+  <div class="row mb-2">
+    <div class="col-lg-3">
+      <button class="btn btn-primary" (click)="prevPage()">Back</button>
+      <button class="btn btn-primary ml-3" (click)="nextPage()">Next</button>
+    </div>
+  </div>
+
+  <div class="row" *ngFor="let result of results">
+    <div *ngIf="result.value" 
+      class="col-lg-12 card tight-card mb-2 bg-light">
+      <div class="col-lg-8">
+        <div class="card-body">
+          <ng-container *ngIf="result.sources > 0">
+            <a (click)="searchByBrowseEntry(result)" href="javascript:void(0)">
+                {{result.value}} ({{result.sources}})
+            </a>
+          </ng-container>
+          <ng-container *ngIf="result.sources == 0">
+            <span>{{result.value}}</span>
+          </ng-container>
+          <div class="row" *ngFor="let heading of result.compiledHeadings">
+            <div class="col-lg-10 offset-lg-1" i18n>
+              <span class="font-italic">
+                <ng-container *ngIf="!heading.type || heading.type == 'variant'">
+                    See
+                </ng-container>
+                <ng-container *ngIf="heading.type == 'broader'">
+                    Broader term
+                </ng-container>
+                <ng-container *ngIf="heading.type == 'narrower'">
+                    Narrower term
+                </ng-container>
+                <ng-container *ngIf="heading.type == 'other'">
+                    Related term
+                </ng-container>
+              </span>
+              <a (click)="newBrowseFromHeading(heading)" href="javascript:void(0)">
+                {{heading.heading}} ({{heading.target_count}})
+              </a>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+
+  <div class="row mb-2">
+    <div class="col-lg-3">
+      <button class="btn btn-primary" (click)="prevPage()">Back</button>
+      <button class="btn btn-primary ml-3" (click)="nextPage()">Next</button>
+    </div>
+  </div>
+
+</div>
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/browse/results.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/browse/results.component.ts
new file mode 100644 (file)
index 0000000..8fcbce1
--- /dev/null
@@ -0,0 +1,140 @@
+import {Component, OnInit, Input} from '@angular/core';
+import {Observable} from 'rxjs/Observable';
+import {Subscription} from 'rxjs/Subscription';
+import {map, switchMap, distinctUntilChanged} from 'rxjs/operators';
+import {ActivatedRoute, ParamMap} from '@angular/router';
+import {CatalogService} from '@eg/share/catalog/catalog.service';
+import {BibRecordService} from '@eg/share/catalog/bib-record.service';
+import {CatalogUrlService} from '@eg/share/catalog/catalog-url.service';
+import {CatalogSearchContext, CatalogSearchState} from '@eg/share/catalog/search-context';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {StaffCatalogService} from '../catalog.service';
+import {IdlObject} from '@eg/core/idl.service';
+
+@Component({
+  selector: 'eg-catalog-browse-results',
+  templateUrl: 'results.component.html'
+})
+export class BrowseResultsComponent implements OnInit {
+
+    searchContext: CatalogSearchContext;
+    results: any[];
+
+    constructor(
+        private route: ActivatedRoute,
+        private pcrud: PcrudService,
+        private cat: CatalogService,
+        private bib: BibRecordService,
+        private catUrl: CatalogUrlService,
+        private staffCat: StaffCatalogService
+    ) {}
+
+    ngOnInit() {
+        this.searchContext = this.staffCat.searchContext;
+        this.route.queryParamMap.subscribe((params: ParamMap) => {
+            this.browseByUrl(params);
+        });
+    }
+
+    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 (bs.fieldClass === 'keyword') {
+            bs.fieldClass = 'title';
+        }
+
+        if (bs.isSearchable()) {
+            this.results = [];
+            this.cat.browse(this.searchContext)
+                .subscribe(result => this.addResult(result))
+        }
+    }
+
+    addResult(result: any) {
+
+        result.compiledHeadings = [];
+
+        // Avoi dupe headings per see
+        const seen: any = {};
+
+        result.sees.forEach(sees => {
+            if (!sees.control_set) { return; }
+
+            sees.headings.forEach(headingStruct => {
+                const fieldId = Object.keys(headingStruct)[0];
+                const heading = headingStruct[fieldId][0];
+
+                const inList = result.list_authorities.filter(
+                    id => Number(id) === Number(heading.target))[0]
+
+                if (   heading.target 
+                    && heading.main_entry
+                    && heading.target_count 
+                    && !inList
+                    && !seen[heading.target]) {
+
+                    seen[heading.target] = true;
+
+                    result.compiledHeadings.push({
+                        heading: heading.heading,
+                        target: heading.target,
+                        target_count: heading.target_count,
+                        type: heading.type
+                    });
+                }
+            });
+        });
+
+        this.results.push(result);
+    }
+
+    browseIsDone(): boolean {
+        return this.searchContext.searchState === CatalogSearchState.COMPLETE;
+    }
+
+    browseIsActive(): boolean {
+        return this.searchContext.searchState === CatalogSearchState.SEARCHING;
+    }
+
+    browseHasResults(): boolean {
+        return this.browseIsDone() && this.results.length > 0;
+    }
+
+    prevPage() {
+        const firstResult = this.results[0];
+        if (firstResult) {
+            this.searchContext.browseSearch.pivot = firstResult.pivot_point;
+            this.staffCat.browse();
+        }
+    }
+
+    nextPage() {
+        const lastResult = this.results[this.results.length - 1];
+        if (lastResult) {
+            this.searchContext.browseSearch.pivot = lastResult.pivot_point;
+            this.staffCat.browse();
+        }
+    }
+
+    searchByBrowseEntry(result) { 
+
+        // Avoid propagating browse values to term search.
+        this.searchContext.browseSearch.reset();
+
+        this.searchContext.termSearch.hasBrowseEntry = 
+            result.browse_entry + ',' + result.fields;
+        this.staffCat.search();
+    }
+
+    // NOTE: to test unauthorized heading display in concerto
+    // browse for author = kab
+    newBrowseFromHeading(heading) {
+        this.searchContext.browseSearch.value = heading.heading;
+        this.staffCat.browse();
+    }
+}
+
+
index 8b2206c..0e2fc98 100644 (file)
@@ -1,18 +1,25 @@
 import {Component, OnInit} from '@angular/core';
 import {StaffCatalogService} from './catalog.service';
+import {BasketService} from '@eg/share/catalog/basket.service';
 
 @Component({
   templateUrl: 'catalog.component.html'
 })
 export class CatalogComponent implements OnInit {
 
-    constructor(private staffCat: StaffCatalogService) {}
+    constructor(
+        private basket: BasketService,
+        private staffCat: StaffCatalogService
+    ) {}
 
     ngOnInit() {
         // Create the search context that will be used by all of my
         // child components.  After initial creation, the context is
         // reset and updated as needed to apply new search parameters.
         this.staffCat.createContext();
+
+        // Cache the basket on page load.
+        this.basket.getRecordIds();
     }
 }
 
index 20e17a0..2d30199 100644 (file)
@@ -14,6 +14,13 @@ import {StaffCatalogService} from './catalog.service';
 import {RecordPaginationComponent} from './record/pagination.component';
 import {RecordActionsComponent} from './record/actions.component';
 import {HoldingsService} from '@eg/staff/share/holdings.service';
+import {BasketActionsComponent} from './basket-actions.component';
+import {HoldComponent} from './hold/hold.component';
+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 {BrowseResultsComponent} from './browse/results.component';
 
 @NgModule({
   declarations: [
@@ -26,7 +33,13 @@ import {HoldingsService} from '@eg/staff/share/holdings.service';
     ResultFacetsComponent,
     ResultPaginationComponent,
     RecordPaginationComponent,
-    RecordActionsComponent
+    RecordActionsComponent,
+    BasketActionsComponent,
+    HoldComponent,
+    PartsComponent,
+    PartMergeDialogComponent,
+    BrowseComponent,
+    BrowseResultsComponent
   ],
   imports: [
     StaffCommonModule,
@@ -35,7 +48,8 @@ import {HoldingsService} from '@eg/staff/share/holdings.service';
   ],
   providers: [
     StaffCatalogService,
-    HoldingsService
+    HoldingsService,
+    HoldService
   ]
 })
 
index 1e50d9b..cf0a36c 100644 (file)
@@ -82,6 +82,28 @@ export class StaffCatalogService {
           ['/staff/catalog/search'], {queryParams: params});
     }
 
+    /**
+     * Redirect to the browse results page while propagating the current
+     * browse paramters into the URL.  Let the browse results component
+     * execute the actual browse.
+     */
+    browse(): void {
+        if (!this.searchContext.browseSearch.isSearchable()) { return; }
+
+        const params = this.catUrl.toUrlParams(this.searchContext);
+
+        // Force a new browse every time this method is called, even if
+        // it's the same as the active browse.  Since router navigation
+        // exits early when the route + params is identical, add a
+        // random token to the route params to force a full navigation.
+        // This also resolves a problem where only removing secondary+
+        // versions of a query param fail to cause a route navigation.
+        // (E.g. going from two query= params to one).
+        params.ridx = '' + this.routeIndex++;
+
+        this.router.navigate(
+          ['/staff/catalog/browse'], {queryParams: params});
+    }
 }
 
 
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.html
new file mode 100644 (file)
index 0000000..1ef096c
--- /dev/null
@@ -0,0 +1,293 @@
+
+<h3 i18n>Place Hold 
+  <small *ngIf="user"> 
+   ({{user.family_name()}}, {{user.first_given_name()}})
+  </small>
+</h3>
+
+<form class="form form-validated common-form" 
+  autocomplete="off" (keydown.enter)="$event.preventDefault()">
+  <div class="row">
+    <div class="col-lg-6 common-form striped-odd">
+      <div class="row mt-2">
+        <div class="col-lg-6">
+          <div class="form-check">
+            <input class="form-check-input" type="radio" 
+              (change)="holdForChanged()"
+              name="holdFor" value="patron" [(ngModel)]="holdFor"/>
+            <label class="form-check-label" i18n>
+              Place hold for patron by barcode:
+            </label>
+          </div>
+        </div>
+        <div class="col-lg-6">
+          <div class="input-group">
+            <input type='text' class="form-control" name="userBarcode"
+              [disabled]="holdFor!='patron'" id='patron-barcode' 
+              (keyup.enter)="userBarcodeChanged()"
+              [(ngModel)]="userBarcode" (change)="userBarcodeChanged()"/>
+            <div class="input-group-append">
+              <button class="btn btn-outline-dark" 
+                [disabled]="true" i18n>Search</button>
+            </div>
+          </div>
+        </div>
+      </div>
+      <div class="row mt-2">
+        <div class="col-lg-6">
+          <div class="form-check">
+            <input class="form-check-input" type="radio" 
+              (change)="holdForChanged()"
+              name="holdFor" value="staff" [(ngModel)]="holdFor"/>
+            <label class="form-check-label" i18n>
+              Place hold for this staff account:
+            </label>
+          </div>
+        </div>
+        <div class="col-lg-6 font-weight-bold">{{requestor.usrname()}}</div>
+      </div>
+      <div class="row mt-2">
+        <div class="col-lg-6">
+          <label i18n>Pickup Location: </label>
+        </div>
+        <div class="col-lg-6">
+          <eg-org-select [applyOrgId]="pickupLib"></eg-org-select>
+        </div>
+      </div>
+      <div class="row mt-2">
+        <div class="col-lg-6">
+          <div class="form-check">
+            <input class="form-check-input" type="checkbox" 
+              name="suspend" [(ngModel)]="suspend"/>
+            <label class="form-check-label" i18n>Suspend Hold</label>
+          </div>
+        </div>
+        <div class="col-lg-6">
+          <eg-date-select (onChangeAsISO)="activeDateSelected($event)"
+            [disabled]="!suspend">
+          </eg-date-select>
+        </div>
+      </div>
+    </div><!-- left column -->
+    <div class="col-lg-6">
+      <div class="card">
+        <div class="card-header">
+          <h4 i18n>Notifications</h4>
+        </div>
+        <ul class="list-group list-group-flush">
+          <li class="list-group-item d-flex">
+            <div class="flex-1">
+              <div class="form-check">
+                <input class="form-check-input" type="checkbox" name="notifyEmail"
+                  [disabled]="!user || !user.email()" [(ngModel)]="notifyEmail"/>
+                <label class="form-check-label" i18n>Notify by Email</label>
+              </div>
+            </div>
+            <div class="flex-1">
+              <div class="input-group">
+                <div class="input-group-prepend">
+                  <span class="input-group-text" i18n>Email Address</span>
+                </div>
+                <input type="text" class="form-control" name="userEmail"
+                  [disabled]="true" value="{{user ? user.email() : ''}}"/>
+              </div>
+            </div>
+          </li>
+          <li class="list-group-item d-flex">
+            <div class="flex-1">
+              <div class="form-check">
+                <input class="form-check-input" type="checkbox" 
+                  name="notifyPhone" [(ngModel)]="notifyPhone"/>
+                <label class="form-check-label" i18n>Notify by Phone</label>
+              </div>
+            </div>
+            <div class="flex-1">
+              <div class="input-group">
+                <div class="input-group-prepend">
+                  <span class="input-group-text" i18n>Phone Number</span>
+                </div>
+                <input type="text" class="form-control" [disabled]="!notifyPhone"
+                  name="phoneValue" [(ngModel)]="phoneValue"/>
+              </div>
+            </div>
+          </li>
+          <li *ngIf="smsEnabled" class="list-group-item d-flex">
+            <div class="flex-1">
+              <div class="form-check">
+                <input class="form-check-input" type="checkbox" 
+                  name="notifySms" [(ngModel)]="notifySms"/>
+                <label class="form-check-label" i18n>Notify by SMS</label>
+              </div>
+            </div>
+            <div class="flex-1">
+              <div class="input-group">
+                <div class="input-group-prepend">
+                  <span class="input-group-text" i18n>SMS Number</span>
+                </div>
+                <input type="text" class="form-control" [disabled]="!notifySms"
+                  name="smsValue" [(ngModel)]="smsValue"/>
+              </div>
+            </div>
+          </li>
+          <li *ngIf="smsEnabled" class="list-group-item d-flex">
+            <div class="flex-1">
+              <label i18n>SMS Carrier</label>
+            </div>
+            <div class="flex-1">
+              <eg-combobox
+                placeholder="SMS Carriers" i18n-placeholder
+                [entries]="smsCarriers">
+              </eg-combobox>
+            </div>
+          </li>
+        </ul><!-- col -->
+      </div><!-- row -->
+    </div><!--card -->
+  </div><!-- col -->
+  <div class="row mt-2">
+    <div class="col-lg-3">
+      <button class="btn btn-success" (click)="placeHolds()" 
+        [disabled]="!user || placeHoldsClicked" i18n>Place Hold(s)</button>
+    </div>
+  </div>
+</form>
+
+<div class="row"><div class="col-lg-12"><hr/></div></div>
+
+<div class="row font-weight-bold pt-3 ml-1 mr-1">
+  <div class="col-lg-12" i18n>Placing 
+    <ng-container *ngIf="holdType == 'M'">METARECORD</ng-container> 
+    <ng-container *ngIf="holdType == 'T'">TITLE</ng-container> 
+    <ng-container *ngIf="holdType == 'V'">VOLUME</ng-container> 
+    <ng-container *ngIf="holdType == 'F'">FORCE COPY</ng-container> 
+    <ng-container *ngIf="holdType == 'C'">COPY</ng-container> 
+    <ng-container *ngIf="holdType == 'R'">RECALL</ng-container> 
+    <ng-container *ngIf="holdType == 'I'">ISSUANCE</ng-container> 
+    <ng-container *ngIf="holdType == 'P'">PARTS</ng-container> 
+    hold on record(s)</div>
+</div>
+
+<ng-template #anyValue>
+  <span class="font-italic" i18n>ANY</span>
+</ng-template>
+
+<!--
+    TODO: add a section per hold context for metarecord holds
+    listing the possible formats and languages.
+
+    TODO: add a secion per hold context for T holds providing a 
+    link to the metarecord hold equivalent (AKA "Advanced Hold 
+    Options") for each record that has selectable filters (and
+    only when metarecord holds are enabled).
+-->
+
+<div class="hold-records-list common-form striped-even">
+
+  <div class="row mt-2 ml-1 mr-1 font-weight-bold">
+    <div class="col-lg-1" i18n>Format</div>
+    <div class="col-lg-3" i18n>Title</div>
+    <div class="col-lg-2" i18n>Author</div>
+    <div class="col-lg-2" i18n>Call Number</div>
+    <div class="col-lg-1" i18n>Barcode</div>
+    <div class="col-lg-2" i18n>Holds Status</div>
+    <div class="col-lg-1" i18n>Override</div>
+  </div>
+  <div class="row mt-1 ml-1 mr-1" *ngFor="let ctx of holdContexts">
+    <div class="col-lg-12" *ngIf="ctx.holdMeta">
+      <div class="row">
+        <div class="col-lg-1">
+          <ng-container 
+            *ngFor="let code of ctx.holdMeta.bibSummary.attributes.icon_format">
+            <img class="pr-1" 
+              alt="{{iconFormatLabel(code)}}"
+              title="{{iconFormatLabel(code)}}"
+              src="/images/format_icons/icon_format/{{code}}.png"/>
+          </ng-container>
+        </div>
+        <!-- TODO: link for a metarecord should 
+            jump to constituent bib list search page? -->
+        <div class="col-lg-3">
+          <a routerLink="/staff/catalog/record/{{ctx.holdMeta.bibId}}">
+            {{ctx.holdMeta.bibSummary.display.title}}
+          </a>
+        </div>
+        <div class="col-lg-2">{{ctx.holdMeta.bibSummary.display.author}}</div>
+        <div class="col-lg-2">
+          <ng-container *ngIf="ctx.holdMeta.volume; else anyValue">
+            {{ctx.holdMeta.volume.label()}}
+          </ng-container>
+        </div>
+        <div class="col-lg-1">
+          <ng-container *ngIf="ctx.holdMeta.copy; else anyValue">
+            {{ctx.holdMeta.copy.barcode()}}
+          </ng-container>
+        </div>
+        <div class="col-lg-2">
+          <ng-container *ngIf="!ctx.lastRequest && !ctx.processing">
+            <div class="alert alert-info" i18n>Hold Pending</div>
+          </ng-container>
+          <ng-container *ngIf="ctx.processing">
+            <div class="alert alert-primary" i18n>Hold Processing...</div>
+          </ng-container>
+          <ng-container *ngIf="ctx.lastRequest">
+            <ng-container *ngIf="ctx.lastRequest.result.success">
+              <div class="alert alert-success" i18n>Hold Succeeded</div>
+            </ng-container>
+            <ng-container *ngIf="!ctx.lastRequest.result.success">
+              <div class="alert alert-danger">
+                {{ctx.lastRequest.result.evt.textcode}}
+              </div>
+            </ng-container>
+          </ng-container>
+        </div>
+        <div class="col-lg-1">
+          <ng-container *ngIf="canOverride(ctx)">
+            <button class="btn btn-info" (click)="override(ctx)">Override</button>
+          </ng-container>
+        </div>
+      </div>
+      <!-- note: using inline style since class-level styling for rows
+          is superseded by the striped-even styling of the container -->
+      <div class="row" *ngIf="hasMetaFilters(ctx)" 
+        style="background-color:inherit; border:none">
+        <div class="col-lg-1"><label i18n>Formats: </label></div>
+        <div class="col-lg-11 d-flex">
+          <ng-container 
+            *ngFor="let ccvm of ctx.holdMeta.metarecord_filters.formats">
+            <div class="form-check ml-3">
+              <input class="form-check-input" type="checkbox" 
+                [disabled]="ctx.holdMeta.metarecord_filters.formats.length == 1"
+                [(ngModel)]="ctx.selectedFormats.formats[ccvm.code()]"/>
+              <img class="ml-1" 
+                alt="{{iconFormatLabel(ccvm.code())}}"
+                title="{{iconFormatLabel(ccvm.code())}}"
+                src="/images/format_icons/icon_format/{{ccvm.code()}}.png"/>
+              <label class="form-check-label ml-1">
+                {{ccvm.search_label() || ccvm.value()}}
+              </label>
+            </div>
+          </ng-container>
+        </div>
+      </div>
+      <div class="row" *ngIf="hasMetaFilters(ctx)" 
+        style="background-color:inherit; border:none">
+        <div class="col-lg-1"><label i18n>Languages: </label></div>
+        <div class="col-lg-11 d-flex">
+          <ng-container 
+            *ngFor="let ccvm of ctx.holdMeta.metarecord_filters.langs">
+            <div class="form-check ml-3">
+              <input class="form-check-input" type="checkbox" 
+                [disabled]="ctx.holdMeta.metarecord_filters.langs.length == 1"
+                [(ngModel)]="ctx.selectedFormats.langs[ccvm.code()]"/>
+              <label class="form-check-label ml-1">
+                {{ccvm.search_label() || ccvm.value()}}
+              </label>
+            </div>
+          </ng-container>
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.ts
new file mode 100644 (file)
index 0000000..a0a0dc2
--- /dev/null
@@ -0,0 +1,401 @@
+import {Component, OnInit, Input, ViewChild, Renderer2} from '@angular/core';
+import {Observable} from 'rxjs/Observable';
+import {Router, ActivatedRoute, ParamMap} from '@angular/router';
+import {tap} from 'rxjs/operators/tap';
+import {EventService} from '@eg/core/event.service';
+import {NetService} from '@eg/core/net.service';
+import {AuthService} from '@eg/core/auth.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {PermService} from '@eg/core/perm.service';
+import {IdlObject} from '@eg/core/idl.service';
+import {OrgService} from '@eg/core/org.service';
+import {BibRecordService, BibRecordSummary} from '@eg/share/catalog/bib-record.service';
+import {CatalogSearchContext, CatalogSearchState} from '@eg/share/catalog/search-context';
+import {CatalogService} from '@eg/share/catalog/catalog.service';
+import {StaffCatalogService} from '../catalog.service';
+import {HoldService, HoldRequest, HoldRequestTarget} 
+    from '@eg/staff/share/hold.service';
+import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
+
+class HoldContext {
+    holdMeta: HoldRequestTarget;
+    holdTarget: number;
+    lastRequest: HoldRequest;
+    canOverride?: boolean;
+    processing: boolean;
+    selectedFormats: any;
+
+    constructor(target: number) {
+        this.holdTarget = target;
+        this.processing = false;
+        this.selectedFormats = {
+           // code => selected-boolean
+           formats: {},
+           langs: {}
+        }
+    }
+}
+
+@Component({
+  templateUrl: 'hold.component.html'
+})
+export class HoldComponent implements OnInit {
+    
+    holdType: string;
+    holdTargets: number[];
+    user: IdlObject; //
+    userBarcode: string;
+    requestor: IdlObject;
+    holdFor: string;
+    pickupLib: number;
+    notifyEmail: boolean;
+    notifyPhone: boolean;
+    phoneValue: string;
+    notifySms: boolean;
+    smsValue: string;
+    smsCarrier: string;
+    suspend: boolean;
+    activeDate: string;
+
+    holdContexts: HoldContext[];
+    recordSummaries: BibRecordSummary[];
+
+    currentUserBarcode: string;
+    smsCarriers: ComboboxEntry[];
+
+    smsEnabled: boolean;
+    placeHoldsClicked: boolean;
+
+    constructor(
+        private router: Router,
+        private route: ActivatedRoute,
+        private renderer: Renderer2,
+        private evt: EventService,
+        private net: NetService,
+        private org: OrgService,
+        private auth: AuthService,
+        private pcrud: PcrudService,
+        private bib: BibRecordService,
+        private cat: CatalogService,
+        private staffCat: StaffCatalogService,
+        private holds: HoldService,
+        private perm: PermService
+    ) {
+        this.holdContexts = [];
+        this.smsCarriers = [];
+    }
+
+    ngOnInit() {
+
+        this.holdType = this.route.snapshot.params['type'];
+        this.holdTargets = this.route.snapshot.queryParams['target'];
+
+        if (!Array.isArray(this.holdTargets)) {
+            this.holdTargets = [this.holdTargets];
+        }
+
+        this.holdTargets = this.holdTargets.map(t => Number(t));
+        this.holdFor = 'patron';
+        this.requestor = this.auth.user();
+        this.pickupLib = this.auth.user().ws_ou();
+
+        this.holdContexts = this.holdTargets.map(target => {
+            const ctx = new HoldContext(target);
+            return ctx;
+        });
+
+        this.getTargetMeta();
+
+        this.org.settings('sms.enable').then(sets => {
+            this.smsEnabled = sets['sms.enable']
+            if (!this.smsEnabled) { return; }
+
+            this.pcrud.search('csc', {active: 't'}, {order_by: {csc: 'name'}})
+            .subscribe(carrier => {
+                this.smsCarriers.push({
+                    id: carrier.id(), 
+                    label: carrier.name()
+                })
+            });
+        });
+
+        setTimeout(() => // Focus barcode input
+            this.renderer.selectRootElement('#patron-barcode').focus());
+    }
+
+    // Load the bib, call number, copy, etc. data associated with each target.
+    getTargetMeta() {
+        this.holds.getHoldTargetMeta(this.holdType, this.holdTargets)
+        .subscribe(meta => {
+            this.holdContexts.filter(ctx => ctx.holdTarget === meta.target)
+            .forEach(ctx => {
+                ctx.holdMeta = meta;
+                this.mrFiltersToSelectors(ctx);
+            });
+        });
+    }
+
+    // By default, all metarecord filters options are enabled.
+    mrFiltersToSelectors(ctx: HoldContext) {
+        if (this.holdType !== 'M') { return; }
+
+        const meta = ctx.holdMeta;
+        if (meta.metarecord_filters) {
+            if (meta.metarecord_filters.formats) {
+                meta.metarecord_filters.formats.forEach(
+                    ccvm => ctx.selectedFormats.formats[ccvm.code()] = true);
+            }
+            if (meta.metarecord_filters.langs) {
+                meta.metarecord_filters.langs.forEach(
+                    ccvm => ctx.selectedFormats.langs[ccvm.code()] = true);
+            }
+        }
+    }
+
+    // Map the selected metarecord filters optoins to a JSON-encoded
+    // list of attr filters as required by the API.
+    // Compiles a blob of 
+    // {target: JSON({"0": [{_attr: ctype, _val: code}, ...], "1": [...]})}
+    // TODO: this should live in the hold service, not in the UI code.
+    mrSelectorsToFilters(ctx: HoldContext): {[target: number]: string} {
+
+        const meta = ctx.holdMeta;
+        const slf = ctx.selectedFormats;
+        const result: any = {};
+
+        const formats = Object.keys(slf.formats)
+            .filter(code => Boolean(slf.formats[code])); // user-selected
+
+        const langs = Object.keys(slf.langs)
+            .filter(code => Boolean(slf.langs[code])); // user-selected
+
+        const compiled: any = {};
+
+        if (formats.length > 0) {
+            compiled['0'] = [];
+            formats.forEach(code => {
+                const ccvm = meta.metarecord_filters.formats.filter(
+                    format => format.code() === code)[0];
+                compiled['0'].push({
+                    _attr: ccvm.ctype(),
+                    _val: ccvm.code()
+                });
+            });
+        }
+
+        if (langs.length > 0) {
+            compiled['1'] = [];
+            langs.forEach(code => {
+                const ccvm = meta.metarecord_filters.langs.filter(
+                    format => format.code() === code)[0];
+                compiled['1'].push({
+                    _attr: ccvm.ctype(),
+                    _val: ccvm.code()
+                });
+            });
+        }
+
+        if (Object.keys(compiled).length > 0) {
+            const result = {};
+            result[ctx.holdTarget] = JSON.stringify(compiled);
+            return result;
+        }
+
+        return null;
+    }
+
+    holdForChanged() {
+        this.user = null;
+
+        if (this.holdFor === 'patron') {
+            if (this.userBarcode) {
+                this.userBarcodeChanged();
+            }
+        } else {
+            // To bypass the dupe check.
+            this.currentUserBarcode = '_' + this.requestor.id();
+            this.getUser(this.requestor.id());
+        }
+    }
+
+    activeDateSelected(dateStr: string) {
+        this.activeDate = dateStr;
+    }
+
+    userBarcodeChanged() {
+
+        // Avoid simultaneous or duplicate lookups
+        if (this.userBarcode === this.currentUserBarcode) { 
+            return; 
+        }
+
+        this.resetForm();
+
+        if (!this.userBarcode) { 
+            this.user = null;
+            return; 
+        }
+
+        this.user = null;
+        this.currentUserBarcode = this.userBarcode;
+
+        this.net.request(
+            'open-ils.actor',
+            'open-ils.actor.get_barcodes',
+            this.auth.token(), this.auth.user().ws_ou(), 
+            'actor', this.userBarcode
+        ).subscribe(barcodes => {
+
+            // Use the first successful barcode response.
+            // TODO: What happens when there are multiple responses?
+            // Use for-loop for early exit since we have async
+            // action within the loop.
+            for (let i = 0; i < barcodes.length; i++) {
+                const bc = barcodes[i];
+                if (!this.evt.parse(bc)) {
+                    this.getUser(bc.id);
+                    break;
+                }
+            }
+        });
+    }
+
+    resetForm() {
+        this.notifyEmail = true;
+        this.notifyPhone = true;
+        this.phoneValue = '';
+        this.pickupLib = this.requestor.ws_ou();
+    }
+
+    getUser(id: number) {
+        this.pcrud.retrieve('au', id, {flesh: 1, flesh_fields: {au: ['settings']}})
+        .subscribe(user => {
+            this.user = user;
+            this.applyUserSettings();
+        });
+    }
+
+    applyUserSettings() {
+        if (!this.user || !this.user.settings()) { return; }
+
+        // Start with defaults.
+        this.phoneValue = this.user.day_phone() || this.user.evening_phone();
+
+        // Default to work org if placing holds for staff.
+        if (this.user.id() !== this.requestor.id()) {
+            this.pickupLib = this.user.home_ou();
+        }
+
+        this.user.settings().forEach(setting => {
+            const name = setting.name();
+            const value = setting.value();
+
+            if (value === '' || value === null) { return; }
+
+            switch(name) {
+                case 'opac.hold_notify':
+                    this.notifyPhone = Boolean(value.match(/phone/));
+                    this.notifyEmail = Boolean(value.match(/email/));
+                    this.notifySms = Boolean(value.match(/sms/));
+                    break;
+
+                case 'opac.default_pickup_location':
+                    this.pickupLib = value; 
+                    break;
+            }
+        });
+
+        if (!this.user.email()) {
+            this.notifyEmail = false;
+        }
+
+        if (!this.phoneValue) {
+            this.notifyPhone = false;
+        }
+    }
+
+    // Attempt hold placement on all targets
+    placeHolds(idx?: number) {
+        if (!idx) { idx = 0; }
+        if (!this.holdTargets[idx]) { return; }
+        this.placeHoldsClicked = true;
+
+        const target = this.holdTargets[idx];
+        const ctx = this.holdContexts.filter(
+            ctx => ctx.holdTarget === target)[0];
+
+        this.placeOneHold(ctx).then(() => this.placeHolds(idx + 1));
+    }
+
+    placeOneHold(ctx: HoldContext, override?: boolean): Promise<any> {
+
+        ctx.processing = true;
+        const selectedFormats = this.mrSelectorsToFilters(ctx);
+
+        return this.holds.placeHold({
+            holdTarget: ctx.holdTarget,
+            holdType: this.holdType,
+            recipient: this.user.id(),
+            requestor: this.requestor.id(),
+            pickupLib: this.pickupLib,
+            override: override,
+            notifyEmail: this.notifyEmail, // bool
+            notifyPhone: this.notifyPhone ? this.phoneValue : null,
+            notifySms: this.notifySms ? this.smsValue : null,
+            smsCarrier: this.notifySms ? this.smsCarrier : null,
+            thawDate: this.suspend ? this.activeDate : null,
+            frozen: this.suspend,
+            holdableFormats: selectedFormats
+
+        }).toPromise().then(
+            request => {
+                console.log('hold returned: ', request);
+                ctx.lastRequest = request;
+                ctx.processing = false;
+    
+                // If this request failed and was not already an override,
+                // see of this user has permission to override.
+                if (!request.override && 
+                    !request.result.success && request.result.evt) {
+    
+                    const txtcode = request.result.evt.textcode;
+                    const perm = txtcode + '.override';
+    
+                    return this.perm.hasWorkPermHere(perm).then(
+                        permResult => ctx.canOverride = permResult[perm]);
+                }
+            },
+            error => {
+                ctx.processing = false;
+                console.error(error);
+            }
+        );
+    }
+
+    override(ctx: HoldContext) {
+        this.placeOneHold(ctx, true);
+    }
+
+    canOverride(ctx: HoldContext): boolean {
+        return ctx.lastRequest && 
+                !ctx.lastRequest.result.success && ctx.canOverride;
+    }
+
+    iconFormatLabel(code: string): string {
+        return this.cat.iconFormatLabel(code);
+    }
+
+    // TODO: for now, only show meta filters for meta holds.
+    // Add an "advanced holds" option to display these for T hold.
+    hasMetaFilters(ctx: HoldContext): boolean {
+        return (
+            this.holdType === 'M' && // TODO
+            ctx.holdMeta.metarecord_filters && (
+                ctx.holdMeta.metarecord_filters.langs.length > 1 ||
+                ctx.holdMeta.metarecord_filters.formats.length > 1
+            )
+        );
+    }
+}
+
+
index 6fd9454..1a76b28 100644 (file)
 
 <div class="row ml-0 mr-0">
 
+  <a target="_blank" href="/eg/opac/record/{{recId}}">
+    <button class="btn btn-info ml-1" i18n>View in Catalog</button>
+  </a>
+
   <button class="btn btn-info ml-1" (click)="addVolumes()" i18n>
-    Add Volumes
+    Add Holdings
   </button>
 
   <div ngbDropdown placement="bottom-right" class="ml-1">
index e7d8249..e60fb24 100644 (file)
@@ -20,6 +20,7 @@
 
 <div class='eg-copies w-100 mt-3'>
   <eg-grid #copyGrid [dataSource]="gridDataSource" 
+    [disableSelect]="true"
     [sortable]="false" persistKey="catalog.record.copies">
     <eg-grid-column i18n-label label="Copy ID" path="id" 
       [hidden]="true" [index]="true">
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/part-merge-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/record/part-merge-dialog.component.html
new file mode 100644 (file)
index 0000000..ef702eb
--- /dev/null
@@ -0,0 +1,28 @@
+<ng-template #dialogContent>
+  <div class="modal-header bg-info">
+    <h4 class="modal-title" i18n>Merge Monograph Parts</h4>
+    <button type="button" class="close" 
+      i18n-aria-label aria-label="Close" 
+      (click)="dismiss('cross_click')">
+      <span aria-hidden="true">&times;</span>
+    </button>
+  </div>
+  <div class="modal-body">
+    <h5 i18n>Select a Lead Part</h5>
+    <div class="row" *ngFor="let part of parts">
+      <div class="col-lg-10 offset-lg-1">
+        <div class="form-check">
+          <input class="form-check-input" type="radio" name="lead"
+            value="{{part.id()}}" [(ngModel)]="leadPart">
+          <label class="form-check-label">{{part.label()}}</label>
+        </div>
+      </div>
+    </div>
+  </div>
+  <div class="modal-footer">
+    <button type="button" class="btn btn-success" 
+      (click)="mergeParts()" i18n>Merge</button>
+    <button type="button" class="btn btn-warning" 
+      (click)="dismiss('canceled')" i18n>Cancel</button>
+  </div>
+</ng-template>
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/part-merge-dialog.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/record/part-merge-dialog.component.ts
new file mode 100644 (file)
index 0000000..27c4d30
--- /dev/null
@@ -0,0 +1,70 @@
+import {Component, Input, ViewChild, TemplateRef} from '@angular/core';
+import {DialogComponent} from '@eg/share/dialog/dialog.component';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {NgbModal, NgbModalOptions} from '@ng-bootstrap/ng-bootstrap';
+
+@Component({
+  selector: 'eg-catalog-part-merge-dialog',
+  templateUrl: './part-merge-dialog.component.html'
+})
+
+/**
+ * Ask the user which part is the lead part then merge others parts in.
+ */
+export class PartMergeDialogComponent extends DialogComponent {
+
+    // What parts are we merging
+    parts: IdlObject[];
+    copyPartMaps: IdlObject[];
+    leadPart: number;
+
+    constructor(
+        private idl: IdlService,
+        private pcrud: PcrudService,
+        private modal: NgbModal) {
+        super(modal);
+    }
+
+    mergeParts() {
+        console.log('Merging parts into lead part ', this.leadPart);
+
+        if (!this.leadPart) { return; }
+
+        this.leadPart = Number(this.leadPart);
+
+        // 1. Migrate copy maps to the lead part.
+        const partIds = this.parts
+            .filter(p => Number(p.id()) !== this.leadPart)
+               .map(p => Number(p.id()));
+
+        const maps = [];
+        this.pcrud.search('acpm', {part: partIds})
+        .subscribe(
+            map => {
+                map.part(this.leadPart);
+                map.ischanged(true);
+                maps.push(map);
+            },
+            err => {},
+            ()  => {
+                // 2. Delete the now-empty subordinate parts.  Note the
+                // delete must come after the part map changes are committed.
+                if (maps.length > 0) {
+                    this.pcrud.autoApply(maps)
+                        .toPromise().then(() => this.deleteParts());
+                } else {
+                    this.deleteParts();
+                }
+            }
+        );
+    }
+
+    deleteParts() {
+        const parts = this.parts.filter(p => Number(p.id()) !== this.leadPart);
+        parts.forEach(p => p.isdeleted(true));
+        this.pcrud.autoApply(parts).toPromise().then(res => this.close(res));
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/parts.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/record/parts.component.html
new file mode 100644 (file)
index 0000000..f5693c1
--- /dev/null
@@ -0,0 +1,22 @@
+<eg-catalog-part-merge-dialog #mergeDialog>
+</eg-catalog-part-merge-dialog>
+
+<div class="mt-3">
+
+  <eg-grid #partsGrid idlClass="bmp" [dataSource]="gridDataSource"
+      [sortable]="true" persistKey="catalog.record.parts"
+      showFields="id,label" class="mt-3">
+    <eg-grid-toolbar-button [disabled]="!permissions.CREATE_MONOGRAPH_PART"
+      label="New Monograph Part" i18n-label [action]="createNew">
+    </eg-grid-toolbar-button>
+    <eg-grid-toolbar-action label="Merge Selected" i18n-label [action]="mergeSelected">
+    </eg-grid-toolbar-action>
+    <eg-grid-toolbar-action label="Delete Selected" i18n-label [action]="deleteSelected">
+    </eg-grid-toolbar-action>
+  </eg-grid>
+  
+  <eg-fm-record-editor #editDialog idlClass="bmp"     
+    hiddenFields="record,label_sortkey,deleted">
+  </eg-fm-record-editor>
+
+</div>
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/parts.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/record/parts.component.ts
new file mode 100644 (file)
index 0000000..e74fc12
--- /dev/null
@@ -0,0 +1,123 @@
+import {Component, OnInit, Input, ViewChild} from '@angular/core';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {Pager} from '@eg/share/util/pager';
+import {OrgService} from '@eg/core/org.service';
+import {PermService} from '@eg/core/perm.service';
+import {GridDataSource} from '@eg/share/grid/grid';
+import {GridComponent} from '@eg/share/grid/grid.component';
+import {FmRecordEditorComponent} from '@eg/share/fm-editor/fm-editor.component';
+import {PartMergeDialogComponent} from './part-merge-dialog.component';
+
+@Component({
+  selector: 'eg-catalog-record-parts',
+  templateUrl: 'parts.component.html'
+})
+export class PartsComponent implements OnInit {
+
+    recId: number;
+    gridDataSource: GridDataSource;
+    initDone: boolean;
+    @ViewChild('partsGrid') partsGrid: GridComponent;
+    @ViewChild('editDialog') editDialog: FmRecordEditorComponent;
+    @ViewChild('mergeDialog') mergeDialog: PartMergeDialogComponent;
+
+    canCreate: boolean;
+    canDelete: boolean;
+    createNew: () => void;
+    deleteSelected: (rows: IdlObject[]) => void;
+    mergeSelected: (rows: IdlObject[]) => void;
+    permissions: {[name: string]: boolean};
+
+    @Input() set recordId(id: number) {
+        this.recId = id;
+        // Only force new data collection when recordId()
+        // is invoked after ngInit() has already run.
+        if (this.initDone) {
+            this.partsGrid.reload();
+        }
+    }
+
+    constructor(
+        private idl: IdlService,
+        private org: OrgService,
+        private pcrud: PcrudService,
+        private perm: PermService
+    ) {
+        this.permissions = {};
+        this.gridDataSource = new GridDataSource();
+    }
+
+    ngOnInit() {
+        this.initDone = true;
+
+        // Load edit perms
+        this.perm.hasWorkPermHere([
+            'CREATE_MONOGRAPH_PART',
+            'UPDATE_MONOGRAPH_PART',
+            'DELETE_MONOGRAPH_PART'
+        ]).then(perms => this.permissions = perms);
+
+        this.gridDataSource.getRows = (pager: Pager, sort: any[]) => {
+            const orderBy: any = {};
+
+            if (sort.length) { // Sort provided by grid.
+                orderBy.bmp = sort[0].name + ' ' + sort[0].dir;
+            } else {
+                orderBy.bmp = 'label';
+            }
+
+            const searchOps = {
+                offset: pager.offset,
+                limit: pager.limit,
+                order_by: orderBy
+            };
+
+            return this.pcrud.search('bmp', 
+                {record: this.recId, deleted: 'f'}, searchOps);
+        };
+
+        this.partsGrid.onRowActivate.subscribe(
+            (part: IdlObject) => {
+                this.editDialog.mode = 'update';
+                this.editDialog.recId = part.id();
+                this.editDialog.open().then(
+                    ok => this.partsGrid.reload(),
+                    err => {}
+                );
+            }
+        );
+
+        this.createNew = () => {
+
+            const part = this.idl.create('bmp');
+            part.record(this.recId);
+            this.editDialog.record = part;
+
+            this.editDialog.mode = 'create';
+            this.editDialog.open().then(
+                ok => this.partsGrid.reload(),
+                err => {}
+            );
+        };
+
+        this.deleteSelected = (parts: IdlObject[]) => {
+            parts.forEach(part => part.isdeleted(true));
+            this.pcrud.autoApply(parts).subscribe(
+                val => console.debug('deleted: ' + val),
+                err => {},
+                ()  => this.partsGrid.reload()
+            );
+        };
+
+        this.mergeSelected = (parts: IdlObject[]) => {
+            if (parts.length < 2) { return; }
+            this.mergeDialog.parts = parts;
+            this.mergeDialog.open().then(
+                ok => this.partsGrid.reload(),
+                err => console.debug('Dialog dismissed')
+            );
+        };
+    }
+}
+
index 4c74316..d138523 100644 (file)
     </eg-bib-summary>
   </div>
   <div id='staff-catalog-bib-tabs-container' class='mt-3'>
+    <div class="row">
+      <div class="col-lg-12 text-right">
+        <button class="btn btn-secondary btn-sm" 
+            [disabled]="recordTab == defaultTab"
+            (click)="setDefaultTab()" i18n>Set Default View</button>
+      </div>
+    </div>
     <ngb-tabset #recordTabs [activeId]="recordTab" (tabChange)="onTabChange($event)">
-      <ngb-tab title="Copy Table" i18n-title id="copy_table">
+      <ngb-tab title="Copy Table" i18n-title id="catalog">
         <ng-template ngbTabContent>
           <eg-catalog-copies [recordId]="recordId"></eg-catalog-copies>
         </ng-template>
       </ngb-tab>
-      <ngb-tab title="MARC View" i18n-title id="marc_view">
+      <!-- NOTE some tabs send the user over to the AngJS app -->
+      <ngb-tab title="MARC Edit" i18n-title id="marc_edit">
+      </ngb-tab>
+      <ngb-tab title="MARC View" i18n-title id="marc_html">
         <ng-template ngbTabContent>
           <eg-marc-html [recordId]="recordId" recordType="bib"></eg-marc-html>
         </ng-template>
       </ngb-tab>
+      <ngb-tab title="View Holds" i18n-title id="holds">
+      </ngb-tab>
+      <ngb-tab title="Monograph Parts" i18n-title id="monoparts">
+        <ng-template ngbTabContent>
+          <eg-catalog-record-parts [recordId]="recordId">
+          </eg-catalog-record-parts>
+        </ng-template>
+      </ngb-tab>
+      <ngb-tab title="Holdings View" i18n-title id="holdings">
+      </ngb-tab>
+      <ngb-tab title="Conjoined Items" i18n-title id="conjoined">
+      </ngb-tab>
     </ngb-tabset>
   </div>
 </div>
index b217e5c..0414a07 100644 (file)
@@ -8,6 +8,14 @@ import {CatalogService} from '@eg/share/catalog/catalog.service';
 import {BibRecordService, BibRecordSummary} from '@eg/share/catalog/bib-record.service';
 import {StaffCatalogService} from '../catalog.service';
 import {BibSummaryComponent} from '@eg/staff/share/bib-summary/bib-summary.component';
+import {StoreService} from '@eg/core/store.service';
+
+const ANGJS_TABS: any = {
+    marc_edit: true,
+    holds: true,
+    holdings: true,
+    conjoined: true
+};
 
 @Component({
   selector: 'eg-catalog-record',
@@ -20,6 +28,7 @@ export class RecordComponent implements OnInit {
     summary: BibRecordSummary;
     searchContext: CatalogSearchContext;
     @ViewChild('recordTabs') recordTabs: NgbTabset;
+    defaultTab: string; // eg.cat.default_record_tab
 
     constructor(
         private router: Router,
@@ -27,21 +36,49 @@ export class RecordComponent implements OnInit {
         private pcrud: PcrudService,
         private bib: BibRecordService,
         private cat: CatalogService,
-        private staffCat: StaffCatalogService
+        private staffCat: StaffCatalogService,
+        private store: StoreService
     ) {}
 
     ngOnInit() {
         this.searchContext = this.staffCat.searchContext;
 
+        this.defaultTab = 
+            this.store.getLocalItem('eg.cat.default_record_tab')
+            || 'catalog';
+
+        // TODO: Implement default tab handling for tabs that require
+        // and AngJS redirect.
+
         // Watch for URL record ID changes
+        // This includes the initial route.
+        // When applying the default configured tab, no navigation occurs
+        // to apply the tab name to the URL, it displays as the default.
+        // This is done so no intermediate redirect is required, which
+        // messes with browser back/forward navigation.
         this.route.paramMap.subscribe((params: ParamMap) => {
-            this.recordTab = params.get('tab') || 'copy_table';
+            this.recordTab = params.get('tab');
             this.recordId = +params.get('id');
             this.searchContext = this.staffCat.searchContext;
+
+            if (!this.recordTab) {
+                this.recordTab = this.defaultTab || 'catalog';
+                // On initial load, if the default tab is set to one of
+                // the AngularJS tabs, redirect the user there.
+                if (this.recordTab in ANGJS_TABS) {
+                    return this.routeToTab();
+                }
+            }
+
             this.loadRecord();
         });
     }
 
+    setDefaultTab() {
+        this.defaultTab = this.recordTab;
+        this.store.setLocalItem('eg.cat.default_record_tab', this.recordTab);
+    }
+
     // Changing a tab in the UI means changing the route.
     // Changing the route ultimately results in changing the tab.
     onTabChange(evt: NgbTabChangeEvent) {
@@ -50,11 +87,23 @@ export class RecordComponent implements OnInit {
         // prevent tab changing until after route navigation
         evt.preventDefault();
 
-        let url = '/staff/catalog/record/' + this.recordId;
-        if (this.recordTab !== 'copy_table') {
-            url += '/' + this.recordTab;
+        this.routeToTab();
+    }
+
+    routeToTab() {
+
+        // Route to the AngularJS catalog tab
+        if (this.recordTab in ANGJS_TABS) {
+            const angjsBase = '/eg/staff/cat/catalog/record';
+
+            window.location.href = 
+                `${angjsBase}/${this.recordId}/${this.recordTab}`;
+            return;
         }
 
+        const url = 
+            `/staff/catalog/record/${this.recordId}/${this.recordTab}`;
+
         // Retain search parameters
         this.router.navigate([url], {queryParamsHandling: 'merge'});
     }
index 8761c58..02b44c9 100644 (file)
@@ -37,22 +37,16 @@ export class CatalogResolver implements Resolve<Promise<any[]>> {
     }
 
     fetchSettings(): Promise<any> {
-        const promises = [];
 
-        promises.push(
-            this.store.getItem('eg.search.search_lib').then(
-                id => this.staffCat.defaultSearchOrg = this.org.get(id)
-            )
-        );
-
-        promises.push(
-            this.store.getItem('eg.search.pref_lib').then(
-                id => this.staffCat.prefOrg = this.org.get(id)
-            )
-        );
-
-        return Promise.all(promises);
+        return this.store.getItemBatch([
+            'eg.search.search_lib', 
+            'eg.search.pref_lib'
+        ]).then(settings => {
+            this.staffCat.defaultSearchOrg = 
+                this.org.get(settings['eg.search.search_lib']);
+            this.staffCat.prefOrg = 
+                this.org.get(settings['eg.search.pref_lib']);
+        })
     }
-
 }
 
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();
     }
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.css b/Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.css
new file mode 100644 (file)
index 0000000..3077d9a
--- /dev/null
@@ -0,0 +1,15 @@
+
+/**
+ * Force the jacket image column to consume a consistent amount of 
+ * horizontal space, while allowing some room for the browser to 
+ * render the correct aspect ratio.
+ */
+.record-jacket-div {
+    width: 68px;
+}
+
+.record-jacket-div img {
+    height: 100%; 
+    max-height:80px; 
+    max-width: 54px;
+}
index 54ad3db..90f066b 100644 (file)
@@ -9,44 +9,57 @@
 <div class="col-lg-12 card tight-card mb-2 bg-light">
   <div class="card-body">
     <div class="row">
-      <div class="col-lg-1">
-        <a href="javascript:void(0)" (click)="navigatToRecord(summary.id)">
-          <img style="height:80px"
-            src="/opac/extras/ac/jacket/small/r/{{summary.id}}"/>
-        </a>
-      </div>
-      <div class="col-lg-5">
-        <div class="row">
-          <div class="col-lg-12 font-weight-bold">
-            <!-- nbsp allows the column to take shape when no value exists -->
-            <span class="font-weight-light font-italic">
-              #{{index + 1 + searchContext.pager.offset}}
-            </span>
-            <a href="javascript:void(0)"
-              (click)="navigatToRecord(summary.id)">
-              {{summary.display.title || '&nbsp;'}}
-            </a>
-          </div>
+      <!-- Checkbox, jacket image, and title blob live in a flex row
+           because there's no way to give them col-lg-* columns that
+           don't waste a lot of space. -->
+      <div class="col-lg-6 d-flex">
+        <label class="checkbox">
+          <span class="font-weight-bold font-italic">
+            {{index + 1 + searchContext.pager.offset}}.
+          </span>
+          <input class="pl-1" type='checkbox' [(ngModel)]="isRecordSelected"
+            (change)="toggleBasketEntry()"/>
+        </label>
+        <!-- XXX hard-coded width so columns align vertically regardless
+             of the presence of a jacket image -->
+        <div class="pl-2 record-jacket-div" >
+          <a href="javascript:void(0)" (click)="navigateToRecord(summary)">
+            <img src="/opac/extras/ac/jacket/small/r/{{summary.id}}"/>
+          </a>
         </div>
-        <div class="row pt-2">
-          <div class="col-lg-12">
-            <!-- nbsp allows the column to take shape when no value exists -->
-            <a href="javascript:void(0)"
-              (click)="searchAuthor(summary)">
-              {{summary.display.author || '&nbsp;'}}
-            </a>
+        <div class="flex-1 pl-2">
+          <div class="row">
+            <div class="col-lg-12 font-weight-bold">
+              <!-- nbsp allows the column to take shape when no value exists -->
+              <a href="javascript:void(0)"
+                (click)="navigateToRecord(summary)">
+                {{summary.display.title || '&nbsp;'}}
+              </a>
+            </div>
           </div>
-        </div>
-        <div class="row pt-2">
-          <div class="col-lg-12">
-            <!-- only shows the first icon format -->
-            <span *ngIf="summary.attributes.icon_format && summary.attributes.icon_format[0]">
-              <img class="pr-1"
-                src="/images/format_icons/icon_format/{{summary.attributes.icon_format[0]}}.png"/>
-              <span>{{iconFormatLabel(summary.attributes.icon_format[0])}}</span>
-            </span>
-            <span class='pl-1'>{{summary.display.edition}}</span>
-            <span class='pl-1'>{{summary.display.pubdate}}</span>
+          <div class="row pt-2">
+            <div class="col-lg-12">
+              <!-- nbsp allows the column to take shape when no value exists -->
+              <a href="javascript:void(0)"
+                (click)="searchAuthor(summary)">
+                {{summary.display.author || '&nbsp;'}}
+              </a>
+            </div>
+          </div>
+          <div class="row pt-2">
+            <div class="col-lg-12">
+              <ng-container *ngIf="summary.attributes.icon_format && summary.attributes.icon_format[0]">
+                <ng-container *ngFor="let icon of summary.attributes.icon_format">
+                <span class="pr-1">
+                  <img class="pr-1"
+                    src="/images/format_icons/icon_format/{{icon}}.png"/>
+                  <span>{{iconFormatLabel(icon)}}</span>
+                </span>
+                </ng-container>
+              </ng-container>
+              <span class='pl-1'>{{summary.display.edition}}</span>
+              <span class='pl-1'>{{summary.display.pubdate}}</span>
+            </div>
           </div>
         </div>
       </div>
                   <span i18n>Place Hold</span>
                 </button>
               </span>
+              <!--
               <span class="pl-1">
                 <button 
                   (click)="addToListDialog.recordId=summary.record.id(); addToListDialog.open({size: 'lg'})"
                   <span i18n>Add to List</span>
                 </button>
               </span>
+              -->
             </div>
           </div>
         </div>
index bfcfd45..7510b3d 100644 (file)
@@ -1,4 +1,5 @@
-import {Component, OnInit, Input} from '@angular/core';
+import {Component, OnInit, OnDestroy, Input} from '@angular/core';
+import {Subscription} from 'rxjs/Subscription';
 import {Router} from '@angular/router';
 import {OrgService} from '@eg/core/org.service';
 import {NetService} from '@eg/core/net.service';
@@ -7,16 +8,20 @@ import {BibRecordService, BibRecordSummary} from '@eg/share/catalog/bib-record.s
 import {CatalogSearchContext} from '@eg/share/catalog/search-context';
 import {CatalogUrlService} from '@eg/share/catalog/catalog-url.service';
 import {StaffCatalogService} from '../catalog.service';
+import {BasketService} from '@eg/share/catalog/basket.service';
 
 @Component({
   selector: 'eg-catalog-result-record',
-  templateUrl: 'record.component.html'
+  templateUrl: 'record.component.html',
+  styleUrls: ['record.component.css']
 })
-export class ResultRecordComponent implements OnInit {
+export class ResultRecordComponent implements OnInit, OnDestroy {
 
     @Input() index: number;  // 0-index display row
     @Input() summary: BibRecordSummary;
     searchContext: CatalogSearchContext;
+    isRecordSelected: boolean;
+    basketSub: Subscription;
 
     constructor(
         private router: Router,
@@ -25,12 +30,23 @@ export class ResultRecordComponent implements OnInit {
         private bib: BibRecordService,
         private cat: CatalogService,
         private catUrl: CatalogUrlService,
-        private staffCat: StaffCatalogService
+        private staffCat: StaffCatalogService,
+        private basket: BasketService
     ) {}
 
     ngOnInit() {
         this.searchContext = this.staffCat.searchContext;
         this.summary.getHoldCount();
+        this.isRecordSelected = this.basket.hasRecordId(this.summary.id);
+
+        // Watch for basket changes caused by other components
+        this.basketSub = this.basket.onChange.subscribe(() => {
+            this.isRecordSelected = this.basket.hasRecordId(this.summary.id);
+        });
+    }
+
+    ngOnDestroy() {
+        this.basketSub.unsubscribe();
     }
 
     orgName(orgId: number): string {
@@ -38,17 +54,21 @@ export class ResultRecordComponent implements OnInit {
     }
 
     iconFormatLabel(code: string): string {
-        if (this.cat.ccvmMap) {
-            const ccvm = this.cat.ccvmMap.icon_format.filter(
-                format => format.code() === code)[0];
-            if (ccvm) {
-                return ccvm.search_label();
-            }
-        }
+        return this.cat.iconFormatLabel(code);
     }
 
     placeHold(): void {
-        alert('Placing hold on bib ' + this.summary.id);
+        let holdType = 'T';
+        let holdTarget = this.summary.id;
+
+        const ts = this.searchContext.termSearch;
+        if (ts.isMetarecordSearch()) {
+            holdType = 'M';
+            holdTarget = this.summary.metabibId;
+        }
+
+        this.router.navigate([`/staff/catalog/hold/${holdType}`], 
+            {queryParams: {target: holdTarget}});
     }
 
     addToList(): void {
@@ -57,21 +77,36 @@ export class ResultRecordComponent implements OnInit {
 
     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();
     }
 
     /**
      * Propagate the search params along when navigating to each record.
      */
-    navigatToRecord(id: number) {
+    navigateToRecord(summary: BibRecordSummary) {
         const params = this.catUrl.toUrlParams(this.searchContext);
 
+        // Jump to metarecord constituent records page when a 
+        // MR has more than 1 constituents.
+        if (summary.metabibId && summary.metabibRecords.length > 1) {
+            this.searchContext.termSearch.fromMetarecord = summary.metabibId;
+            this.staffCat.search();
+            return;
+        }
+
         this.router.navigate(
-          ['/staff/catalog/record/' + id], {queryParams: params});
+            ['/staff/catalog/record/' + summary.id], {queryParams: params});
     }
 
+    toggleBasketEntry() {
+        if (this.isRecordSelected) {
+            return this.basket.addRecordIds([this.summary.id]);
+        } else {
+            return this.basket.removeRecordIds([this.summary.id]);
+        }
+    }
 }
 
 
index ee9ca8d..902e50b 100644 (file)
@@ -1,30 +1,75 @@
 
-<div id="staff-catalog-results-container" *ngIf="searchIsDone()">
+<!-- search results progress bar -->
+<div class="row" *ngIf="searchIsActive()">
+  <div class="col-lg-6 offset-lg-3 pt-3">
+    <div class="progress">
+      <div class="progress-bar progress-bar-striped active w-100"
+        role="progressbar" aria-valuenow="100" 
+        aria-valuemin="0" aria-valuemax="100">
+        <span class="sr-only" i18n>Searching..</span>
+      </div>
+    </div>
+  </div>
+</div>
+
+<!-- no items found -->
+<div *ngIf="searchIsDone() && !searchHasResults()">
+  <div class="row pt-3">
+    <div class="col-lg-6 offset-lg-3">
+      <div class="alert alert-warning">
+        <span i18n>No Maching Items Were Found</span>
+      </div>
+    </div>
+  </div>
+</div>
+
+<!-- header, pager, and list of records -->
+<div id="staff-catalog-results-container" *ngIf="searchHasResults()">
   <div class="row">
-    <div class="col-lg-2"><!--match pagination margin-->
-      <h3 i18n>Search Results ({{searchContext.result.count}})</h3>
+    <div class="col-lg-2" *ngIf="!searchContext.basket">
+      <ng-container *ngIf="searchContext.termSearch.browseEntry">
+        <h3 i18n>Results for browse "{{searchContext.termSearch.browseEntry.value()}}"</h3>
+      </ng-container>
+      <ng-container *ngIf="!searchContext.termSearch.browseEntry">
+        <h3 i18n>Search Results ({{searchContext.result.count}})</h3>
+      </ng-container>
+    </div>
+    <div class="col-lg-2" *ngIf="searchContext.basket">
+      <h3 i18n>Basket View</h3>
     </div>
-    <div class="col-lg-1"></div>
-    <div class="col-lg-9">
+    <div class="col-lg-2">
+      <label class="checkbox" *ngIf="!searchContext.basket">
+        <input type='checkbox' [(ngModel)]="allRecsSelected" 
+            (change)="toggleAllRecsSelected()"/>
+        <span class="pl-1" i18n>Select {{searchContext.pager.rowNumber(0)}} - 
+          {{searchContext.pager.rowNumber(searchContext.currentResultIds().length - 1)}}
+        </span>
+      </label>
+    </div>
+    <div class="col-lg-8">
       <div class="float-right">
-                               <eg-catalog-result-pagination></eg-catalog-result-pagination>
+        <eg-catalog-result-pagination></eg-catalog-result-pagination>
       </div>
     </div>
   </div>
-       <div class="row mt-2">
-               <div class="col-lg-2">
-      <eg-catalog-result-facets></eg-catalog-result-facets>
-               </div>
-               <div class="col-lg-10">
-                       <div *ngIf="searchContext.result">
-                               <div *ngFor="let summary of searchContext.result.records; let idx = index">
-          <div *ngIf="summary">
-                                         <eg-catalog-result-record [summary]="summary" [index]="idx">
-                                         </eg-catalog-result-record>
+  <div>
+    <div class="row mt-2">
+      <div class="col-lg-2" *ngIf="!searchContext.basket">
+        <eg-catalog-result-facets></eg-catalog-result-facets>
+      </div>
+      <div
+        [ngClass]="{'col-lg-10': !searchContext.basket, 'col-lg-12': searchContext.basket}">
+        <div *ngIf="shouldStartRendering()">
+          <div *ngFor="let summary of searchContext.result.records; let idx = index">
+            <div *ngIf="summary">
+              <eg-catalog-result-record [summary]="summary" [index]="idx">
+              </eg-catalog-result-record>
+            </div>
           </div>
-                               </div>
-                       </div>
-               </div>
-       </div>
+        </div>
+      </div>
+    </div>
+  </div>
 </div>
 
+
index 121888d..6a03b9b 100644 (file)
@@ -1,5 +1,5 @@
-import {Component, OnInit, Input} from '@angular/core';
-import {Observable} from 'rxjs';
+import {Component, OnInit, OnDestroy, Input} from '@angular/core';
+import {Observable, Subscription} from 'rxjs';
 import {map, switchMap, distinctUntilChanged} from 'rxjs/operators';
 import {ActivatedRoute, ParamMap} from '@angular/router';
 import {CatalogService} from '@eg/share/catalog/catalog.service';
@@ -9,12 +9,13 @@ import {CatalogSearchContext, CatalogSearchState} from '@eg/share/catalog/search
 import {PcrudService} from '@eg/core/pcrud.service';
 import {StaffCatalogService} from '../catalog.service';
 import {IdlObject} from '@eg/core/idl.service';
+import {BasketService} from '@eg/share/catalog/basket.service';
 
 @Component({
   selector: 'eg-catalog-results',
   templateUrl: 'results.component.html'
 })
-export class ResultsComponent implements OnInit {
+export class ResultsComponent implements OnInit, OnDestroy {
 
     searchContext: CatalogSearchContext;
 
@@ -22,13 +23,20 @@ export class ResultsComponent implements OnInit {
     // reasonably small set of data w/ lots of repitition.
     userCache: {[id: number]: IdlObject} = {};
 
+    allRecsSelected: boolean;
+
+    searchSub: Subscription;
+    routeSub: Subscription;
+    basketSub: Subscription;
+
     constructor(
         private route: ActivatedRoute,
         private pcrud: PcrudService,
         private cat: CatalogService,
         private bib: BibRecordService,
         private catUrl: CatalogUrlService,
-        private staffCat: StaffCatalogService
+        private staffCat: StaffCatalogService,
+        private basket: BasketService
     ) {}
 
     ngOnInit() {
@@ -41,7 +49,8 @@ export class ResultsComponent implements OnInit {
         // searches.
         //
         // This will also fire on page load.
-        this.route.queryParamMap.subscribe((params: ParamMap) => {
+        this.routeSub = 
+            this.route.queryParamMap.subscribe((params: ParamMap) => {
 
               // TODO: Angular docs suggest using switchMap(), but
               // it's not firing for some reason.  Also, could avoid
@@ -51,8 +60,36 @@ export class ResultsComponent implements OnInit {
               // .map() is not firing either.  I'm missing something.
               this.searchByUrl(params);
         });
+
+        // After each completed search, update the record selector.
+        this.searchSub = this.cat.onSearchComplete.subscribe(
+            ctx => this.applyRecordSelection());
+
+        // Watch for basket changes applied by other components.
+        this.basketSub = this.basket.onChange.subscribe(
+            () => this.applyRecordSelection());
+    }
+
+    ngOnDestroy() {
+        this.routeSub.unsubscribe();
+        this.searchSub.unsubscribe();
+        this.basketSub.unsubscribe();
+    }
+
+    // Apply the select-all checkbox when all visible records
+    // are selected.
+    applyRecordSelection() {
+        const ids = this.searchContext.currentResultIds();
+        let allChecked = true;
+        ids.forEach(id => {
+            if (!this.basket.hasRecordId(id)) { 
+                allChecked = false; 
+            }
+        });
+        this.allRecsSelected = allChecked;
     }
 
+    // Pull values from the URL and run the requested search.
     searchByUrl(params: ParamMap): void {
         this.catUrl.applyUrlParams(this.searchContext, params);
 
@@ -67,6 +104,25 @@ export class ResultsComponent implements OnInit {
         }
     }
 
+    // Records file into place randomly as the server returns data.
+    // To reduce page display shuffling, avoid showing the list of
+    // records until the first few are ready to render.
+    shouldStartRendering(): boolean {
+
+        if (this.searchHasResults()) {
+            const pageCount = this.searchContext.currentResultIds().length;
+            switch (pageCount) {
+                case 1:
+                    return this.searchContext.result.records[0];
+                default:
+                    return this.searchContext.result.records[0]
+                        && this.searchContext.result.records[1];
+            }
+        }
+
+        return false;
+    }
+
     fleshSearchResults(): void {
         const records = this.searchContext.result.records;
         if (!records || records.length === 0) { return; }
@@ -79,6 +135,23 @@ export class ResultsComponent implements OnInit {
         return this.searchContext.searchState === CatalogSearchState.COMPLETE;
     }
 
+    searchIsActive(): boolean {
+        return this.searchContext.searchState === CatalogSearchState.SEARCHING;
+    }
+
+    searchHasResults(): boolean {
+        return this.searchIsDone() && this.searchContext.result.count > 0;
+    }
+
+    toggleAllRecsSelected() {
+        const ids = this.searchContext.currentResultIds();
+
+        if (this.allRecsSelected) {
+            this.basket.addRecordIds(ids);
+        } else {
+            this.basket.removeRecordIds(ids);
+        }
+    }
 }
 
 
index 0e3c96f..8bcef4f 100644 (file)
@@ -4,6 +4,8 @@ import {CatalogComponent} from './catalog.component';
 import {ResultsComponent} from './result/results.component';
 import {RecordComponent} from './record/record.component';
 import {CatalogResolver} from './resolver.service';
+import {HoldComponent} from './hold/hold.component';
+import {BrowseComponent} from './browse.component';
 
 const routes: Routes = [{
   path: '',
@@ -16,9 +18,16 @@ const routes: Routes = [{
     path: 'record/:id',
     component: RecordComponent
   }, {
+    path: 'hold/:type',
+    component: HoldComponent
+  }, {
     path: 'record/:id/:tab',
     component: RecordComponent
-  }]
+  }]}, {
+  // Browse is a top-level UI
+  path: 'browse',
+  component: BrowseComponent,
+  resolve: {catResolver : CatalogResolver},
 }];
 
 @NgModule({
index 6201dff..c7d59d1 100644 (file)
@@ -12,5 +12,17 @@ select.form-control:not([size]):not([multiple]) {
 }
 
 #staffcat-search-form {
-  border-bottom: 2px dashed rgba(0,0,0,.225);
+  border-radius: 0px 0px 7px 7px;
+  background-color: rgba(243, 127, 65, .1);
+  box-shadow: 3px 3px 2px rgba(185, 65, 0, .2);
+}
+
+#staffcat-search-form .tab-content {
+  border: 3px;
+}
+
+.tab-content {
+  padding: 5px;
+  margin-top: 25px;
+  font-weight: bold;
 }
index da54f4a..ee4abc5 100644 (file)
 <!--
 TODO focus search input
 -->
-<div id='staffcat-search-form' class='pb-2 mb-3'>
-  <div class="row"
-    *ngFor="let q of searchContext.query; let idx = index; trackBy:trackByIdx">
-    <div class="col-lg-9 d-flex">
-      <div class="flex-1">
-        <div *ngIf="idx == 0">
-          <select class="form-control" [(ngModel)]="searchContext.format">
-            <option i18n value=''>All Formats</option>
-            <option *ngFor="let fmt of ccvmMap.search_format"
-              value="{{fmt.code()}}">{{fmt.value()}}</option>
-          </select>
-        </div>
-        <div *ngIf="idx > 0">
-          <select class="form-control"
-            [(ngModel)]="searchContext.joinOp[idx]">
-            <option i18n value='&&'>And</option>
-            <option i18n value='||'>Or</option>
-          </select>
-        </div>
-      </div>
-      <div class="flex-1 pl-1">
-        <select class="form-control" 
-          [(ngModel)]="searchContext.fieldClass[idx]">
-          <option i18n value='keyword'>Keyword</option>
-          <option i18n value='title'>Title</option>
-          <option i18n value='jtitle'>Journal Title</option>
-          <option i18n value='author'>Author</option>
-          <option i18n value='subject'>Subject</option>
-          <option i18n value='series'>Series</option>
-        </select>
-      </div>
-      <div class="flex-1 pl-1">
-        <select class="form-control" 
-          [(ngModel)]="searchContext.matchOp[idx]">
-          <option i18n value='contains'>Contains</option>
-          <option i18n value='nocontains'>Does not contain</option>
-          <option i18n value='phrase'>Contains phrase</option>
-          <option i18n value='exact'>Matches exactly</option>
-          <option i18n value='starts'>Starts with</option>
-        </select>
-      </div>
-      <div class="flex-2 pl-1">
-        <div class="form-group">
-          <div *ngIf="idx == 0">
-            <input type="text" class="form-control"
-              id='first-query-input'
-              [(ngModel)]="searchContext.query[idx]"
-              (keyup.enter)="formEnter('query')"
-              placeholder="Query..."/>
+<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="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 context.termSearch.query; let idx = index; trackBy:trackByIdx">
+            <div class="col-lg-2 pr-1">
+              <div *ngIf="idx == 0">
+                <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>
+                </select>
+              </div>
+              <div *ngIf="idx > 0">
+                <select class="form-control"
+                  [(ngModel)]="context.termSearch.joinOp[idx]">
+                  <option i18n value='&&'>And</option>
+                  <option i18n value='||'>Or</option>
+                </select>
+              </div>
+            </div>
+            <div class="col-lg-2 pl-0 pr-2">
+              <select class="form-control" 
+                [(ngModel)]="context.termSearch.fieldClass[idx]">
+                <option i18n value='keyword'>Keyword</option>
+                <option i18n value='title'>Title</option>
+                <option i18n value='jtitle'>Journal Title</option>
+                <option i18n value='author'>Author</option>
+                <option i18n value='subject'>Subject</option>
+                <option i18n value='series'>Series</option>
+              </select>
+            </div>
+            <div class="col-lg-2 pl-0 pr-2">
+              <select class="form-control" 
+                [(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>
+                <option i18n value='exact'>Matches exactly</option>
+                <option i18n value='starts'>Starts with</option>
+              </select>
+            </div>
+            <div class="col-lg-4 pl-0 pr-2">
+              <div class="form-group">
+                <div *ngIf="idx == 0">
+                  <input type="text" class="form-control"
+                    id='first-query-input'
+                    [(ngModel)]="context.termSearch.query[idx]"
+                    (keyup.enter)="searchByForm()"
+                    placeholder="Query..."/>
+                </div>
+                <div *ngIf="idx > 0">
+                  <input type="text" class="form-control"
+                    [(ngModel)]="context.termSearch.query[idx]"
+                    (keyup.enter)="searchByForm()"
+                    placeholder="Query..."/>
+                </div>
+              </div>
+            </div>
+            <div class="col-lg-2 pl-0 pr-1">
+              <button class="btn btn-sm material-icon-button"
+                (click)="addSearchRow(idx + 1)"
+                i18n-title title="Add Search Row">
+                <span class="material-icons">add_circle_outline</span>
+              </button>
+              <button class="btn btn-sm material-icon-button"
+                [disabled]="context.termSearch.query.length < 2"
+                (click)="delSearchRow(idx)"
+                i18n-title title="Remove Search Row">
+                <span class="material-icons">remove_circle_outline</span>
+              </button>
+              <button *ngIf="idx == 0"
+                class="btn btn-sm material-icon-button" 
+                type="button" (click)="toggleFilters()" 
+                title="Toggle Search Filters" i18n-title>
+                <span class="material-icons">more_vert</span>
+              </button>
+            </div>
           </div>
-          <div *ngIf="idx > 0">
-            <input type="text" class="form-control"
-              [(ngModel)]="searchContext.query[idx]"
-              (keyup.enter)="formEnter('query')"
-              placeholder="Query..."/>
+          <div class="row">
+            <div class="col-lg-12 form-inline">
+                <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>
+                    <option value='titlesort.descending' i18n>Title: Z to A</option>
+                  </optgroup>
+                  <optgroup label="Sort by Author" i18n-label>
+                    <option value='authorsort' i18n>Author: A to Z</option>
+                    <option value='authorsort.descending' i18n>Author: Z to A</option>
+                  </optgroup>
+                  <optgroup label="Sort by Publication Date" i18n-label>
+                    <option value='pubdate' i18n>Date: A to Z</option>
+                    <option value='pubdate.descending' i18n>Date: Z to A</option>
+                  </optgroup>
+                  <optgroup label="Sort by Popularity" i18n-label>
+                    <option value='popularity' i18n>Most Popular</option>
+                    <option value='poprel' i18n>Popularity Adjusted Relevance</option>
+                  </optgroup>
+                </select>
+                <div class="checkbox pl-2 ml-2">
+                  <label>
+                    <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"
+                      [(ngModel)]="context.termSearch.groupByMetarecord"/>
+                    <span class="pl-1" i18n>Group Formats/Editions</span>
+                  </label>
+                </div>
+                <div class="checkbox pl-3">
+                  <label>
+                    <input type="checkbox" [(ngModel)]="context.termSearch.global"/>
+                    <span class="pl-1" i18n>Results from All Libraries</span>
+                  </label>
+                </div>
+              </div>
           </div>
-        </div>
-      </div>
-      <div class="flex-1 pl-1">
-        <button class="btn btn-sm material-icon-button"
-          (click)="addSearchRow(idx + 1)">
-          <span class="material-icons">add_circle_outline</span>
-        </button>
-        <button class="btn btn-sm material-icon-button"
-          [disabled]="searchContext.query.length < 2"
-          (click)="delSearchRow(idx)">
-          <span class="material-icons">remove_circle_outline</span>
-        </button>
-      </div>
-    </div><!-- col -->
-    <div class="col-lg-3">
-      <div *ngIf="idx == 0" class="float-right">
-        <button class="btn btn-success mr-1" type="button"
-          [disabled]="searchIsActive()"
-          (click)="searchContext.pager.offset=0;searchByForm()">
-          Search
-        </button>
-        <button class="btn btn-warning mr-1" type="button"
-          [disabled]="searchIsActive()"
-          (click)="searchContext.reset()">
-          Clear Form
-        </button>
-        <button class="btn btn-outline-secondary" type="button"
-          *ngIf="!showAdvanced()"
-          [disabled]="searchIsActive()"
-          (click)="showAdvancedSearch=true">
-          More Filters
-        </button>
-        <button class="btn btn-outline-secondary" type="button"
-          *ngIf="showAdvanced()"
-          (click)="showAdvancedSearch=false">
-          Hide Filters
-        </button>
-      </div>
-    </div>
-  </div><!-- row -->
-
-  <div class="row">
-    <div class="col-lg-9 d-flex">
-      <div class="flex-1">
-        <eg-org-select 
-          (onChange)="orgOnChange($event)"
-          [initialOrg]="searchContext.searchOrg"
-          [placeholder]="'Library'" >
-        </eg-org-select>
-      </div>
-      <div class="flex-3 pl-1">
-        <select class="form-control" [(ngModel)]="searchContext.sort">
-          <option value='' i18n>Sort by Relevance</option>
-          <optgroup label="Sort by Title" i18n-label>
-            <option value='titlesort' i18n>Title: A to Z</option>
-            <option value='titlesort.descending' i18n>Title: Z to A</option>
-          </optgroup>
-          <optgroup label="Sort by Author" i18n-label>
-            <option value='authorsort' i18n>Author: A to Z</option>
-            <option value='authorsort.descending' i18n>Author: Z to A</option>
-          </optgroup>
-          <optgroup label="Sort by Publication Date" i18n-label>
-            <option value='pubdate' i18n>Date: A to Z</option>
-            <option value='pubdate.descending' i18n>Date: Z to A</option>
-          </optgroup>
-          <optgroup label="Sort by Popularity" i18n-label>
-            <option value='popularity' i18n>Most Popular</option>
-            <option value='poprel' i18n>Popularity Adjusted Relevance</option>
-          </optgroup>
-        </select>
-      </div>
-      <div class="flex-2 pl-2 align-self-end">
-        <div class="checkbox">
-          <label>
-            <input type="checkbox" [(ngModel)]="searchContext.available"/>
-            <span i18n>Limit to Available</span>
-          </label>
-        </div>
-      </div>
-      <div class="flex-4 pl-2 align-self-end">
-        <div class="checkbox">
-          <label>
-            <input type="checkbox" [(ngModel)]="searchContext.global"/>
-            <span i18n>Show Results from All Libraries</span>
-          </label>
-        </div>
-      </div>
-      <div class="flex-2 pl-1">
-        <!-- alignment -->
-      </div>
-    </div>
-    <div class="col-lg-3">
-      <div *ngIf="searchIsActive()">
-        <div class="progress">
-          <div class="progress-bar progress-bar-striped active w-100"
-            role="progressbar" aria-valuenow="100" 
-            aria-valuemin="0" aria-valuemax="100">
-            <span class="sr-only" i18n>Searching..</span>
+          <div class="row mt-3" *ngIf="showFilters()">
+            <div class="col-lg-3">
+              <select class="form-control"  multiple="true"
+                [(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>
+              </select>
+            </div>
+            <div class="col-lg-3">
+              <select class="form-control" multiple="true"
+                [(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>
+              </select>
+            </div>
+            <div class="col-lg-3">
+              <select class="form-control" 
+                [(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>
+              </select>
+            </div>
+            <div class="col-lg-3">
+              <select class="form-control" 
+                [(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>
+              </select>
+            </div>
+          </div>
+          <div class="row mt-3" *ngIf="showFilters()">
+            <div class="col-lg-3">
+              <select class="form-control" 
+                [(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>
+              </select>
+            </div>
+            <div class="col-lg-3">
+              <select class="form-control" 
+                [(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>
+              </select>
+            </div>
+            <div class="col-lg-3">
+              <select class="form-control" 
+                [(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>
+              </select>
+            </div>
+            <div class="col-lg-3">
+              <select class="form-control" 
+                [(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())}})
+                </option>
+              </select>
+            </div>
+          </div>
+          <div class="row mt-3" *ngIf="showFilters()">
+            <div class="col-lg-12">
+              <div class="form-inline" i18n>
+                <label for="pub-date1-input">Publication Year is</label>
+                <select class="form-control ml-2" [(ngModel)]="context.termSearch.dateOp">
+                  <option value='is'>Is</option>
+                  <option value='before'>Before</option>
+                  <option value='after'>After</option>
+                  <option value='between'>Between</option>
+                </select>
+                <input class="form-control ml-2" type="number"
+                  [(ngModel)]="context.termSearch.date1"/>
+                <input class="form-control ml-2" type="number"
+                  *ngIf="context.termSearch.dateOp == 'between'"
+                  [(ngModel)]="context.termSearch.date2"/>
+              </div>
+            </div>
+          </div>
+        </ng-template>
+      </ngb-tab>
+      <ngb-tab title="Numeric Search" i18n-title id="ident">
+        <ng-template ngbTabContent>
+          <div class="row mt-4">
+            <div class="col-lg-12">
+              <div class="form-inline">
+                <label for="ident-type" i18n>Query Type</label>
+                <select class="form-control ml-2" name="ident-type"
+                  [(ngModel)]="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>
+                  <option i18n value="identifier|lccn">LCCN</option>
+                  <option i18n value="identifier|tcn">TCN</option>
+                  <option i18n value="item_barcode">Item Barcode</option>
+                </select>
+                <label for="ident-value" class="ml-2" i18n>Value</label>
+                <input name="ident-value" id='ident-query-input' 
+                  type="text" class="form-control ml-2"
+                  [(ngModel)]="context.identSearch.value"
+                  (keyup.enter)="searchByForm()"
+                  placeholder="Numeric Query..."/>
+              </div>
+            </div>
+          </div>
+        </ng-template>
+      </ngb-tab>
+      <ngb-tab title="MARC Search" i18n-title id="marc">
+        <ng-template ngbTabContent>
+          <div class="row mt-4">
+            <div class="col-lg-12">
+              <div class="form-inline mt-2" 
+                *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)]="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)]="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)]="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]="context.marcSearch.values.length < 2"
+                  (click)="delMarcSearchRow(idx)">
+                  <span class="material-icons">remove_circle_outline</span>
+                </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="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
+              </button>
+            </div>
           </div>
         </div>
       </div>
     </div>
-  </div>
-  <div class="row pt-2" *ngIf="showAdvanced()">
-    <div class="col-lg-2">
-      <select class="form-control"  multiple="true"
-        [(ngModel)]="searchContext.ccvmFilters.item_type">
-        <option value='' i18n>All Item Types</option>
-        <option *ngFor="let itemType of ccvmMap.item_type"
-          value="{{itemType.code()}}">{{itemType.value()}}</option>
-      </select>
-    </div>
-    <div class="col-lg-2">
-      <select class="form-control" multiple="true"
-        [(ngModel)]="searchContext.ccvmFilters.item_form">
-        <option value='' i18n>All Item Forms</option>
-        <option *ngFor="let itemForm of ccvmMap.item_form"
-          value="{{itemForm.code()}}">{{itemForm.value()}}</option>
-      </select>
-    </div>
-    <div class="col-lg-2">
-      <select class="form-control" 
-        [(ngModel)]="searchContext.ccvmFilters.item_lang" multiple="true">
-        <option value='' i18n>All Languages</option>
-        <option *ngFor="let lang of ccvmMap.item_lang"
-          value="{{lang.code()}}">{{lang.value()}}</option>
-      </select>
-    </div>
-    <div class="col-lg-2">
-      <select class="form-control" 
-        [(ngModel)]="searchContext.ccvmFilters.audience" multiple="true">
-        <option value='' i18n>All Audiences</option>
-        <option *ngFor="let audience of ccvmMap.audience"
-          value="{{audience.code()}}">{{audience.value()}}</option>
-      </select>
-    </div>
-    <div class="col-lg-2">
-      <select class="form-control"
-        [(ngModel)]="searchContext.identQueryType">
-        <option i18n value="identifier|isbn">ISBN</option>
-        <option i18n value="identifier|issn">ISSN</option>
-        <option i18n disabled value="cnbrowse">Call Number (Shelf Browse)</option>
-        <option i18n value="identifier|lccn">LCCN</option>
-        <option i18n value="identifier|tcn">TCN</option>
-        <option i18n disabled value="item_barcode">Item Barcode</option>
-      </select>
-    </div>
-    <div class="col-lg-2">
-      <input id='ident-query-input' type="text" class="form-control"
-        [(ngModel)]="searchContext.identQuery"
-        (keyup.enter)="formEnter('ident')"
-        placeholder="Numeric Query..."/>
-    </div>
-  </div>
-  <div class="row pt-2" *ngIf="showAdvanced()">
-    <div class="col-lg-2">
-      <select class="form-control" 
-        [(ngModel)]="searchContext.ccvmFilters.vr_format" multiple="true">
-        <option value='' i18n>All Video Formats</option>
-        <option *ngFor="let vrFormat of ccvmMap.vr_format"
-          value="{{vrFormat.code()}}">{{vrFormat.value()}}</option>
-      </select>
-    </div>
-    <div class="col-lg-2">
-      <select class="form-control" 
-        [(ngModel)]="searchContext.ccvmFilters.bib_level" multiple="true">
-        <option value='' i18n>All Bib Levels</option>
-        <option *ngFor="let bibLevel of ccvmMap.bib_level"
-          value="{{bibLevel.code()}}">{{bibLevel.value()}}</option>
-      </select>
-    </div>
-    <div class="col-lg-2">
-      <select class="form-control" 
-        [(ngModel)]="searchContext.ccvmFilters.lit_form" multiple="true">
-        <option value='' i18n>All Literary Forms</option>
-        <option *ngFor="let litForm of ccvmMap.lit_form"
-          value="{{litForm.code()}}">{{litForm.value()}}</option>
-      </select>
-    </div>
-    <div class="col-lg-2">
-      <i>Copy location filter goes here...</i>
+    <div class="row mt-2">
+      <div class="col-lg-12">
+        <eg-catalog-basket-actions></eg-catalog-basket-actions>
+      </div>
     </div>
   </div>
 </div>
index 52a26f2..711ff90 100644 (file)
@@ -1,9 +1,11 @@
 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';
+import {NgbTabset, NgbTabChangeEvent} from '@ng-bootstrap/ng-bootstrap';
 
 @Component({
   selector: 'eg-catalog-search-form',
@@ -12,27 +14,32 @@ import {StaffCatalogService} from './catalog.service';
 })
 export class SearchFormComponent implements OnInit, AfterViewInit {
 
-    searchContext: CatalogSearchContext;
+    context: CatalogSearchContext;
     ccvmMap: {[ccvm: string]: IdlObject[]} = {};
     cmfMap: {[cmf: string]: IdlObject} = {};
-    showAdvancedSearch = false;
+    showSearchFilters = false;
+    copyLocations: IdlObject[];
+    searchTab: string;
 
     constructor(
         private renderer: Renderer2,
+        private router: Router,
         private org: OrgService,
         private cat: CatalogService,
         private staffCat: StaffCatalogService
-    ) {}
+    ) {
+        this.copyLocations = [];
+        //this.searchTab = 'term';
+    }
 
     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.
-        this.showAdvancedSearch = this.hasAdvancedOptions();
-
+        this.showSearchFilters = this.filtersActive();
     }
 
     ngAfterViewInit() {
@@ -40,83 +47,180 @@ export class SearchFormComponent implements OnInit, AfterViewInit {
         // so they are not available until after the first render.
         // Search context data is extracted synchronously from the URL.
 
-        if (this.searchContext.identQuery) {
-            // Focus identifier query input if identQuery is in progress
-            this.renderer.selectRootElement('#ident-query-input').focus();
-        } else {
-            // Otherwise focus the main query input
-            this.renderer.selectRootElement('#first-query-input').focus();
+        // Avoid changing the tab in the lifecycle hook thread.
+        setTimeout(() => {
+
+            // Apply a tab if none was already specified
+            if (!this.searchTab) {
+                // 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.focusTabInput();
+        });
+    }
+
+    onTabChange(evt: NgbTabChangeEvent) {
+        this.searchTab = evt.nextId;
+
+        // Focus after tab-change event has a chance to complete
+        // or the tab body and its input won't exist yet and no
+        // elements will be focus-able.
+        setTimeout(() => this.focusTabInput());
+    }
+
+    focusTabInput() {
+        // Select a DOM node to focus when the tab changes.
+        let selector;
+        switch (this.searchTab) {
+            case 'ident':
+                selector = '#ident-query-input';
+                break;
+            case 'marc':
+                selector = '#first-marc-tag';
+                break;
+            case 'browse':
+                selector = '#browse-term-input';
+                break;
+            default:
+                this.refreshCopyLocations();
+                selector = '#first-query-input';
         }
+
+        this.renderer.selectRootElement(selector).focus();
     }
 
     /**
      * Display the advanced/extended search options when asked to
      * or if any advanced options are selected.
      */
-    showAdvanced(): boolean {
-        return this.showAdvancedSearch;
+    showFilters(): boolean {
+        return this.showSearchFilters;
     }
 
-    hasAdvancedOptions(): boolean {
+    toggleFilters() {
+        this.showSearchFilters = !this.showSearchFilters;
+        this.refreshCopyLocations();
+    }
+
+    filtersActive(): boolean {
+
+        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;
             }
         });
 
-        if (this.searchContext.identQuery) {
-            show = true;
-        }
-
         return show;
     }
 
     orgOnChange = (org: IdlObject): void => {
-        this.searchContext.searchOrg = org;
+        this.context.searchOrg = org;
+        this.refreshCopyLocations();
+    }
+
+    refreshCopyLocations() {
+        if (!this.showFilters()) { return; }
+
+        // TODO: is this how we avoid displaying too many locations?
+        const org = this.context.searchOrg;
+        if (org.id() === this.org.root().id()) { 
+            this.copyLocations = [];
+            return; 
+        }
+
+        this.cat.fetchCopyLocations(org).then(() =>
+            this.copyLocations = this.cat.copyLocations
+        );
+    }
+
+    orgName(orgId: number): string {
+        return this.org.get(orgId).shortname();
     }
 
     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.context.marcSearch.tags.splice(index, 0, '');
+        this.context.marcSearch.subfields.splice(index, 0, '');
+        this.context.marcSearch.values.splice(index, 0, '');
     }
 
-    formEnter(source) {
-        this.searchContext.pager.offset = 0;
+    delMarcSearchRow(index: number): void {
+        this.context.marcSearch.tags.splice(index, 1);
+        this.context.marcSearch.subfields.splice(index, 1);
+        this.context.marcSearch.values.splice(index, 1);
+    }
 
-        switch (source) {
+    searchByForm(): void {
+        this.context.pager.offset = 0; // New search
+
+        // Form search overrides basket display
+        this.context.showBasket = false; 
+
+        switch (this.searchTab) {
+
+            case 'term': // AKA keyword search
+                this.context.marcSearch.reset();
+                this.context.browseSearch.reset();
+                this.context.identSearch.reset();
+                this.context.termSearch.hasBrowseEntry = '';
+                this.context.termSearch.browseEntry = null;
+                this.context.termSearch.fromMetarecord = null;
+                this.context.termSearch.facetFilters = [];
+                this.staffCat.search();
+                break;
 
-            case 'query': // main search form query input
+            case 'ident': 
+                this.context.marcSearch.reset();
+                this.context.browseSearch.reset();
+                this.context.termSearch.reset();
+                this.staffCat.search();
+                break;
 
-                // Be sure a previous ident search does not take precedence
-                // over the newly entered/submitted search query
-                this.searchContext.identQuery = null;
+            case 'marc':
+                this.context.browseSearch.reset();
+                this.context.termSearch.reset();
+                this.context.identSearch.reset();
+                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 'browse':
+                this.context.marcSearch.reset();
+                this.context.termSearch.reset();
+                this.context.identSearch.reset();
+                this.context.browseSearch.pivot = null;
+                this.staffCat.browse();
                 break;
         }
-
-        this.searchByForm();
     }
 
     // https://stackoverflow.com/questions/42322968/angular2-dynamic-input-field-lose-focus-when-input-changes
@@ -124,14 +228,13 @@ export class SearchFormComponent implements OnInit, AfterViewInit {
        return index;
     }
 
-    searchByForm(): void {
-        this.staffCat.search();
-    }
-
     searchIsActive(): boolean {
-        return this.searchContext.searchState === CatalogSearchState.SEARCHING;
+        return this.context.searchState === CatalogSearchState.SEARCHING;
     }
 
+    goToBrowse() {
+        this.router.navigate(['/staff/catalog/browse']);
+    }
 }
 
 
index 9220921..0642f49 100644 (file)
             <span class="material-icons">assignment</span>
             <span i18n>Search for Copies by Barcode</span>
           </a>
-          <a class="dropdown-item" routerLink="/staff/catalog/search"
-            egAccessKey keyCtx="navbar"
-            keySpec="alt+c" i18n-keySpec
-            keyDesc="Navigate To Catalog" i18n-keyDesc>
+          <a href="/eg/staff/cat/catalog/index" class="dropdown-item">
             <span class="material-icons">search</span>
             <span i18n>Search the Catalog</span>
           </a>
             Link to experimental Angular staff catalog.
             Leaving disabled until more functionality can be fleshed out.
           -->
-          <!--
           <a class="dropdown-item"
               routerLink="/staff/catalog/search">
             <span class="material-icons">search</span>
             <span i18n>Staff Catalog (Experimental)</span>
           </a>
-          -->
           <a href="/eg/staff/cat/bucket/record/view" class="dropdown-item">
             <span class="material-icons">list_alt</span>
             <span i18n>Record Buckets</span>
index 78d2653..645b56c 100644 (file)
@@ -14,6 +14,9 @@ export class BibSummaryComponent implements OnInit {
 
     initDone = false;
     expandDisplay = true;
+    @Input() set expand(e: boolean) {
+        this.expandDisplay = e;
+    }
 
     // If provided, the record will be fetched by the component.
     @Input() recordId: number;
index 4399111..a2c88b8 100644 (file)
@@ -1,7 +1,14 @@
 <ng-template #dialogContent>
   <div class="modal-header bg-info">
-    <h4 class="modal-title" *ngIf="recId" i18n>Add To Record #{{recId}} to Bucket</h4>
-    <h4 class="modal-title" *ngIf="qId" i18n>Add Records from queue #{{qId}} to Bucket</h4>
+    <h4 class="modal-title">
+      <ng-container *ngIf="recIds.length > 0">
+        <span *ngIf="recIds.length == 1" i18n>
+          Add Record #{{recIds[0]}} to Bucket</span>
+        <span *ngIf="recIds.length > 1" i18n>
+          Add {{recIds.length}} Record(s) to Bucket</span>
+      </ng-container>
+      <span *ngIf="qId" i18n>Add Records from queue #{{qId}} to Bucket</span>
+    </h4>
     <button type="button" class="close" 
       i18n-aria-label aria-label="Close" 
       (click)="dismiss('cross_click')">
index 2270081..f1f6f19 100644 (file)
@@ -26,9 +26,10 @@ export class RecordBucketDialogComponent
 
     @Input() bucketType: string;
 
-    recId: number;
-    @Input() set recordId(id: number) {
-        this.recId = id;
+    // Add one or more bib records to bucket by ID.
+    recIds: number[];
+    @Input() set recordId(id: number | number[]) {
+        this.recIds = [].concat(id);
     }
 
     // Add items from a (vandelay) bib queue to a bucket
@@ -46,6 +47,7 @@ export class RecordBucketDialogComponent
         private evt: EventService,
         private auth: AuthService) {
         super(modal); // required for subclassing
+        this.recIds = [];
     }
 
     ngOnInit() {
@@ -98,29 +100,33 @@ export class RecordBucketDialogComponent
                 // requires the bucket name.
                 bucket.id(bktId);
                 this.buckets.push(bucket);
-
                 this.addToBucket(bktId);
             }
         });
     }
 
-    // Add the record to the selected existing bucket
     addToBucket(id: number) {
-        if (this.recId) {
+        if (this.recIds.length > 0) {
             this.addRecordToBucket(id);
         } else if (this.qId) {
             this.addQueueToBucket(id);
         }
     }
 
+    // Add the record(s) to the bucket with provided ID.
     addRecordToBucket(bucketId: number) {
-        const item = this.idl.create('cbrebi');
-        item.bucket(bucketId);
-        item.target_biblio_record_entry(this.recId);
+        const items = [];
+        this.recIds.forEach(recId => {
+            const item = this.idl.create('cbrebi');
+            item.bucket(bucketId);
+            item.target_biblio_record_entry(recId);
+            items.push(item);
+        });
+
         this.net.request(
             'open-ils.actor',
             'open-ils.actor.container.item.create',
-            this.auth.token(), 'biblio', item
+            this.auth.token(), 'biblio', items
         ).subscribe(resp => {
             const evt = this.evt.parse(resp);
             if (evt) {
diff --git a/Open-ILS/src/eg2/src/app/staff/share/hold.service.ts b/Open-ILS/src/eg2/src/app/staff/share/hold.service.ts
new file mode 100644 (file)
index 0000000..3d89c20
--- /dev/null
@@ -0,0 +1,143 @@
+/**
+ * Common code for mananging holdings
+ */
+import {Injectable, EventEmitter} from '@angular/core';
+import {Observable} from 'rxjs/Observable';
+import {map} from 'rxjs/operators/map';
+import {mergeMap} from 'rxjs/operators/mergeMap';
+import {IdlObject} from '@eg/core/idl.service';
+import {NetService} from '@eg/core/net.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {EventService, EgEvent} from '@eg/core/event.service';
+import {AuthService} from '@eg/core/auth.service';
+import {BibRecordService, BibRecordSummary} 
+    from '@eg/share/catalog/bib-record.service';
+
+// Response from a place-holds API call.
+export interface HoldRequestResult {
+    success: boolean;
+    holdId?: number;
+    evt?: EgEvent;
+};
+
+// Values passed to the place-holds API call.
+export interface HoldRequest {
+    holdType: string;
+    holdTarget: number;
+    recipient: number;
+    requestor: number;
+    pickupLib: number;
+    override?: boolean;
+    notifyEmail?: boolean;
+    notifyPhone?: string;
+    notifySms?: string;
+    smsCarrier?: string;
+    thawDate?: string; // ISO date
+    frozen?: boolean;
+    holdableFormats?: {[target: number]: string};
+    result?: HoldRequestResult
+};
+
+// A fleshed hold request target object containing whatever data is
+// available for each hold type / target.  E.g. a TITLE hold will
+// not have a value for 'volume', but a COPY hold will, since all
+// copies have volumes.  Every HoldRequestTarget will have a bibId and
+// bibSummary.  Some values come directly from the API call, others
+// applied locally.
+export interface HoldRequestTarget {
+    target: number;
+    metarecord?: IdlObject;
+    bibrecord?: IdlObject;
+    bibId?: number;
+    bibSummary?: BibRecordSummary;
+    part?: IdlObject;
+    volume?: IdlObject;
+    copy?: IdlObject;
+    issuance?: IdlObject;
+    metarecord_filters?: any;
+}
+
+@Injectable()
+export class HoldService {
+
+    constructor(
+        private evt: EventService,
+        private net: NetService,
+        private pcrud: PcrudService,
+        private auth: AuthService,
+        private bib: BibRecordService,
+    ) {}
+
+    placeHold(request: HoldRequest): Observable<HoldRequest> {
+        
+        let method = 'open-ils.circ.holds.test_and_create.batch';
+        if (request.override) { method = method + '.override'; }
+
+        return this.net.request(
+            'open-ils.circ', method, this.auth.token(), {
+                patronid:       request.recipient,
+                pickup_lib:     request.pickupLib,
+                hold_type:      request.holdType,
+                email_notify:   request.notifyEmail,
+                phone_notify:   request.notifyPhone,
+                thaw_date:      request.thawDate,
+                frozen:         request.frozen,
+                sms_notify:     request.notifySms,
+                sms_carrier:    request.smsCarrier,
+                holdable_formats_map: request.holdableFormats
+            },
+            [request.holdTarget]
+        ).pipe(map(
+            resp => {
+                let result = resp.result;
+                const holdResult: HoldRequestResult = {success: true};
+
+                // API can return an ID, an array of events, or a hash
+                // of info.
+
+                if (Number(result) > 0) {
+                    // On success, the API returns the hold ID.
+                    holdResult.holdId = result;
+                    console.debug(`Hold successfully placed ${result}`);
+
+                } else {
+                    holdResult.success = false;
+                    console.info('Hold request failed: ', result);
+
+                    if (Array.isArray(result)) { result = result[0]; }
+
+                    if (this.evt.parse(result)) {
+                        holdResult.evt = this.evt.parse(result);
+                    } else {
+                        holdResult.evt = this.evt.parse(result.last_event);
+                    }
+                }
+
+                request.result = holdResult;
+                return request;
+            }
+        ));
+    }
+
+    getHoldTargetMeta(holdType: string, holdTarget: number | number[], 
+        orgId?: number): Observable<HoldRequestTarget> {
+
+        const targetIds = [].concat(holdTarget);
+
+        return this.net.request(
+            'open-ils.circ',
+            'open-ils.circ.hold.get_metadata',
+            holdType, targetIds, orgId
+        ).pipe(mergeMap(meta => {
+            const target: HoldRequestTarget = meta;
+            target.bibId = target.bibrecord.id();
+
+            return this.bib.getBibSummary(target.bibId)
+            .pipe(map(sum => {
+                target.bibSummary = sum;
+                return target;
+            }));
+        }));
+    }
+}
+
index d2596b5..cf58409 100644 (file)
@@ -3,6 +3,7 @@
  */
 import {Injectable, EventEmitter} from '@angular/core';
 import {NetService} from '@eg/core/net.service';
+import {AnonCacheService} from '@eg/share/util/anon-cache.service';
 
 interface NewVolumeData {
     owner: number;
@@ -12,7 +13,10 @@ interface NewVolumeData {
 @Injectable()
 export class HoldingsService {
 
-    constructor(private net: NetService) {}
+    constructor(
+        private net: NetService,
+        private anonCache: AnonCacheService
+    ) {}
 
     // Open the holdings editor UI in a new browser window/tab.
     spawnAddHoldingsUi(
@@ -30,28 +34,21 @@ export class HoldingsService {
 
         if (raw.length === 0) { raw.push({}); }
 
-        this.net.request(
-            'open-ils.actor',
-            'open-ils.actor.anon_cache.set_value',
-            null, 'edit-these-copies', {
-                record_id: recordId,
-                raw: raw,
-                hide_vols : false,
-                hide_copies : false
+        this.anonCache.setItem(null, 'edit-these-copies', {
+            record_id: recordId,
+            raw: raw,
+            hide_vols : false,
+            hide_copies : false
+        }).then(key => {
+            if (!key) {
+                console.error('Could not create holds cache key!');
+                return;
             }
-        ).subscribe(
-            key => {
-                if (!key) {
-                    console.error('Could not create holds cache key!');
-                    return;
-                }
-                setTimeout(() => {
-                    const url = `/eg/staff/cat/volcopy/${key}`;
-                    window.open(url, '_blank');
-                });
-            }
-        );
+            setTimeout(() => {
+                const url = `/eg/staff/cat/volcopy/${key}`;
+                window.open(url, '_blank');
+            });
+        });
     }
-
 }
 
index b87ad78..cf10855 100644 (file)
@@ -128,10 +128,7 @@ h5 {font-size: .95rem}
 .common-form label {
   font-weight: bold;
 }
-.common-form input[type="checkbox"] {
-  /* BS adds a negative left margin */
-  margin-left: 0px;
-}
+
 .common-form.striped-even .row:nth-child(even) {
   background-color: rgba(0,0,0,.03);
   border-top: 1px solid rgba(0,0,0,.125);
index badb134..abeeea2 100644 (file)
@@ -350,7 +350,8 @@ sub item_create {
     return $e->die_event unless $e->checkauth;
     my $items = (ref $item eq 'ARRAY') ? $item : [$item];
 
-    my ( $bucket, $evt ) = $apputils->fetch_container_e($e, $item->bucket, $class);
+    my ( $bucket, $evt ) = 
+        $apputils->fetch_container_e($e, $items->[0]->bucket, $class);
     return $evt if $evt;
 
     if( $bucket->owner ne $e->requestor->id ) {
index dcbe7dd..7786850 100644 (file)
@@ -3502,12 +3502,13 @@ sub find_hold_mvr {
     my $volume;
     my $issuance;
     my $part;
+    my $metarecord;
     my $no_mvr = $args->{suppress_mvr};
 
     if( $hold->hold_type eq OILS_HOLD_TYPE_METARECORD ) {
-        my $mr = $e->retrieve_metabib_metarecord($hold->target)
+        $metarecord = $e->retrieve_metabib_metarecord($hold->target)
             or return $e->event;
-        $tid = $mr->master_record;
+        $tid = $metarecord->master_record;
 
     } elsif( $hold->hold_type eq OILS_HOLD_TYPE_TITLE ) {
         $tid = $hold->target;
@@ -3553,7 +3554,8 @@ sub find_hold_mvr {
 
     # TODO return metarcord mvr for M holds
     my $title = $e->retrieve_biblio_record_entry($tid);
-    return ( ($no_mvr) ? undef : $U->record_to_mvr($title), $volume, $copy, $issuance, $part, $title );
+    return ( ($no_mvr) ? undef : $U->record_to_mvr($title), 
+        $volume, $copy, $issuance, $part, $title, $metarecord);
 }
 
 __PACKAGE__->register_method(
@@ -4505,4 +4507,89 @@ sub copy_has_holds_count {
     return 0;
 }
 
+__PACKAGE__->register_method(
+    method        => "hold_metadata",
+    api_name      => "open-ils.circ.hold.get_metadata",
+    authoritative => 1,
+    stream => 1,
+    signature     => {
+        desc => q/
+            Returns a stream of objects containing whatever bib, 
+            volume, etc. data is available to the specific hold 
+            type and target.
+        /,
+        params => [
+            {desc => 'Hold Type', type => 'string'},
+            {desc => 'Hold Target(s)', type => 'number or array'},
+            {desc => 'Context org unit (optional)', type => 'number'}
+        ],
+        return => {
+            desc => q/
+                Stream of hold metadata objects.
+            /,
+            type => 'object'
+        }
+    }
+);
+
+sub hold_metadata {
+    my ($self, $client, $hold_type, $hold_targets, $org_id) = @_;
+
+    $hold_targets = [$hold_targets] unless ref $hold_targets;
+
+    my $e = new_editor();
+    for my $target (@$hold_targets) {
+
+        # create a dummy hold for find_hold_mvr
+        my $hold = Fieldmapper::action::hold_request->new;
+        $hold->hold_type($hold_type);
+        $hold->target($target);
+
+        my (undef, $volume, $copy, $issuance, $part, $bre, $metarecord) = 
+            find_hold_mvr($e, $hold, {suppress_mvr => 1});
+
+        $bre->clear_marc; # avoid bulk
+
+        my $meta = {
+            target => $target,
+            copy => $copy,
+            volume => $volume,
+            issuance => $issuance,
+            part => $part,
+            bibrecord => $bre,
+            metarecord => $metarecord,
+            metarecord_filters => {}
+        };
+
+        # If this is a bib hold or metarecord hold, also return the
+        # available set of MR filters (AKA "Holdable Formats") for the
+        # hold.  For bib holds these may be used to upgrade the hold
+        # from a bib to metarecord hold.
+        if ($hold_type eq 'T') {
+            my $map = $e->search_metabib_metarecord_source_map(
+                {source => $meta->{bibrecord}->id})->[0];
+
+            if ($map) {
+                $meta->{metarecord} = 
+                    $e->retrieve_metabib_metarecord($map->metarecord);
+            }
+        }
+
+        if ($meta->{metarecord}) {
+
+            my ($filters) = 
+                $self->method_lookup('open-ils.circ.mmr.holds.filters')
+                    ->run($meta->{metarecord}->id, $org_id);
+
+            if ($filters) {
+                $meta->{metarecord_filters} = $filters->{metarecord};
+            }
+        }
+
+        $client->respond($meta);
+    }
+
+    return undef;
+}
+
 1;
index 9ebb6da..78d4a4e 100644 (file)
@@ -16,6 +16,7 @@ use OpenILS::Application::Search::Z3950;
 use OpenILS::Application::Search::Zips;
 use OpenILS::Application::Search::CNBrowse;
 use OpenILS::Application::Search::Serial;
+use OpenILS::Application::Search::Browse;
 
 
 use OpenILS::Application::AppUtils;
@@ -34,6 +35,7 @@ sub initialize {
 
 sub child_init {
     OpenILS::Application::Search::Z3950->child_init;
+    OpenILS::Application::Search::Browse->child_init;
 }
     
 
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Search/Browse.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Search/Browse.pm
new file mode 100644 (file)
index 0000000..803f08b
--- /dev/null
@@ -0,0 +1,392 @@
+package OpenILS::Application::Search::Browse;
+use base qw/OpenILS::Application/;
+use strict; use warnings;
+
+# Most of this code is copied directly from ../../WWW/EGCatLoader/Browse.pm
+# and modified to be API-compatible.
+
+use Digest::MD5 qw/md5_hex/;
+use Apache2::Const -compile => qw/OK/;
+use MARC::Record;
+use List::Util qw/first/;
+
+use OpenSRF::Utils::Logger qw/$logger/;
+use OpenILS::Utils::CStoreEditor qw/:funcs/;
+use OpenILS::Utils::Fieldmapper;
+use OpenILS::Utils::Normalize qw/search_normalize/;
+use OpenILS::Application::AppUtils;
+use OpenSRF::Utils::JSON;
+use OpenSRF::Utils::Cache;
+use OpenSRF::Utils::SettingsClient;
+
+my $U = 'OpenILS::Application::AppUtils';
+my $browse_cache;
+my $browse_timeout;
+
+sub initialize { return 1; }
+
+sub child_init {
+    if (not defined $browse_cache) {
+        my $conf = new OpenSRF::Utils::SettingsClient;
+
+        $browse_timeout = $conf->config_value(
+            "apps", "open-ils.search", "app_settings", "cache_timeout"
+        ) || 300;
+        $browse_cache = new OpenSRF::Utils::Cache("global");
+    }
+}
+
+__PACKAGE__->register_method(
+    method      => "browse",
+    api_name    => "open-ils.search.browse.staff",
+    stream      => 1,
+    signature   => {
+        desc    => q/Bib + authority browse/,
+        params  => [{
+            params => {
+                name => 'Browse Parameters',
+                desc => q/Hash of arguments:
+                    browse_class
+                        -- title, author, subject, series
+                    term
+                        -- term to browse for
+                    org_unit
+                        -- context org unit ID
+                    copy_location_group
+                        -- copy location filter ID
+                    limit
+                        -- return this many results
+                    pivot
+                        -- browse entry ID
+                /
+            }
+        }]
+    }
+);
+
+__PACKAGE__->register_method(
+    method      => "browse",
+    api_name    => "open-ils.search.browse",
+    stream      => 1,
+    signature   => {
+        desc    => q/See open-ils.search.browse.staff/
+    }
+);
+
+sub browse {
+    my ($self, $client, $params) = @_;
+
+    $params->{staff} = 1 if $self->api_name =~ /staff/;
+    my ($cache_key, @params) = prepare_browse_parameters($params);
+
+    my $results = $browse_cache->get_cache($cache_key);
+
+    if (!$results) {
+        $results = 
+            new_editor()->json_query({from => ['metabib.browse', @params]});
+        if ($results) {
+            $browse_cache->put_cache($cache_key, $results, $browse_timeout);
+        }
+    }
+
+    my ($warning, $alternative) = 
+        leading_article_test($params->{browse_class}, $params->{term});
+
+    for my $result (@$results) {
+        $result->{leading_article_warning} = $warning;
+        $result->{leading_article_alternative} = $alternative;
+        flesh_browse_results([$result]);
+        $client->respond($result);
+    }
+
+    return undef;
+}
+
+
+# Returns cache key and a list of parameters for DB proc metabib.browse().
+sub prepare_browse_parameters {
+    my ($params) = @_;
+
+    no warnings 'uninitialized';
+
+    my @params = (
+        $params->{browse_class},
+        $params->{term},
+        $params->{org_unit},
+        $params->{copy_location_group},
+        $params->{staff} ? 't' : 'f',
+        $params->{pivot},
+        $params->{limit} || 10
+    );
+
+    return (
+        "oils_browse_" . md5_hex(OpenSRF::Utils::JSON->perl2JSON(\@params)),
+        @params
+    );
+}
+
+sub leading_article_test {
+    my ($browse_class, $bterm) = @_;
+
+    my $flag_name = "opac.browse.warnable_regexp_per_class";
+    my $flag = new_editor()->retrieve_config_global_flag($flag_name);
+
+    return unless $flag->enabled eq 't';
+
+    my $map;
+    my $warning;
+    my $alternative;
+
+    eval { $map = OpenSRF::Utils::JSON->JSON2perl($flag->value); };
+    if ($@) {
+        $logger->warn("cgf '$flag_name' enabled but value is invalid JSON? $@");
+        return;
+    }
+
+    # Don't crash over any of the things that could go wrong in here:
+    eval {
+        if ($map->{$browse_class}) {
+            if ($bterm =~ qr/$map->{$browse_class}/i) {
+                $warning = 1;
+                ($alternative = $bterm) =~ s/$map->{$browse_class}//;
+            }
+        }
+    };
+
+    if ($@) {
+        $logger->warn("cgf '$flag_name' has valid JSON in value, but: $@");
+    }
+
+    return ($warning, $alternative);
+}
+
+# flesh_browse_results() attaches data from authority records. It
+# changes $results and returns 1 for success, undef for failure
+# $results must be an arrayref of result rows from the DB's metabib.browse()
+sub flesh_browse_results {
+    my ($results) = @_;
+
+    for my $authority_field_name ( qw/authorities sees/ ) {
+        for my $r (@$results) {
+            # Turn comma-seprated strings of numbers in "authorities" and "sees"
+            # columns into arrays.
+            if ($r->{$authority_field_name}) {
+                $r->{$authority_field_name} = [split /,/, $r->{$authority_field_name}];
+            } else {
+                $r->{$authority_field_name} = [];
+            }
+            $r->{"list_$authority_field_name"} = [ @{$r->{$authority_field_name} } ];
+        }
+
+        # Group them in one arrray, not worrying about dupes because we're about
+        # to use them in an IN () comparison in a SQL query.
+        my @auth_ids = map { @{$_->{$authority_field_name}} } @$results;
+
+        if (@auth_ids) {
+            # Get all linked authority records themselves
+            my $linked = new_editor()->json_query({
+                select => {
+                    are => [qw/id marc control_set/],
+                    aalink => [{column => "target", transform => "array_agg",
+                        aggregate => 1}]
+                },
+                from => {
+                    are => {
+                        aalink => {
+                            type => "left",
+                            fkey => "id", field => "source"
+                        }
+                    }
+                },
+                where => {"+are" => {id => \@auth_ids}}
+            }) or return;
+
+            map_authority_headings_to_results(
+                $linked, $results, \@auth_ids, $authority_field_name);
+        }
+    }
+
+    return 1;
+}
+
+sub map_authority_headings_to_results {
+    my ($linked, $results, $auth_ids, $authority_field_name) = @_;
+
+    # Use the linked authority records' control sets to find and pick
+    # out non-main-entry headings. Build the headings and make a
+    # combined data structure for the template's use.
+    my %linked_headings_by_auth_id = map {
+        $_->{id} => find_authority_headings_and_notes($_)
+    } @$linked;
+
+    # Avoid sending the full MARC blobs to the caller.
+    delete $_->{marc} for @$linked;
+
+    # Graft this authority heading data onto our main result set at the
+    # named column, either "authorities" or "sees".
+    foreach my $row (@$results) {
+        $row->{$authority_field_name} = [
+            map { $linked_headings_by_auth_id{$_} } @{$row->{$authority_field_name}}
+        ];
+    }
+
+    # Get linked-bib counts for each of those authorities, and put THAT
+    # information into place in the data structure.
+    my $counts = new_editor()->json_query({
+        select => {
+            abl => [
+                {column => "id", transform => "count",
+                    alias => "count", aggregate => 1},
+                "authority"
+            ]
+        },
+        from => {abl => {}},
+        where => {
+            "+abl" => {
+                authority => [
+                    @$auth_ids,
+                    $U->unique_unnested_numbers(map { $_->{target} } @$linked)
+                ]
+            }
+        }
+    }) or return;
+
+    my %auth_counts = map { $_->{authority} => $_->{count} } @$counts;
+
+    # Soooo nesty!  We look for places where we'll need a count of bibs
+    # linked to an authority record, and put it there for the template to find.
+    for my $row (@$results) {
+        for my $auth (@{$row->{$authority_field_name}}) {
+            if ($auth->{headings}) {
+                for my $outer_heading (@{$auth->{headings}}) {
+                    for my $heading_blob (@{(values %$outer_heading)[0]}) {
+                        if ($heading_blob->{target}) {
+                            $heading_blob->{target_count} =
+                                $auth_counts{$heading_blob->{target}};
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
+
+
+# TOOD consider locale-aware caching
+sub get_acsaf {
+    my $control_set = shift;
+
+    my $acs = new_editor()
+        ->search_authority_control_set_authority_field(
+            {control_set => $control_set}
+        );
+
+    return {  map { $_->id => $_ } @$acs };
+}
+
+sub find_authority_headings_and_notes {
+    my ($row) = @_;
+
+    my $acsaf_table = get_acsaf($row->{control_set});
+
+    $row->{headings} = [];
+
+    my $record;
+    eval {
+        $record = new_from_xml MARC::Record($row->{marc});
+    };
+
+    if ($@) {
+        $logger->warn("Problem with MARC from authority record #" .
+            $row->{id} . ": $@");
+        return $row;    # We're called in map(), so we must move on without
+                        # a fuss.
+    }
+
+    extract_public_general_notes($record, $row);
+
+    # extract headings from the main authority record along with their
+    # types
+    my $parsed_headings = new_editor()->json_query({
+        from => ['authority.extract_headings', $row->{marc}]
+    });
+    my %heading_type_map = ();
+    if ($parsed_headings) {
+        foreach my $h (@$parsed_headings) {
+            $heading_type_map{$h->{normalized_heading}} =
+                $h->{purpose} eq 'variant' ? 'variant' :
+                $h->{purpose} eq 'related' ? $h->{related_type} :
+                '';
+        }
+    }
+
+    # By applying grep in this way, we get acsaf objects that *have* and
+    # therefore *aren't* main entries, which is what we want.
+    foreach my $acsaf (values(%$acsaf_table)) {
+        my @fields = $record->field($acsaf->tag);
+        my %sf_lookup = map { $_ => 1 } split("", $acsaf->display_sf_list);
+        my @headings;
+
+        foreach my $field (@fields) {
+            my $h = { main_entry => ( $acsaf->main_entry ? 0 : 1 ),
+                      heading => get_authority_heading($field, \%sf_lookup, $acsaf->joiner) };
+
+            my $norm = search_normalize($h->{heading});
+            if (exists $heading_type_map{$norm}) {
+                $h->{type} = $heading_type_map{$norm};
+            }
+            # XXX I was getting "target" from authority.authority_linking, but
+            # that makes no sense: that table can only tell you that one
+            # authority record as a whole points at another record.  It does
+            # not record when a specific *field* in one authority record
+            # points to another record (not that it makes much sense for
+            # one authority record to have links to multiple others, but I can't
+            # say there definitely aren't cases for that).
+            $h->{target} = $2
+                if ($field->subfield('0') || "") =~ /(^|\))(\d+)$/;
+
+            # The target is the row id if this is a main entry...
+            $h->{target} = $row->{id} if $h->{main_entry};
+
+            push @headings, $h;
+        }
+
+        push @{$row->{headings}}, {$acsaf->id => \@headings} if @headings;
+    }
+
+    return $row;
+}
+
+
+# Break out any Public General Notes (field 680) for display. These are
+# sometimes (erroneously?) called "scope notes." I say erroneously,
+# tentatively, because LoC doesn't seem to document a "scope notes"
+# field for authority records, while it does so for classification
+# records, which are something else. But I am not a librarian.
+sub extract_public_general_notes {
+    my ($record, $row) = @_;
+
+    # Make a list of strings, each string being a concatentation of any
+    # subfields 'i', '5', or 'a' from one field 680, in order of appearance.
+    $row->{notes} = [
+        map {
+            join(
+                " ",
+                map { $_->[1] } grep { $_->[0] =~ /[i5a]/ } $_->subfields
+            )
+        } $record->field('680')
+    ];
+}
+
+sub get_authority_heading {
+    my ($field, $sf_lookup, $joiner) = @_;
+
+    $joiner ||= ' ';
+
+    return join(
+        $joiner,
+        map { $_->[1] } grep { $sf_lookup->{$_->[0]} } $field->subfields
+    );
+}
+
+1;