* 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>
<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">
</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"/>
<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"
<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">
/**
*
*/
- 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;
}
// 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) {
const values: any = {};
keys.forEach(key => {
- if (this.cache[key]) {
+ if (key in this.cache) {
values[key] = this.cache[key];
}
});
--- /dev/null
+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([]);
+ }
+}
+
+
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';
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;
this.display = {};
this.attributes = {};
this.bibCallNumber = null;
+ this.metabibRecords = [];
}
ingest() {
// 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()];
}
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);
}
}
// 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')
}));
}
+ // 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
}
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',
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';
MarcHtmlComponent
],
providers: [
+ AnonCacheService,
CatalogService,
CatalogUrlService,
UnapiService,
- BibRecordService
+ BibRecordService,
+ BasketService,
]
})
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 {
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.
}
});
- 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;
}
// 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(/,/);
+ }
}
}
}
+
+
-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';
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.
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, {
).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 {
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;
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();
});
}
+ 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.
);
});
}
+
+ 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;
+ }));
+ }
}
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,
}
}
-// 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;
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 = [];
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; }
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;
}
}
-
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>
@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>;
@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 {
});
}
- // 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();
}
[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>
}
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) {
<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>
// The value is prefixed with 'eg.grid.'
@Input() persistKey: string;
+ @Input() disableSelect: boolean;
+
// Prevent selection of multiple rows
@Input() disableMultiSelect: boolean;
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;
useLocalSort: boolean;
persistKey: string;
disableMultiSelect: boolean;
+ disableSelect: boolean;
dataSource: GridDataSource;
columnSet: GridColumnSet;
rowSelector: GridRowSelector;
--- /dev/null
+/**
+ * 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();
+ }
+}
+
+
--- /dev/null
+<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>
--- /dev/null
+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
+ }
+}
+
+
--- /dev/null
+
+<eg-catalog-search-form #searchForm></eg-catalog-search-form>
+
+<eg-catalog-browse-results><eg-catalog-browse-results>
+
--- /dev/null
+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';
+ }
+}
+
--- /dev/null
+
+<!-- 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>
+
+
--- /dev/null
+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();
+ }
+}
+
+
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();
}
}
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: [
ResultFacetsComponent,
ResultPaginationComponent,
RecordPaginationComponent,
- RecordActionsComponent
+ RecordActionsComponent,
+ BasketActionsComponent,
+ HoldComponent,
+ PartsComponent,
+ PartMergeDialogComponent,
+ BrowseComponent,
+ BrowseResultsComponent
],
imports: [
StaffCommonModule,
],
providers: [
StaffCatalogService,
- HoldingsService
+ HoldingsService,
+ HoldService
]
})
['/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});
+ }
}
--- /dev/null
+
+<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>
+
+
--- /dev/null
+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
+ )
+ );
+ }
+}
+
+
<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">
<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">
--- /dev/null
+<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>
--- /dev/null
+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));
+ }
+}
+
+
--- /dev/null
+<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>
--- /dev/null
+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')
+ );
+ };
+ }
+}
+
</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>
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',
summary: BibRecordSummary;
searchContext: CatalogSearchContext;
@ViewChild('recordTabs') recordTabs: NgbTabset;
+ defaultTab: string; // eg.cat.default_record_tab
constructor(
private router: Router,
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) {
// 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'});
}
}
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']);
+ })
}
-
}
}
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();
}
--- /dev/null
+
+/**
+ * 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;
+}
<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>
<span i18n>Place Hold</span>
</button>
</span>
+ <!--
<span class="pl-1">
<button
(click)="addToListDialog.recordId=summary.record.id(); addToListDialog.open({size: 'lg'})"
<span i18n>Add to List</span>
</button>
</span>
+ -->
</div>
</div>
</div>
-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';
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,
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 {
}
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 {
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]);
+ }
+ }
}
-<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>
+
-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';
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;
// 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() {
// 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
// .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);
}
}
+ // 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; }
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);
+ }
+ }
}
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: '',
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({
}
#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;
}
<!--
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>
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',
})
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() {
// 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
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']);
+ }
}
<span class="material-icons">assignment</span>
<span i18n>Search for Copies by Barcode</span>
</a>
- <a class="dropdown-item" routerLink="/staff/catalog/search"
- egAccessKey keyCtx="navbar"
- keySpec="alt+c" i18n-keySpec
- keyDesc="Navigate To Catalog" i18n-keyDesc>
+ <a href="/eg/staff/cat/catalog/index" class="dropdown-item">
<span class="material-icons">search</span>
<span i18n>Search the Catalog</span>
</a>
Link to experimental Angular staff catalog.
Leaving disabled until more functionality can be fleshed out.
-->
- <!--
<a class="dropdown-item"
routerLink="/staff/catalog/search">
<span class="material-icons">search</span>
<span i18n>Staff Catalog (Experimental)</span>
</a>
- -->
<a href="/eg/staff/cat/bucket/record/view" class="dropdown-item">
<span class="material-icons">list_alt</span>
<span i18n>Record Buckets</span>
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;
<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')">
@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
private evt: EventService,
private auth: AuthService) {
super(modal); // required for subclassing
+ this.recIds = [];
}
ngOnInit() {
// 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) {
--- /dev/null
+/**
+ * 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;
+ }));
+ }));
+ }
+}
+
*/
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;
@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(
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');
+ });
+ });
}
-
}
.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);
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 ) {
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;
# 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(
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;
use OpenILS::Application::Search::Zips;
use OpenILS::Application::Search::CNBrowse;
use OpenILS::Application::Search::Serial;
+use OpenILS::Application::Search::Browse;
use OpenILS::Application::AppUtils;
sub child_init {
OpenILS::Application::Search::Z3950->child_init;
+ OpenILS::Application::Search::Browse->child_init;
}
--- /dev/null
+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;