From 7075905d588919bce7e6a520b3a061e142e16e19 Mon Sep 17 00:00:00 2001 From: Bill Erickson <berickxx@gmail.com> Date: Wed, 7 Nov 2018 10:18:31 -0500 Subject: [PATCH] LP1806087 Angular staff catalog phase II. * 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> --- Open-ILS/examples/fm_IDL.xml | 21 +- Open-ILS/src/eg2/src/app/core/org.service.ts | 3 +- Open-ILS/src/eg2/src/app/core/perm.service.ts | 3 +- .../src/eg2/src/app/core/server-store.service.ts | 2 +- .../eg2/src/app/share/catalog/basket.service.ts | 103 ++++ .../src/app/share/catalog/bib-record.service.ts | 104 +++- .../src/app/share/catalog/catalog-common.module.ts | 6 +- .../src/app/share/catalog/catalog-url.service.ts | 236 ++++++--- .../eg2/src/app/share/catalog/catalog.service.ts | 208 +++++++- .../eg2/src/app/share/catalog/search-context.ts | 459 ++++++++++++----- .../share/date-select/date-select.component.html | 3 +- .../app/share/date-select/date-select.component.ts | 6 +- .../src/app/share/fm-editor/fm-editor.component.ts | 11 +- .../src/app/share/grid/grid-body.component.html | 8 +- .../eg2/src/app/share/grid/grid-body.component.ts | 7 + .../src/app/share/grid/grid-header.component.html | 8 +- .../src/eg2/src/app/share/grid/grid.component.ts | 3 + Open-ILS/src/eg2/src/app/share/grid/grid.ts | 1 + .../eg2/src/app/share/util/anon-cache.service.ts | 59 +++ .../staff/catalog/basket-actions.component.html | 28 ++ .../app/staff/catalog/basket-actions.component.ts | 106 ++++ .../src/app/staff/catalog/browse.component.html | 5 + .../eg2/src/app/staff/catalog/browse.component.ts | 28 ++ .../staff/catalog/browse/results.component.html | 84 ++++ .../app/staff/catalog/browse/results.component.ts | 140 ++++++ .../eg2/src/app/staff/catalog/catalog.component.ts | 9 +- .../eg2/src/app/staff/catalog/catalog.module.ts | 18 +- .../eg2/src/app/staff/catalog/catalog.service.ts | 22 + .../src/app/staff/catalog/hold/hold.component.html | 293 +++++++++++ .../src/app/staff/catalog/hold/hold.component.ts | 401 +++++++++++++++ .../staff/catalog/record/actions.component.html | 6 +- .../app/staff/catalog/record/copies.component.html | 1 + .../record/part-merge-dialog.component.html | 28 ++ .../catalog/record/part-merge-dialog.component.ts | 70 +++ .../app/staff/catalog/record/parts.component.html | 22 + .../app/staff/catalog/record/parts.component.ts | 123 +++++ .../app/staff/catalog/record/record.component.html | 26 +- .../app/staff/catalog/record/record.component.ts | 59 ++- .../eg2/src/app/staff/catalog/resolver.service.ts | 24 +- .../app/staff/catalog/result/facets.component.ts | 4 +- .../app/staff/catalog/result/record.component.css | 15 + .../app/staff/catalog/result/record.component.html | 87 ++-- .../app/staff/catalog/result/record.component.ts | 67 ++- .../staff/catalog/result/results.component.html | 85 +++- .../app/staff/catalog/result/results.component.ts | 83 +++- .../eg2/src/app/staff/catalog/routing.module.ts | 11 +- .../app/staff/catalog/search-form.component.css | 14 +- .../app/staff/catalog/search-form.component.html | 552 ++++++++++++--------- .../src/app/staff/catalog/search-form.component.ts | 209 ++++++-- Open-ILS/src/eg2/src/app/staff/nav.component.html | 7 +- .../share/bib-summary/bib-summary.component.ts | 3 + .../buckets/record-bucket-dialog.component.html | 11 +- .../buckets/record-bucket-dialog.component.ts | 26 +- .../src/eg2/src/app/staff/share/hold.service.ts | 143 ++++++ .../eg2/src/app/staff/share/holdings.service.ts | 41 +- Open-ILS/src/eg2/src/styles.css | 5 +- .../lib/OpenILS/Application/Actor/Container.pm | 3 +- .../perlmods/lib/OpenILS/Application/Circ/Holds.pm | 93 +++- .../src/perlmods/lib/OpenILS/Application/Search.pm | 2 + .../lib/OpenILS/Application/Search/Browse.pm | 392 +++++++++++++++ 60 files changed, 3909 insertions(+), 688 deletions(-) create mode 100644 Open-ILS/src/eg2/src/app/share/catalog/basket.service.ts create mode 100644 Open-ILS/src/eg2/src/app/share/util/anon-cache.service.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/basket-actions.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/basket-actions.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/browse.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/browse.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/browse/results.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/browse/results.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/record/part-merge-dialog.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/record/part-merge-dialog.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/record/parts.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/record/parts.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.css create mode 100644 Open-ILS/src/eg2/src/app/staff/share/hold.service.ts create mode 100644 Open-ILS/src/perlmods/lib/OpenILS/Application/Search/Browse.pm diff --git a/Open-ILS/examples/fm_IDL.xml b/Open-ILS/examples/fm_IDL.xml index b8d0efff60..db651878bd 100644 --- a/Open-ILS/examples/fm_IDL.xml +++ b/Open-ILS/examples/fm_IDL.xml @@ -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"> diff --git a/Open-ILS/src/eg2/src/app/core/org.service.ts b/Open-ILS/src/eg2/src/app/core/org.service.ts index 530e3cb5ad..71dba933ed 100644 --- a/Open-ILS/src/eg2/src/app/core/org.service.ts +++ b/Open-ILS/src/eg2/src/app/core/org.service.ts @@ -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; diff --git a/Open-ILS/src/eg2/src/app/core/perm.service.ts b/Open-ILS/src/eg2/src/app/core/perm.service.ts index 44d3c635fb..2b3a471ad2 100644 --- a/Open-ILS/src/eg2/src/app/core/perm.service.ts +++ b/Open-ILS/src/eg2/src/app/core/perm.service.ts @@ -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) { diff --git a/Open-ILS/src/eg2/src/app/core/server-store.service.ts b/Open-ILS/src/eg2/src/app/core/server-store.service.ts index 43415c1951..ea2d93da36 100644 --- a/Open-ILS/src/eg2/src/app/core/server-store.service.ts +++ b/Open-ILS/src/eg2/src/app/core/server-store.service.ts @@ -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 index 0000000000..99c8c24ca0 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/catalog/basket.service.ts @@ -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([]); + } +} + + diff --git a/Open-ILS/src/eg2/src/app/share/catalog/bib-record.service.ts b/Open-ILS/src/eg2/src/app/share/catalog/bib-record.service.ts index e9fbb610ff..5602bbb192 100644 --- a/Open-ILS/src/eg2/src/app/share/catalog/bib-record.service.ts +++ b/Open-ILS/src/eg2/src/app/share/catalog/bib-record.service.ts @@ -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', diff --git a/Open-ILS/src/eg2/src/app/share/catalog/catalog-common.module.ts b/Open-ILS/src/eg2/src/app/share/catalog/catalog-common.module.ts index c370b300c9..eeaf38af29 100644 --- a/Open-ILS/src/eg2/src/app/share/catalog/catalog-common.module.ts +++ b/Open-ILS/src/eg2/src/app/share/catalog/catalog-common.module.ts @@ -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, ] }) diff --git a/Open-ILS/src/eg2/src/app/share/catalog/catalog-url.service.ts b/Open-ILS/src/eg2/src/app/share/catalog/catalog-url.service.ts index 253e3aacdd..0f07070656 100644 --- a/Open-ILS/src/eg2/src/app/share/catalog/catalog-url.service.ts +++ b/Open-ILS/src/eg2/src/app/share/catalog/catalog-url.service.ts @@ -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(/,/); + } } } } + + diff --git a/Open-ILS/src/eg2/src/app/share/catalog/catalog.service.ts b/Open-ILS/src/eg2/src/app/share/catalog/catalog.service.ts index 7c3a365b60..b8ffb857d4 100644 --- a/Open-ILS/src/eg2/src/app/share/catalog/catalog.service.ts +++ b/Open-ILS/src/eg2/src/app/share/catalog/catalog.service.ts @@ -1,6 +1,6 @@ -import {Injectable} 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; + })); + } } diff --git a/Open-ILS/src/eg2/src/app/share/catalog/search-context.ts b/Open-ILS/src/eg2/src/app/share/catalog/search-context.ts index e4e64b2d0e..d34d71105d 100644 --- a/Open-ILS/src/eg2/src/app/share/catalog/search-context.ts +++ b/Open-ILS/src/eg2/src/app/share/catalog/search-context.ts @@ -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; } } - diff --git a/Open-ILS/src/eg2/src/app/share/date-select/date-select.component.html b/Open-ILS/src/eg2/src/app/share/date-select/date-select.component.html index a1558b1ce9..575bbde5c8 100644 --- a/Open-ILS/src/eg2/src/app/share/date-select/date-select.component.html +++ b/Open-ILS/src/eg2/src/app/share/date-select/date-select.component.html @@ -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> diff --git a/Open-ILS/src/eg2/src/app/share/date-select/date-select.component.ts b/Open-ILS/src/eg2/src/app/share/date-select/date-select.component.ts index 2f8837d967..625629026f 100644 --- a/Open-ILS/src/eg2/src/app/share/date-select/date-select.component.ts +++ b/Open-ILS/src/eg2/src/app/share/date-select/date-select.component.ts @@ -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>; diff --git a/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.ts b/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.ts index 25d05525bd..17c0e46dd1 100644 --- a/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.ts +++ b/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.ts @@ -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(); } diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid-body.component.html b/Open-ILS/src/eg2/src/app/share/grid/grid-body.component.html index b7284fe6f0..8d495aa84d 100644 --- a/Open-ILS/src/eg2/src/app/share/grid/grid-body.component.html +++ b/Open-ILS/src/eg2/src/app/share/grid/grid-body.component.html @@ -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> diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid-body.component.ts b/Open-ILS/src/eg2/src/app/share/grid/grid-body.component.ts index e4829cee01..15aa2b7038 100644 --- a/Open-ILS/src/eg2/src/app/share/grid/grid-body.component.ts +++ b/Open-ILS/src/eg2/src/app/share/grid/grid-body.component.ts @@ -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) { diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid-header.component.html b/Open-ILS/src/eg2/src/app/share/grid/grid-header.component.html index 58e0c66774..0662f54b0b 100644 --- a/Open-ILS/src/eg2/src/app/share/grid/grid-header.component.html +++ b/Open-ILS/src/eg2/src/app/share/grid/grid-header.component.html @@ -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> diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid.component.ts b/Open-ILS/src/eg2/src/app/share/grid/grid.component.ts index 3bcc2cbe78..d48028b938 100644 --- a/Open-ILS/src/eg2/src/app/share/grid/grid.component.ts +++ b/Open-ILS/src/eg2/src/app/share/grid/grid.component.ts @@ -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; diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid.ts b/Open-ILS/src/eg2/src/app/share/grid/grid.ts index 37bb188c72..dcffc95143 100644 --- a/Open-ILS/src/eg2/src/app/share/grid/grid.ts +++ b/Open-ILS/src/eg2/src/app/share/grid/grid.ts @@ -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 index 0000000000..29c168dc71 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/util/anon-cache.service.ts @@ -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 index 0000000000..5837acee84 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/catalog/basket-actions.component.html @@ -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 index 0000000000..08d02bcc63 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/catalog/basket-actions.component.ts @@ -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 index 0000000000..b50a415b4b --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/catalog/browse.component.html @@ -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 index 0000000000..67e5eed1f1 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/catalog/browse.component.ts @@ -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 index 0000000000..fdbb05408c --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/catalog/browse/results.component.html @@ -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 index 0000000000..8fcbce1e65 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/catalog/browse/results.component.ts @@ -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(); + } +} + + diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/catalog.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/catalog.component.ts index 8b2206c2f5..0e2fc98884 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/catalog.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/catalog/catalog.component.ts @@ -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(); } } diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/catalog.module.ts b/Open-ILS/src/eg2/src/app/staff/catalog/catalog.module.ts index 20e17a091c..2d30199441 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/catalog.module.ts +++ b/Open-ILS/src/eg2/src/app/staff/catalog/catalog.module.ts @@ -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 ] }) diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/catalog.service.ts b/Open-ILS/src/eg2/src/app/staff/catalog/catalog.service.ts index 1e50d9ba88..cf0a36c97f 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/catalog.service.ts +++ b/Open-ILS/src/eg2/src/app/staff/catalog/catalog.service.ts @@ -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 index 0000000000..1ef096c495 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.html @@ -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 index 0000000000..a0a0dc24f4 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.ts @@ -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 + ) + ); + } +} + + diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/actions.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/record/actions.component.html index 6fd945414c..1a76b282f7 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/record/actions.component.html +++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/actions.component.html @@ -15,8 +15,12 @@ <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"> diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/copies.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/record/copies.component.html index e7d82491b5..e60fb24847 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/record/copies.component.html +++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/copies.component.html @@ -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 index 0000000000..ef702ebfea --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/part-merge-dialog.component.html @@ -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">×</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 index 0000000000..27c4d3045f --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/part-merge-dialog.component.ts @@ -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 index 0000000000..f5693c18e5 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/parts.component.html @@ -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 index 0000000000..e74fc12d42 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/parts.component.ts @@ -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') + ); + }; + } +} + diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html index 4c74316c4e..d1385230f3 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html +++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html @@ -19,17 +19,39 @@ </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> diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.ts index b217e5c9b6..0414a076b4 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.ts @@ -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'}); } diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/resolver.service.ts b/Open-ILS/src/eg2/src/app/staff/catalog/resolver.service.ts index 8761c58924..02b44c90df 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/resolver.service.ts +++ b/Open-ILS/src/eg2/src/app/staff/catalog/resolver.service.ts @@ -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']); + }) } - } diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/result/facets.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/result/facets.component.ts index 44583b8780..f16215a65f 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/result/facets.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/catalog/result/facets.component.ts @@ -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 index 0000000000..3077d9ac93 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.css @@ -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; +} diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.html index 54ad3db0ee..90f066b1e9 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.html +++ b/Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.html @@ -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 || ' '}} - </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 || ' '}} - </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 || ' '}} + </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 || ' '}} + </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> @@ -114,6 +127,7 @@ <span i18n>Place Hold</span> </button> </span> + <!-- <span class="pl-1"> <button (click)="addToListDialog.recordId=summary.record.id(); addToListDialog.open({size: 'lg'})" @@ -122,6 +136,7 @@ <span i18n>Add to List</span> </button> </span> + --> </div> </div> </div> diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.ts index bfcfd4572e..7510b3d108 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.ts @@ -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]); + } + } } diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/result/results.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/result/results.component.html index ee9ca8ddf2..902e50baa4 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/result/results.component.html +++ b/Open-ILS/src/eg2/src/app/staff/catalog/result/results.component.html @@ -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> + diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/result/results.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/result/results.component.ts index 121888d3ba..6a03b9bdd2 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/result/results.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/catalog/result/results.component.ts @@ -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); + } + } } diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/routing.module.ts b/Open-ILS/src/eg2/src/app/staff/catalog/routing.module.ts index 0e3c96fd00..8bcef4f30c 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/routing.module.ts +++ b/Open-ILS/src/eg2/src/app/staff/catalog/routing.module.ts @@ -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({ diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.css b/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.css index 6201dff923..c7d59d19d3 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.css +++ b/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.css @@ -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; } diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.html index da54f4a9b9..ee4abc522c 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.html +++ b/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.html @@ -1,243 +1,333 @@ <!-- 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> diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.ts index 52a26f2b2b..711ff90ac7 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.ts @@ -1,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']); + } } diff --git a/Open-ILS/src/eg2/src/app/staff/nav.component.html b/Open-ILS/src/eg2/src/app/staff/nav.component.html index 92209218e6..0642f493e1 100644 --- a/Open-ILS/src/eg2/src/app/staff/nav.component.html +++ b/Open-ILS/src/eg2/src/app/staff/nav.component.html @@ -26,10 +26,7 @@ <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> @@ -143,13 +140,11 @@ 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> diff --git a/Open-ILS/src/eg2/src/app/staff/share/bib-summary/bib-summary.component.ts b/Open-ILS/src/eg2/src/app/staff/share/bib-summary/bib-summary.component.ts index 78d2653c47..645b56cd78 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/bib-summary/bib-summary.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/share/bib-summary/bib-summary.component.ts @@ -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; diff --git a/Open-ILS/src/eg2/src/app/staff/share/buckets/record-bucket-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/share/buckets/record-bucket-dialog.component.html index 4399111883..a2c88b8e34 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/buckets/record-bucket-dialog.component.html +++ b/Open-ILS/src/eg2/src/app/staff/share/buckets/record-bucket-dialog.component.html @@ -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')"> diff --git a/Open-ILS/src/eg2/src/app/staff/share/buckets/record-bucket-dialog.component.ts b/Open-ILS/src/eg2/src/app/staff/share/buckets/record-bucket-dialog.component.ts index 2270081148..f1f6f19cfa 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/buckets/record-bucket-dialog.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/share/buckets/record-bucket-dialog.component.ts @@ -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 index 0000000000..3d89c20523 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/hold.service.ts @@ -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; + })); + })); + } +} + diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings.service.ts b/Open-ILS/src/eg2/src/app/staff/share/holdings.service.ts index d2596b5527..cf58409982 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/holdings.service.ts +++ b/Open-ILS/src/eg2/src/app/staff/share/holdings.service.ts @@ -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'); + }); + }); } - } diff --git a/Open-ILS/src/eg2/src/styles.css b/Open-ILS/src/eg2/src/styles.css index b87ad78acf..cf10855253 100644 --- a/Open-ILS/src/eg2/src/styles.css +++ b/Open-ILS/src/eg2/src/styles.css @@ -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); diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Actor/Container.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Actor/Container.pm index badb13482d..abeeea2e9b 100644 --- a/Open-ILS/src/perlmods/lib/OpenILS/Application/Actor/Container.pm +++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Actor/Container.pm @@ -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 ) { diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Holds.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Holds.pm index dcbe7dda4f..77868503f3 100644 --- a/Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Holds.pm +++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Holds.pm @@ -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; diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Search.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Search.pm index 9ebb6da3d4..78d4a4e2db 100644 --- a/Open-ILS/src/perlmods/lib/OpenILS/Application/Search.pm +++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Search.pm @@ -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 index 0000000000..803f08b054 --- /dev/null +++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Search/Browse.pm @@ -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; -- 2.11.0