From 7075905d588919bce7e6a520b3a061e142e16e19 Mon Sep 17 00:00:00 2001 From: Bill Erickson Date: Wed, 7 Nov 2018 10:18:31 -0500 Subject: [PATCH] LP1806087 Angular staff catalog phase II. * Record detail tabs redirect to AngJS catalog where needed. * Initial holds placement UI. * Record baskets, actions, and UI. * Ported MonographParts tab to Angular * Set default catalog tab * Browse * MARC search * Identifier search * pub date filter * Record detail 'View in Catalog' button * Group formats and editions Signed-off-by: Bill Erickson Signed-off-by: Dan Wells --- Open-ILS/examples/fm_IDL.xml | 21 +- Open-ILS/src/eg2/src/app/core/org.service.ts | 3 +- Open-ILS/src/eg2/src/app/core/perm.service.ts | 3 +- .../eg2/src/app/core/server-store.service.ts | 2 +- .../src/app/share/catalog/basket.service.ts | 103 ++++ .../app/share/catalog/bib-record.service.ts | 104 +++- .../share/catalog/catalog-common.module.ts | 6 +- .../app/share/catalog/catalog-url.service.ts | 236 +++++--- .../src/app/share/catalog/catalog.service.ts | 208 ++++++- .../src/app/share/catalog/search-context.ts | 459 +++++++++++---- .../date-select/date-select.component.html | 3 +- .../date-select/date-select.component.ts | 6 +- .../share/fm-editor/fm-editor.component.ts | 11 +- .../app/share/grid/grid-body.component.html | 8 +- .../src/app/share/grid/grid-body.component.ts | 7 + .../app/share/grid/grid-header.component.html | 8 +- .../eg2/src/app/share/grid/grid.component.ts | 3 + Open-ILS/src/eg2/src/app/share/grid/grid.ts | 1 + .../src/app/share/util/anon-cache.service.ts | 59 ++ .../catalog/basket-actions.component.html | 28 + .../staff/catalog/basket-actions.component.ts | 106 ++++ .../app/staff/catalog/browse.component.html | 5 + .../src/app/staff/catalog/browse.component.ts | 28 + .../catalog/browse/results.component.html | 84 +++ .../staff/catalog/browse/results.component.ts | 140 +++++ .../app/staff/catalog/catalog.component.ts | 9 +- .../src/app/staff/catalog/catalog.module.ts | 18 +- .../src/app/staff/catalog/catalog.service.ts | 22 + .../staff/catalog/hold/hold.component.html | 293 ++++++++++ .../app/staff/catalog/hold/hold.component.ts | 401 +++++++++++++ .../catalog/record/actions.component.html | 6 +- .../catalog/record/copies.component.html | 1 + .../record/part-merge-dialog.component.html | 28 + .../record/part-merge-dialog.component.ts | 70 +++ .../staff/catalog/record/parts.component.html | 22 + .../staff/catalog/record/parts.component.ts | 123 ++++ .../catalog/record/record.component.html | 26 +- .../staff/catalog/record/record.component.ts | 59 +- .../src/app/staff/catalog/resolver.service.ts | 24 +- .../staff/catalog/result/facets.component.ts | 4 +- .../staff/catalog/result/record.component.css | 15 + .../catalog/result/record.component.html | 87 +-- .../staff/catalog/result/record.component.ts | 67 ++- .../catalog/result/results.component.html | 85 ++- .../staff/catalog/result/results.component.ts | 83 ++- .../src/app/staff/catalog/routing.module.ts | 11 +- .../staff/catalog/search-form.component.css | 14 +- .../staff/catalog/search-form.component.html | 552 ++++++++++-------- .../staff/catalog/search-form.component.ts | 209 +++++-- .../src/eg2/src/app/staff/nav.component.html | 7 +- .../bib-summary/bib-summary.component.ts | 3 + .../record-bucket-dialog.component.html | 11 +- .../buckets/record-bucket-dialog.component.ts | 26 +- .../eg2/src/app/staff/share/hold.service.ts | 143 +++++ .../src/app/staff/share/holdings.service.ts | 41 +- Open-ILS/src/eg2/src/styles.css | 5 +- .../OpenILS/Application/Actor/Container.pm | 3 +- .../lib/OpenILS/Application/Circ/Holds.pm | 93 ++- .../lib/OpenILS/Application/Search.pm | 2 + .../lib/OpenILS/Application/Search/Browse.pm | 392 +++++++++++++ 60 files changed, 3909 insertions(+), 688 deletions(-) create mode 100644 Open-ILS/src/eg2/src/app/share/catalog/basket.service.ts create mode 100644 Open-ILS/src/eg2/src/app/share/util/anon-cache.service.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/basket-actions.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/basket-actions.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/browse.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/browse.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/browse/results.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/browse/results.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/record/part-merge-dialog.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/record/part-merge-dialog.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/record/parts.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/record/parts.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.css create mode 100644 Open-ILS/src/eg2/src/app/staff/share/hold.service.ts create mode 100644 Open-ILS/src/perlmods/lib/OpenILS/Application/Search/Browse.pm diff --git a/Open-ILS/examples/fm_IDL.xml b/Open-ILS/examples/fm_IDL.xml index b8d0efff60..db651878bd 100644 --- a/Open-ILS/examples/fm_IDL.xml +++ b/Open-ILS/examples/fm_IDL.xml @@ -3931,18 +3931,25 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA - + + + + + + + + @@ -3975,7 +3982,7 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA - + @@ -3985,6 +3992,11 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA + + + + + + + + + + diff --git a/Open-ILS/src/eg2/src/app/core/org.service.ts b/Open-ILS/src/eg2/src/app/core/org.service.ts index 530e3cb5ad..71dba933ed 100644 --- a/Open-ILS/src/eg2/src/app/core/org.service.ts +++ b/Open-ILS/src/eg2/src/app/core/org.service.ts @@ -231,9 +231,10 @@ export class OrgService { /** * */ - settings(names: string[], + settings(name: string | string[], orgId?: number, anonymous?: boolean): Promise { + let names = [].concat(name); const settings = {}; let auth: string = null; let useCache = false; diff --git a/Open-ILS/src/eg2/src/app/core/perm.service.ts b/Open-ILS/src/eg2/src/app/core/perm.service.ts index 44d3c635fb..2b3a471ad2 100644 --- a/Open-ILS/src/eg2/src/app/core/perm.service.ts +++ b/Open-ILS/src/eg2/src/app/core/perm.service.ts @@ -41,7 +41,8 @@ export class PermService { } // workstation required - hasWorkPermHere(permNames: string[]): Promise { + hasWorkPermHere(permNames: string | string[]): Promise { + permNames = [].concat(permNames); const wsId: number = +this.auth.user().wsid(); if (!wsId) { diff --git a/Open-ILS/src/eg2/src/app/core/server-store.service.ts b/Open-ILS/src/eg2/src/app/core/server-store.service.ts index 43415c1951..ea2d93da36 100644 --- a/Open-ILS/src/eg2/src/app/core/server-store.service.ts +++ b/Open-ILS/src/eg2/src/app/core/server-store.service.ts @@ -65,7 +65,7 @@ export class ServerStoreService { const values: any = {}; keys.forEach(key => { - if (this.cache[key]) { + if (key in this.cache) { values[key] = this.cache[key]; } }); diff --git a/Open-ILS/src/eg2/src/app/share/catalog/basket.service.ts b/Open-ILS/src/eg2/src/app/share/catalog/basket.service.ts new file mode 100644 index 0000000000..99c8c24ca0 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/catalog/basket.service.ts @@ -0,0 +1,103 @@ +import {Injectable, EventEmitter} from '@angular/core'; +import {Observable} from 'rxjs/Observable'; +import {StoreService} from '@eg/core/store.service'; +import {NetService} from '@eg/core/net.service'; +import {PcrudService} from '@eg/core/pcrud.service'; +import {AnonCacheService} from '@eg/share/util/anon-cache.service'; + +// Baskets are stored in an anonymous cache using the cache key stored +// in a LoginSessionItem (i.e. cookie) at name BASKET_CACHE_KEY_COOKIE. +// The list is stored under attribute BASKET_CACHE_ATTR. +// Avoid conflicts with the AngularJS embedded catalog basket by +// using a different value for the cookie name, since our version +// stores all cookies as JSON, unlike the TPAC. +const BASKET_CACHE_KEY_COOKIE = 'basket'; +const BASKET_CACHE_ATTR = 'recordIds'; + +@Injectable() +export class BasketService { + + idList: number[]; + + // Fired every time our list of ID's are updated. + onChange: EventEmitter; + + constructor( + private net: NetService, + private pcrud: PcrudService, + private store: StoreService, + private anonCache: AnonCacheService + ) { + this.idList = []; + this.onChange = new EventEmitter(); + } + + 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 { + 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 { + 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 { + 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 { + + 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 { + return this.setRecordIds([]); + } +} + + diff --git a/Open-ILS/src/eg2/src/app/share/catalog/bib-record.service.ts b/Open-ILS/src/eg2/src/app/share/catalog/bib-record.service.ts index e9fbb610ff..5602bbb192 100644 --- a/Open-ILS/src/eg2/src/app/share/catalog/bib-record.service.ts +++ b/Open-ILS/src/eg2/src/app/share/catalog/bib-record.service.ts @@ -1,6 +1,6 @@ import {Injectable} from '@angular/core'; import {Observable, from} from 'rxjs'; -import {mergeMap, map} from 'rxjs/operators'; +import {mergeMap, map, tap} from 'rxjs/operators'; import {OrgService} from '@eg/core/org.service'; import {UnapiService} from '@eg/share/catalog/unapi.service'; import {IdlService, IdlObject} from '@eg/core/idl.service'; @@ -20,6 +20,8 @@ export const HOLDINGS_XPATH = export class BibRecordSummary { id: number; // == record.id() for convenience + metabibId: number; // If present, this is a metabib summary + metabibRecords: number[]; // all constituent bib records orgId: number; orgDepth: number; record: IdlObject; @@ -38,6 +40,7 @@ export class BibRecordSummary { this.display = {}; this.attributes = {}; this.bibCallNumber = null; + this.metabibRecords = []; } ingest() { @@ -67,7 +70,10 @@ export class BibRecordSummary { // Any attr can be multi-valued. this.record.mattrs().forEach(attr => { if (this.attributes[attr.attr()]) { - this.attributes[attr.attr()].push(attr.value()); + // Avoid dupes + if (this.attributes[attr.attr()].indexOf(attr.value()) < 0) { + this.attributes[attr.attr()].push(attr.value()); + } } else { this.attributes[attr.attr()] = [attr.value()]; } @@ -81,9 +87,16 @@ export class BibRecordSummary { return Promise.resolve(this.holdCount); } + let method = 'open-ils.circ.bre.holds.count'; + let target = this.id; + + if (this.metabibId) { + method = 'open-ils.circ.mmr.holds.count'; + target = this.metabibId; + } + return this.net.request( - 'open-ils.circ', - 'open-ils.circ.bre.holds.count', this.id + 'open-ils.circ', method, target ).toPromise().then(count => this.holdCount = count); } @@ -131,7 +144,7 @@ export class BibRecordService { } // Avoid fetching the MARC blob by specifying which fields on the - // bre to select. Note that fleshed fields are explicitly selected. + // bre to select. Note that fleshed fields are implicitly selected. fetchableBreFields(): string[] { return this.idl.classes.bre.fields .filter(f => !f.virtual && f.name !== 'marc') @@ -167,6 +180,83 @@ export class BibRecordService { })); } + // A Metabib Summary is a BibRecordSummary with the lead record as + // its core bib record plus attributes (e.g. formats) from related + // records. + getMetabibSummary(metabibIds: number | number[], + orgId?: number, orgDepth?: number): Observable { + + 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 { + + // 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(o => observer = o); + + // NOTE: getBibSummary calls getHoldingsSummary against + // the bib record unnecessarily. It's called again below. + // Reconsider this approach (see also note above about API). + this.getBibSummary(metabib.master_record(), orgId, orgDepth) + .subscribe(summary => { + summary.metabibId = metabib.id(); + summary.metabibRecords = + metabib.source_maps().map(map => Number(map.source())) + + let promise; + + if (relatedBibIds.length > 0) { + + // Grab data for MR bib summary augmentation + promise = this.pcrud.search('mraf', {id: relatedBibIds}) + .pipe(tap(attr => summary.record.mattrs().push(attr))) + .toPromise(); + } else { + + // Metarecord has only one constituent bib. + promise = Promise.resolve(); + } + + promise.then(() => { + + // Re-compile with augmented data + summary.compileRecordAttrs(); + + // Fetch holdings data for the metarecord + this.getHoldingsSummary(metabib.id(), orgId, orgDepth, true) + .then(holdingsSummary => { + summary.holdingsSummary = holdingsSummary; + observer.next(summary); + observer.complete(); + }); + }); + }); + + return observable; + } + // Flesh the creator and editor fields. // Handling this separately lets us pull from the cache and // avoids the requirement that the main bib query use a staff @@ -207,12 +297,12 @@ export class BibRecordService { } getHoldingsSummary(recordId: number, - orgId: number, orgDepth: number): Promise { + orgId: number, orgDepth: number, isMetarecord?: boolean): Promise { const holdingsSummary = []; return this.unapi.getAsXmlDocument({ - target: 'bre', + target: isMetarecord ? 'mmr' : 'bre', id: recordId, extras: '{holdings_xml}', format: 'holdings_xml', diff --git a/Open-ILS/src/eg2/src/app/share/catalog/catalog-common.module.ts b/Open-ILS/src/eg2/src/app/share/catalog/catalog-common.module.ts index c370b300c9..eeaf38af29 100644 --- a/Open-ILS/src/eg2/src/app/share/catalog/catalog-common.module.ts +++ b/Open-ILS/src/eg2/src/app/share/catalog/catalog-common.module.ts @@ -1,6 +1,8 @@ import {NgModule} from '@angular/core'; import {EgCommonModule} from '@eg/common.module'; import {CatalogService} from './catalog.service'; +import {AnonCacheService} from '@eg/share/util/anon-cache.service' +import {BasketService} from './basket.service'; import {CatalogUrlService} from './catalog-url.service'; import {BibRecordService} from './bib-record.service'; import {UnapiService} from './unapi.service'; @@ -18,10 +20,12 @@ import {MarcHtmlComponent} from './marc-html.component'; MarcHtmlComponent ], providers: [ + AnonCacheService, CatalogService, CatalogUrlService, UnapiService, - BibRecordService + BibRecordService, + BasketService, ] }) diff --git a/Open-ILS/src/eg2/src/app/share/catalog/catalog-url.service.ts b/Open-ILS/src/eg2/src/app/share/catalog/catalog-url.service.ts index 253e3aacdd..0f07070656 100644 --- a/Open-ILS/src/eg2/src/app/share/catalog/catalog-url.service.ts +++ b/Open-ILS/src/eg2/src/app/share/catalog/catalog-url.service.ts @@ -1,8 +1,9 @@ import {Injectable} from '@angular/core'; import {ParamMap} from '@angular/router'; import {OrgService} from '@eg/core/org.service'; -import {CatalogSearchContext, FacetFilter} from './search-context'; -import {CATALOG_CCVM_FILTERS} from './catalog.service'; +import {CatalogSearchContext, CatalogBrowseContext, CatalogMarcContext, + CatalogTermContext, FacetFilter} from './search-context'; +import {CATALOG_CCVM_FILTERS} from './search-context'; @Injectable() export class CatalogUrlService { @@ -19,28 +20,22 @@ export class CatalogUrlService { toUrlParams(context: CatalogSearchContext): {[key: string]: string | string[]} { - const params = { - query: [], - fieldClass: [], - joinOp: [], - matchOp: [], - facets: [], - identQuery: null, - identQueryType: null, - org: null, - limit: null, - offset: null - }; - - params.org = context.searchOrg.id(); - - params.limit = context.pager.limit; + const params: any = {}; + + if (context.searchOrg) { + params.org = context.searchOrg.id(); + } + + if (context.pager.limit) { + params.limit = context.pager.limit; + } + if (context.pager.offset) { params.offset = context.pager.offset; } // These fields can be copied directly into place - ['format', 'sort', 'available', 'global', 'identQuery', 'identQueryType'] + ['limit', 'offset', 'sort', 'global', 'showBasket', 'sort'] .forEach(field => { if (context[field]) { // Only propagate applied values to the URL. @@ -48,36 +43,84 @@ export class CatalogUrlService { } }); - if (params.identQuery) { - // Ident queries (e.g. tcn search) discards all remaining filters - return params; + if (context.marcSearch.isSearchable()) { + const ms = context.marcSearch; + params.marcTag = []; + params.marcSubfield = []; + params.marcValue = []; + + ms.values.forEach((val, idx) => { + if (val !== '') { + params.marcTag.push(ms.tags[idx]); + params.marcSubfield.push(ms.subfields[idx]); + params.marcValue.push(ms.values[idx]); + } + }); } - context.query.forEach((q, idx) => { - ['query', 'fieldClass', 'joinOp', 'matchOp'].forEach(field => { - // Propagate all array-based fields regardless of - // whether a value is applied to ensure correct - // correlation between values. - params[field][idx] = context[field][idx]; - }); - }); + if (context.identSearch.isSearchable()) { + params.identQuery = context.identSearch.value; + params.identQueryType = context.identSearch.queryType; + } - // CCVM filters are encoded as comma-separated lists - Object.keys(context.ccvmFilters).forEach(code => { - if (context.ccvmFilters[code] && - context.ccvmFilters[code][0] !== '') { - params[code] = context.ccvmFilters[code].join(','); + if (context.browseSearch.isSearchable()) { + params.browseTerm = context.browseSearch.value; + params.browseClass = context.browseSearch.fieldClass; + if (context.browseSearch.pivot) { + params.browsePivot = context.browseSearch.pivot; } - }); + } - // Each facet is a JSON encoded blob of class, name, and value - context.facetFilters.forEach(facet => { - params.facets.push(JSON.stringify({ - c : facet.facetClass, - n : facet.facetName, - v : facet.facetValue - })); - }); + if (context.termSearch.isSearchable()) { + + const ts = context.termSearch; + + params.query = []; + params.fieldClass = []; + params.joinOp = []; + params.matchOp = []; + + ['format', 'available', 'hasBrowseEntry', 'date1', + 'date2', 'dateOp', 'groupByMetarecord', 'fromMetarecord'] + .forEach(field => { + if (ts[field]) { + params[field] = ts[field]; + } + }); + + ts.query.forEach((val, idx) => { + if (val !== '') { + params.query.push(ts.query[idx]); + params.fieldClass.push(ts.fieldClass[idx]); + params.joinOp.push(ts.joinOp[idx]); + params.matchOp.push(ts.matchOp[idx]); + } + }); + + // CCVM filters are encoded as comma-separated lists + Object.keys(ts.ccvmFilters).forEach(code => { + if (ts.ccvmFilters[code] && + ts.ccvmFilters[code][0] !== '') { + params[code] = ts.ccvmFilters[code].join(','); + } + }); + + // Each facet is a JSON encoded blob of class, name, and value + if (ts.facetFilters.length) { + params.facets = []; + ts.facetFilters.forEach(facet => { + params.facets.push(JSON.stringify({ + c : facet.facetClass, + n : facet.facetName, + v : facet.facetValue + })); + }); + } + + if (ts.copyLocations.length && ts.copyLocations[0] !== '') { + params.copyLocations = ts.copyLocations.join(','); + } + } return params; } @@ -97,47 +140,96 @@ export class CatalogUrlService { // Reset query/filter args. The will be reconstructed below. context.reset(); + let val; - // These fields can be copied directly into place - ['format', 'sort', 'available', 'global', 'identQuery', 'identQueryType'] - .forEach(field => { - const val = params.get(field); - if (val !== null) { - context[field] = val; - } - }); + if (params.get('org')) { + context.searchOrg = this.org.get(+params.get('org')); + } - if (params.get('limit')) { - context.pager.limit = +params.get('limit'); + if (val = params.get('limit')) { + context.pager.limit = +val; } - if (params.get('offset')) { - context.pager.offset = +params.get('offset'); + if (val = params.get('offset')) { + context.pager.offset = +val; } - ['query', 'fieldClass', 'joinOp', 'matchOp'].forEach(field => { - const arr = params.getAll(field); - if (arr && arr.length) { - context[field] = arr; - } - }); + if (val = params.get('sort')) { + context.sort = val; + } + + if (val = params.get('global')) { + context.global = val; + } + + if (val = params.get('showBasket')) { + context.showBasket = val; + } - CATALOG_CCVM_FILTERS.forEach(code => { - const val = params.get(code); - if (val) { - context.ccvmFilters[code] = val.split(/,/); - } else { - context.ccvmFilters[code] = ['']; + if (params.get('marcValue')) { + context.marcSearch.tags = params.getAll('marcTag'); + context.marcSearch.subfields = params.getAll('marcSubfield'); + context.marcSearch.values = params.getAll('marcValue'); + } + + if (params.get('identQuery')) { + context.identSearch.value = params.get('identQuery'); + context.identSearch.queryType = params.get('identQueryType'); + } + + if (params.get('browseTerm')) { + context.browseSearch.value = params.get('browseTerm'); + context.browseSearch.fieldClass = params.get('browseClass'); + if (params.has('browsePivot')) { + context.browseSearch.pivot = +params.get('browsePivot'); } - }); + } + + const ts = context.termSearch; + // browseEntry and query searches may be facet-limited params.getAll('facets').forEach(blob => { const facet = JSON.parse(blob); - context.addFacet(new FacetFilter(facet.c, facet.n, facet.v)); + ts.addFacet(new FacetFilter(facet.c, facet.n, facet.v)); }); - if (params.get('org')) { - context.searchOrg = this.org.get(+params.get('org')); + if (params.has('hasBrowseEntry')) { + + ts.hasBrowseEntry = params.get('hasBrowseEntry'); + + } else if (params.has('query')) { + + // Scalars + ['format', 'available', 'date1', 'date2', + 'dateOp', 'groupByMetarecord', 'fromMetarecord'] + .forEach(field => { + if (params.has(field)) { + ts[field] = params.get(field); + } + }); + + // Arrays + ['query', 'fieldClass', 'joinOp', 'matchOp'].forEach(field => { + const arr = params.getAll(field); + if (params.has(field)) { + ts[field] = params.getAll(field); + } + }); + + CATALOG_CCVM_FILTERS.forEach(code => { + const val = params.get(code); + if (val) { + ts.ccvmFilters[code] = val.split(/,/); + } else { + ts.ccvmFilters[code] = ['']; + } + }); + + if (params.get('copyLocations')) { + ts.copyLocations = params.get('copyLocations').split(/,/); + } } } } + + diff --git a/Open-ILS/src/eg2/src/app/share/catalog/catalog.service.ts b/Open-ILS/src/eg2/src/app/share/catalog/catalog.service.ts index 7c3a365b60..b8ffb857d4 100644 --- a/Open-ILS/src/eg2/src/app/share/catalog/catalog.service.ts +++ b/Open-ILS/src/eg2/src/app/share/catalog/catalog.service.ts @@ -1,6 +1,6 @@ -import {Injectable} from '@angular/core'; +import {Injectable, EventEmitter} from '@angular/core'; import {Observable} from 'rxjs'; -import {mergeMap, map} from 'rxjs/operators'; +import {mergeMap, map, tap} from 'rxjs/operators'; import {OrgService} from '@eg/core/org.service'; import {UnapiService} from '@eg/share/catalog/unapi.service'; import {IdlService, IdlObject} from '@eg/core/idl.service'; @@ -8,27 +8,15 @@ import {NetService} from '@eg/core/net.service'; import {PcrudService} from '@eg/core/pcrud.service'; import {CatalogSearchContext, CatalogSearchState} from './search-context'; import {BibRecordService, BibRecordSummary} from './bib-record.service'; - -// CCVM's we care about in a catalog context -// Don't fetch them all because there are a lot. -export const CATALOG_CCVM_FILTERS = [ - 'item_type', - 'item_form', - 'item_lang', - 'audience', - 'audience_group', - 'vr_format', - 'bib_level', - 'lit_form', - 'search_format', - 'icon_format' -]; +import {BasketService} from './basket.service'; +import {CATALOG_CCVM_FILTERS} from './search-context'; @Injectable() export class CatalogService { ccvmMap: {[ccvm: string]: IdlObject[]} = {}; cmfMap: {[cmf: string]: IdlObject} = {}; + copyLocations: IdlObject[]; // Keep a reference to the most recently retrieved facet data, // since facet data is consistent across a given search. @@ -36,27 +24,117 @@ export class CatalogService { lastFacetData: any; lastFacetKey: string; + // Allow anyone to watch for completed searches. + onSearchComplete: EventEmitter; + 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(); + + } search(ctx: CatalogSearchContext): Promise { 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 { + 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 { + + 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 { + 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 { let method = 'open-ils.search.biblio.multiclass.query'; + let fullQuery; + + if (ctx.identSearch.isSearchable()) { + fullQuery = ctx.compileIdentSearchQuery(); + + } else { + fullQuery = ctx.compileTermSearchQuery(); + + if (ctx.termSearch.groupByMetarecord + && !ctx.termSearch.fromMetarecord) { + method = 'open-ils.search.metabib.multiclass.query'; + } + + if (ctx.termSearch.hasBrowseEntry) { + this.fetchBrowseEntry(ctx); + } + } + + console.debug(`search query: ${fullQuery}`); + if (ctx.isStaff) { method += '.staff'; } - + return new Promise((resolve, reject) => { this.net.request( 'open-ils.search', method, { @@ -66,9 +144,24 @@ export class CatalogService { ).subscribe(result => { this.applyResultData(ctx, result); ctx.searchState = CatalogSearchState.COMPLETE; + this.onSearchComplete.emit(ctx); resolve(); }); }); + + } + + // When showing titles linked to a browse entry, fetch + // the entry data as well so the UI can display it. + fetchBrowseEntry(ctx: CatalogSearchContext) { + const ts = ctx.termSearch; + + const parts = ts.hasBrowseEntry.split(','); + const mbeId = parts[0]; + const cmfId = parts[1]; + + this.pcrud.retrieve('mbe', mbeId) + .subscribe(mbe => ctx.termSearch.browseEntry = mbe); } applyResultData(ctx: CatalogSearchContext, result: any): void { @@ -94,11 +187,27 @@ export class CatalogService { ctx.org.root().ou_type().depth() : ctx.searchOrg.ou_type().depth(); - return this.bibService.getBibSummary( - ctx.currentResultIds(), ctx.searchOrg.id(), depth) - .pipe(map(summary => { + const isMeta = ctx.termSearch.isMetarecordSearch(); + + let observable: Observable; + + if (isMeta) { + observable = this.bibService.getMetabibSummary( + ctx.currentResultIds(), ctx.searchOrg.id(), depth); + } else { + observable = this.bibService.getBibSummary( + ctx.currentResultIds(), ctx.searchOrg.id(), depth); + } + + return observable.pipe(map(summary => { // Responses are not necessarily returned in request-ID order. - const idx = ctx.currentResultIds().indexOf(summary.record.id()); + let idx; + if (isMeta) { + idx = ctx.currentResultIds().indexOf(summary.metabibId); + } else { + idx = ctx.currentResultIds().indexOf(summary.id); + } + if (ctx.result.records) { // May be reset when quickly navigating results. ctx.result.records[idx] = summary; @@ -112,6 +221,10 @@ export class CatalogService { return Promise.reject('Cannot fetch facets without results'); } + if (!ctx.result.facet_key) { + return Promise.resolve(); + } + if (this.lastFacetKey === ctx.result.facet_key) { ctx.result.facetData = this.lastFacetData; return Promise.resolve(); @@ -188,6 +301,15 @@ export class CatalogService { }); } + iconFormatLabel(code: string): string { + if (this.ccvmMap) { + const ccvm = this.ccvmMap.icon_format.filter( + format => format.code() === code)[0]; + if (ccvm) { + return ccvm.search_label(); + } + } + } fetchCmfs(): Promise { // At the moment, we only need facet CMFs. @@ -206,4 +328,38 @@ export class CatalogService { ); }); } + + fetchCopyLocations(contextOrg: number | IdlObject): Promise { + 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 { + ctx.searchState = CatalogSearchState.SEARCHING; + const bs = ctx.browseSearch; + + let method = 'open-ils.search.browse'; + if (ctx.isStaff) { + method += '.staff'; + } + + return this.net.request( + 'open-ils.search', + 'open-ils.search.browse.staff', { + browse_class: bs.fieldClass, + term: bs.value, + limit : ctx.pager.limit, + pivot: bs.pivot, + org_unit: ctx.searchOrg.id() + } + ).pipe(tap(result => { + ctx.searchState = CatalogSearchState.COMPLETE; + })); + } } diff --git a/Open-ILS/src/eg2/src/app/share/catalog/search-context.ts b/Open-ILS/src/eg2/src/app/share/catalog/search-context.ts index e4e64b2d0e..d34d71105d 100644 --- a/Open-ILS/src/eg2/src/app/share/catalog/search-context.ts +++ b/Open-ILS/src/eg2/src/app/share/catalog/search-context.ts @@ -3,6 +3,21 @@ import {IdlObject} from '@eg/core/idl.service'; import {Pager} from '@eg/share/util/pager'; import {Params} from '@angular/router'; +// CCVM's we care about in a catalog context +// Don't fetch them all because there are a lot. +export const CATALOG_CCVM_FILTERS = [ + 'item_type', + 'item_form', + 'item_lang', + 'audience', + 'audience_group', + 'vr_format', + 'bib_level', + 'lit_form', + 'search_format', + 'icon_format' +]; + export enum CatalogSearchState { PENDING, SEARCHING, @@ -29,32 +44,183 @@ export class FacetFilter { } } -// Not an angular service. -// It's conceviable there could be multiple contexts. -export class CatalogSearchContext { +export class CatalogSearchResults { + ids: number[]; + count: number; + [misc: string]: any; - // Search options and filters - available = false; - global = false; - sort: string; + constructor() { + this.ids = []; + this.count = 0; + } +} + +export class CatalogBrowseContext { + value: string; + pivot: number; + fieldClass: string; + + reset() { + this.value = ''; + this.pivot = null; + this.fieldClass = 'title'; + } + + isSearchable(): boolean { + return ( + this.value !== '' && + this.fieldClass !== '' + ); + } +} + +export class CatalogMarcContext { + tags: string[]; + subfields: string[]; + values: string[]; + + reset() { + this.tags = ['']; + this.values = ['']; + this.subfields = ['']; + } + + isSearchable() { + return ( + this.tags[0] !== '' && + this.values[0] !== '' + ); + } + +} + +export class CatalogIdentContext { + value: string; + queryType: string; + + reset() { + this.value = ''; + this.queryType = ''; + } + + isSearchable() { + return ( + this.value !== '' + && this.queryType !== '' + ); + } + +} + +export class CatalogTermContext { fieldClass: string[]; query: string[]; - identQuery: string; - identQueryType: string; // isbn, issn, etc. joinOp: string[]; matchOp: string[]; format: string; - searchOrg: IdlObject; + available = false; ccvmFilters: {[ccvmCode: string]: string[]}; facetFilters: FacetFilter[]; + copyLocations: string[]; // ID's, but treated as strings in the UI. + + // True when searching for metarecords + groupByMetarecord: boolean; + + // Filter results by records which link to this metarecord ID. + fromMetarecord: number; + + hasBrowseEntry: string; // "entryId,fieldId" + browseEntry: IdlObject; + date1: number; + date2: number; + dateOp: string; // before, after, between, is + + reset() { + this.query = ['']; + this.fieldClass = ['keyword']; + this.matchOp = ['contains']; + this.joinOp = ['']; + this.facetFilters = []; + this.copyLocations = ['']; + this.format = ''; + this.hasBrowseEntry = ''; + this.date1 = null; + this.date2 = null; + this.dateOp = 'is'; + this.fromMetarecord = null; + + // Apply empty string values for each ccvm filter + this.ccvmFilters = {}; + CATALOG_CCVM_FILTERS.forEach(code => this.ccvmFilters[code] = ['']); + } + + // True when grouping by metarecord but not when displaying the + // contents of a metarecord. + isMetarecordSearch(): boolean { + return ( + this.isSearchable() && + this.groupByMetarecord && + this.fromMetarecord === null + ); + } + + isSearchable(): boolean { + return ( + this.query[0] !== '' + || this.hasBrowseEntry !== '' + || this.fromMetarecord !== null + ); + } + + hasFacet(facet: FacetFilter): boolean { + return Boolean( + this.facetFilters.filter(f => f.equals(facet))[0] + ); + } + + removeFacet(facet: FacetFilter): void { + this.facetFilters = this.facetFilters.filter(f => !f.equals(facet)); + } + + addFacet(facet: FacetFilter): void { + if (!this.hasFacet(facet)) { + this.facetFilters.push(facet); + } + } + + toggleFacet(facet: FacetFilter): void { + if (this.hasFacet(facet)) { + this.removeFacet(facet); + } else { + this.facetFilters.push(facet); + } + } +} + + + +// Not an angular service. +// It's conceviable there could be multiple contexts. +export class CatalogSearchContext { + + // Attributes that are used across different contexts. + sort: string; isStaff: boolean; + showBasket: boolean; + searchOrg: IdlObject; + global: boolean; + + termSearch: CatalogTermContext; + marcSearch: CatalogMarcContext; + identSearch: CatalogIdentContext; + browseSearch: CatalogBrowseContext; // Result from most recent search. - result: any = {}; + result: CatalogSearchResults; searchState: CatalogSearchState = CatalogSearchState.PENDING; // List of IDs in page/offset context. - resultIds: number[] = []; + resultIds: number[]; // Utility stuff pager: Pager; @@ -62,9 +228,40 @@ export class CatalogSearchContext { constructor() { this.pager = new Pager(); + this.termSearch = new CatalogTermContext(); + this.marcSearch = new CatalogMarcContext(); + this.identSearch = new CatalogIdentContext(); + this.browseSearch = new CatalogBrowseContext(); this.reset(); } + /** + * Return search context to its default state, resetting search + * parameters and clearing any cached result data. + */ + reset(): void { + this.pager.offset = 0; + this.sort = ''; + this.showBasket = false; + this.result = new CatalogSearchResults(); + this.resultIds = []; + this.searchState = CatalogSearchState.PENDING; + this.termSearch.reset(); + this.marcSearch.reset(); + this.identSearch.reset(); + this.browseSearch.reset(); + } + + isSearchable(): boolean { + return ( + this.showBasket || + this.termSearch.isSearchable() || + this.marcSearch.isSearchable() || + this.identSearch.isSearchable() || + this.browseSearch.isSearchable() + ); + } + // List of result IDs for the current page of data. currentResultIds(): number[] { const ids = []; @@ -97,119 +294,53 @@ export class CatalogSearchContext { return null; } - /** - * Return search context to its default state, resetting search - * parameters and clearing any cached result data. - * This does not reset global filters like limit-to-available - * search-global, or search-org. - */ - reset(): void { - this.pager.offset = 0; - this.format = ''; - this.sort = ''; - this.query = ['']; - this.identQuery = null; - this.identQueryType = 'identifier|isbn'; - this.fieldClass = ['keyword']; - this.matchOp = ['contains']; - this.joinOp = ['']; - this.ccvmFilters = {}; - this.facetFilters = []; - this.result = {}; - this.resultIds = []; - this.searchState = CatalogSearchState.PENDING; - } - - isSearchable(): boolean { - - if (this.identQuery && this.identQueryType) { - return true; - } - - return this.query.length - && this.query[0] !== '' - && this.searchOrg !== null; - } - - compileSearch(): string { - let str = ''; + compileMarcSearchArgs(): any { + const searches: any = []; + const ms = this.marcSearch; + + ms.values.forEach((val, idx) => { + if (val !== '') { + searches.push({ + restrict: [{ + // "_" is the wildcard subfield for the API. + subfield: ms.subfields[idx] ? ms.subfields[idx] : '_', + tag: ms.tags[idx] + }], + term: ms.values[idx] + }); + } + }); - if (this.available) { - str += '#available'; - } + const args: any = { + searches: searches, + limit : this.pager.limit, + offset : this.pager.offset, + org_unit: this.searchOrg.id() + }; if (this.sort) { - // e.g. title, title.descending const parts = this.sort.split(/\./); - if (parts[1]) { str += ' #descending'; } - str += ' sort(' + parts[0] + ')'; - } - - if (this.identQuery && this.identQueryType) { - if (str) { str += ' '; } - str += this.identQueryType + ':' + this.identQuery; - - } else { - - // ------- - // Compile boolean sub-query components - if (str.length) { str += ' '; } - const qcount = this.query.length; - - // if we multiple boolean query components, wrap them in parens. - if (qcount > 1) { str += '('; } - this.query.forEach((q, idx) => { - str += this.compileBoolQuerySet(idx); - }); - if (qcount > 1) { str += ')'; } - // ------- - } - - if (this.format) { - str += ' format(' + this.format + ')'; - } - - if (this.global) { - str += ' depth(' + - this.org.root().ou_type().depth() + ')'; + args.sort = parts[0]; // title, author, etc. + if (parts[1]) { args.sort_dir = 'descending' }; } - str += ' site(' + this.searchOrg.shortname() + ')'; - - Object.keys(this.ccvmFilters).forEach(field => { - if (this.ccvmFilters[field][0] !== '') { - str += ' ' + field + '(' + this.ccvmFilters[field] + ')'; - } - }); - - this.facetFilters.forEach(f => { - str += ' ' + f.facetClass + '|' - + f.facetName + '[' + f.facetValue + ']'; - }); - - return str; + return args; } - stripQuotes(query: string): string { - return query.replace(/"/g, ''); - } + compileIdentSearchQuery(): string { - stripAnchors(query: string): string { - return query.replace(/[\^\$]/g, ''); + let str = ' site(' + this.searchOrg.shortname() + ')'; + return str + ' ' + + this.identSearch.queryType + ':' + this.identSearch.value; } - addQuotes(query: string): string { - if (query.match(/ /)) { - return '"' + query + '"'; - } - return query; - } compileBoolQuerySet(idx: number): string { - let query = this.query[idx]; - const joinOp = this.joinOp[idx]; - const matchOp = this.matchOp[idx]; - const fieldClass = this.fieldClass[idx]; + const ts = this.termSearch; + let query = ts.query[idx]; + const joinOp = ts.joinOp[idx]; + const matchOp = ts.matchOp[idx]; + const fieldClass = ts.fieldClass[idx]; let str = ''; if (!query) { return str; } @@ -238,29 +369,103 @@ export class CatalogSearchContext { return str + query + ')'; } - hasFacet(facet: FacetFilter): boolean { - return Boolean( - this.facetFilters.filter(f => f.equals(facet))[0] - ); + stripQuotes(query: string): string { + return query.replace(/"/g, ''); } - removeFacet(facet: FacetFilter): void { - this.facetFilters = this.facetFilters.filter(f => !f.equals(facet)); + stripAnchors(query: string): string { + return query.replace(/[\^\$]/g, ''); } - addFacet(facet: FacetFilter): void { - if (!this.hasFacet(facet)) { - this.facetFilters.push(facet); + addQuotes(query: string): string { + if (query.match(/ /)) { + return '"' + query + '"'; } + return query; } - toggleFacet(facet: FacetFilter): void { - if (this.hasFacet(facet)) { - this.removeFacet(facet); - } else { - this.facetFilters.push(facet); + compileTermSearchQuery(): string { + const ts = this.termSearch; + let str = ''; + + if (ts.available) { + str += '#available'; + } + + if (this.sort) { + // e.g. title, title.descending + const parts = this.sort.split(/\./); + if (parts[1]) { str += ' #descending'; } + str += ' sort(' + parts[0] + ')'; + } + + if (ts.date1 && ts.dateOp) { + switch (ts.dateOp) { + case 'is': + str += ` date1(${ts.date1})`; + break; + case 'before': + str += ` before(${ts.date1})`; + break; + case 'after': + str += ` after(${ts.date1})`; + break; + case 'between': + if (ts.date2) { + str += ` between(${ts.date1},${ts.date2})`; + } + } + } + + // ------- + // Compile boolean sub-query components + if (str.length) { str += ' '; } + const qcount = ts.query.length; + + // if we multiple boolean query components, wrap them in parens. + if (qcount > 1) { str += '('; } + ts.query.forEach((q, idx) => { + str += this.compileBoolQuerySet(idx); + }); + if (qcount > 1) { str += ')'; } + // ------- + + if (ts.hasBrowseEntry) { + // stored as a comma-separated string of "entryId,fieldId" + str += ` has_browse_entry(${ts.hasBrowseEntry})`; + } + + if (ts.fromMetarecord) { + str += ` from_metarecord(${ts.fromMetarecord})`; + } + + if (ts.format) { + str += ' format(' + ts.format + ')'; + } + + if (this.global) { + str += ' depth(' + + this.org.root().ou_type().depth() + ')'; + } + + if (ts.copyLocations[0] !== '') { + str += ' locations(' + ts.copyLocations + ')'; } + + str += ' site(' + this.searchOrg.shortname() + ')'; + + Object.keys(ts.ccvmFilters).forEach(field => { + if (ts.ccvmFilters[field][0] !== '') { + str += ' ' + field + '(' + ts.ccvmFilters[field] + ')'; + } + }); + + ts.facetFilters.forEach(f => { + str += ' ' + f.facetClass + '|' + + f.facetName + '[' + f.facetValue + ']'; + }); + + return str; } } - diff --git a/Open-ILS/src/eg2/src/app/share/date-select/date-select.component.html b/Open-ILS/src/eg2/src/app/share/date-select/date-select.component.html index a1558b1ce9..575bbde5c8 100644 --- a/Open-ILS/src/eg2/src/app/share/date-select/date-select.component.html +++ b/Open-ILS/src/eg2/src/app/share/date-select/date-select.component.html @@ -8,11 +8,12 @@ placeholder="yyyy-mm-dd" class="form-control" name="{{fieldName}}" + [disabled]="_disabled" [required]="required" [(ngModel)]="current" (dateSelect)="onDateSelect($event)">
- + +
+ + +
+
+
+
+ + + {{result.value}} ({{result.sources}}) + + + + {{result.value}} + +
+
+ + + See + + + Broader term + + + Narrower term + + + Related term + + + + {{heading.heading}} ({{heading.target_count}}) + +
+
+
+
+
+
+ +
+
+ + +
+
+ + + + diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/browse/results.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/browse/results.component.ts new file mode 100644 index 0000000000..8fcbce1e65 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/catalog/browse/results.component.ts @@ -0,0 +1,140 @@ +import {Component, OnInit, Input} from '@angular/core'; +import {Observable} from 'rxjs/Observable'; +import {Subscription} from 'rxjs/Subscription'; +import {map, switchMap, distinctUntilChanged} from 'rxjs/operators'; +import {ActivatedRoute, ParamMap} from '@angular/router'; +import {CatalogService} from '@eg/share/catalog/catalog.service'; +import {BibRecordService} from '@eg/share/catalog/bib-record.service'; +import {CatalogUrlService} from '@eg/share/catalog/catalog-url.service'; +import {CatalogSearchContext, CatalogSearchState} from '@eg/share/catalog/search-context'; +import {PcrudService} from '@eg/core/pcrud.service'; +import {StaffCatalogService} from '../catalog.service'; +import {IdlObject} from '@eg/core/idl.service'; + +@Component({ + selector: 'eg-catalog-browse-results', + templateUrl: 'results.component.html' +}) +export class BrowseResultsComponent implements OnInit { + + searchContext: CatalogSearchContext; + results: any[]; + + constructor( + private route: ActivatedRoute, + private pcrud: PcrudService, + private cat: CatalogService, + private bib: BibRecordService, + private catUrl: CatalogUrlService, + private staffCat: StaffCatalogService + ) {} + + ngOnInit() { + this.searchContext = this.staffCat.searchContext; + this.route.queryParamMap.subscribe((params: ParamMap) => { + this.browseByUrl(params); + }); + } + + browseByUrl(params: ParamMap): void { + this.catUrl.applyUrlParams(this.searchContext, params); + const bs = this.searchContext.browseSearch; + + // SearchContext applies a default fieldClass value of 'keyword'. + // Replace with 'title', since there is no 'keyword' browse. + if (bs.fieldClass === 'keyword') { + bs.fieldClass = 'title'; + } + + if (bs.isSearchable()) { + this.results = []; + this.cat.browse(this.searchContext) + .subscribe(result => this.addResult(result)) + } + } + + addResult(result: any) { + + result.compiledHeadings = []; + + // Avoi dupe headings per see + const seen: any = {}; + + result.sees.forEach(sees => { + if (!sees.control_set) { return; } + + sees.headings.forEach(headingStruct => { + const fieldId = Object.keys(headingStruct)[0]; + const heading = headingStruct[fieldId][0]; + + const inList = result.list_authorities.filter( + id => Number(id) === Number(heading.target))[0] + + if ( heading.target + && heading.main_entry + && heading.target_count + && !inList + && !seen[heading.target]) { + + seen[heading.target] = true; + + result.compiledHeadings.push({ + heading: heading.heading, + target: heading.target, + target_count: heading.target_count, + type: heading.type + }); + } + }); + }); + + this.results.push(result); + } + + browseIsDone(): boolean { + return this.searchContext.searchState === CatalogSearchState.COMPLETE; + } + + browseIsActive(): boolean { + return this.searchContext.searchState === CatalogSearchState.SEARCHING; + } + + browseHasResults(): boolean { + return this.browseIsDone() && this.results.length > 0; + } + + prevPage() { + const firstResult = this.results[0]; + if (firstResult) { + this.searchContext.browseSearch.pivot = firstResult.pivot_point; + this.staffCat.browse(); + } + } + + nextPage() { + const lastResult = this.results[this.results.length - 1]; + if (lastResult) { + this.searchContext.browseSearch.pivot = lastResult.pivot_point; + this.staffCat.browse(); + } + } + + searchByBrowseEntry(result) { + + // Avoid propagating browse values to term search. + this.searchContext.browseSearch.reset(); + + this.searchContext.termSearch.hasBrowseEntry = + result.browse_entry + ',' + result.fields; + this.staffCat.search(); + } + + // NOTE: to test unauthorized heading display in concerto + // browse for author = kab + newBrowseFromHeading(heading) { + this.searchContext.browseSearch.value = heading.heading; + this.staffCat.browse(); + } +} + + diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/catalog.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/catalog.component.ts index 8b2206c2f5..0e2fc98884 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/catalog.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/catalog/catalog.component.ts @@ -1,18 +1,25 @@ import {Component, OnInit} from '@angular/core'; import {StaffCatalogService} from './catalog.service'; +import {BasketService} from '@eg/share/catalog/basket.service'; @Component({ templateUrl: 'catalog.component.html' }) export class CatalogComponent implements OnInit { - constructor(private staffCat: StaffCatalogService) {} + constructor( + private basket: BasketService, + private staffCat: StaffCatalogService + ) {} ngOnInit() { // Create the search context that will be used by all of my // child components. After initial creation, the context is // reset and updated as needed to apply new search parameters. this.staffCat.createContext(); + + // Cache the basket on page load. + this.basket.getRecordIds(); } } diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/catalog.module.ts b/Open-ILS/src/eg2/src/app/staff/catalog/catalog.module.ts index 20e17a091c..2d30199441 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/catalog.module.ts +++ b/Open-ILS/src/eg2/src/app/staff/catalog/catalog.module.ts @@ -14,6 +14,13 @@ import {StaffCatalogService} from './catalog.service'; import {RecordPaginationComponent} from './record/pagination.component'; import {RecordActionsComponent} from './record/actions.component'; import {HoldingsService} from '@eg/staff/share/holdings.service'; +import {BasketActionsComponent} from './basket-actions.component'; +import {HoldComponent} from './hold/hold.component'; +import {HoldService} from '@eg/staff/share/hold.service'; +import {PartsComponent} from './record/parts.component'; +import {PartMergeDialogComponent} from './record/part-merge-dialog.component'; +import {BrowseComponent} from './browse.component'; +import {BrowseResultsComponent} from './browse/results.component'; @NgModule({ declarations: [ @@ -26,7 +33,13 @@ import {HoldingsService} from '@eg/staff/share/holdings.service'; ResultFacetsComponent, ResultPaginationComponent, RecordPaginationComponent, - RecordActionsComponent + RecordActionsComponent, + BasketActionsComponent, + HoldComponent, + PartsComponent, + PartMergeDialogComponent, + BrowseComponent, + BrowseResultsComponent ], imports: [ StaffCommonModule, @@ -35,7 +48,8 @@ import {HoldingsService} from '@eg/staff/share/holdings.service'; ], providers: [ StaffCatalogService, - HoldingsService + HoldingsService, + HoldService ] }) diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/catalog.service.ts b/Open-ILS/src/eg2/src/app/staff/catalog/catalog.service.ts index 1e50d9ba88..cf0a36c97f 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/catalog.service.ts +++ b/Open-ILS/src/eg2/src/app/staff/catalog/catalog.service.ts @@ -82,6 +82,28 @@ export class StaffCatalogService { ['/staff/catalog/search'], {queryParams: params}); } + /** + * Redirect to the browse results page while propagating the current + * browse paramters into the URL. Let the browse results component + * execute the actual browse. + */ + browse(): void { + if (!this.searchContext.browseSearch.isSearchable()) { return; } + + const params = this.catUrl.toUrlParams(this.searchContext); + + // Force a new browse every time this method is called, even if + // it's the same as the active browse. Since router navigation + // exits early when the route + params is identical, add a + // random token to the route params to force a full navigation. + // This also resolves a problem where only removing secondary+ + // versions of a query param fail to cause a route navigation. + // (E.g. going from two query= params to one). + params.ridx = '' + this.routeIndex++; + + this.router.navigate( + ['/staff/catalog/browse'], {queryParams: params}); + } } diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.html new file mode 100644 index 0000000000..1ef096c495 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.html @@ -0,0 +1,293 @@ + +

Place Hold + + ({{user.family_name()}}, {{user.first_given_name()}}) + +

+ +
+
+
+
+
+
+ + +
+
+
+
+ +
+ +
+
+
+
+
+
+
+ + +
+
+
{{requestor.usrname()}}
+
+
+
+ +
+
+ +
+
+
+
+
+ + +
+
+
+ + +
+
+
+
+
+
+

Notifications

+
+
    +
  • +
    +
    + + +
    +
    +
    +
    +
    + Email Address +
    + +
    +
    +
  • +
  • +
    +
    + + +
    +
    +
    +
    +
    + Phone Number +
    + +
    +
    +
  • +
  • +
    +
    + + +
    +
    +
    +
    +
    + SMS Number +
    + +
    +
    +
  • +
  • +
    + +
    +
    + + +
    +
  • +
+
+
+
+
+
+ +
+
+
+ +

+ +
+
Placing + METARECORD + TITLE + VOLUME + FORCE COPY + COPY + RECALL + ISSUANCE + PARTS + hold on record(s)
+
+ + + ANY + + + + +
+ +
+
Format
+
Title
+
Author
+
Call Number
+
Barcode
+
Holds Status
+
Override
+
+
+
+
+
+ + {{iconFormatLabel(code)}} + +
+ + +
{{ctx.holdMeta.bibSummary.display.author}}
+
+ + {{ctx.holdMeta.volume.label()}} + +
+
+ + {{ctx.holdMeta.copy.barcode()}} + +
+
+ +
Hold Pending
+
+ +
Hold Processing...
+
+ + +
Hold Succeeded
+
+ +
+ {{ctx.lastRequest.result.evt.textcode}} +
+
+
+
+
+ + + +
+
+ +
+
+
+ +
+ + {{iconFormatLabel(ccvm.code())}} + +
+
+
+
+
+
+
+ +
+ + +
+
+
+
+
+
+
+ + diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.ts new file mode 100644 index 0000000000..a0a0dc24f4 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.ts @@ -0,0 +1,401 @@ +import {Component, OnInit, Input, ViewChild, Renderer2} from '@angular/core'; +import {Observable} from 'rxjs/Observable'; +import {Router, ActivatedRoute, ParamMap} from '@angular/router'; +import {tap} from 'rxjs/operators/tap'; +import {EventService} from '@eg/core/event.service'; +import {NetService} from '@eg/core/net.service'; +import {AuthService} from '@eg/core/auth.service'; +import {PcrudService} from '@eg/core/pcrud.service'; +import {PermService} from '@eg/core/perm.service'; +import {IdlObject} from '@eg/core/idl.service'; +import {OrgService} from '@eg/core/org.service'; +import {BibRecordService, BibRecordSummary} from '@eg/share/catalog/bib-record.service'; +import {CatalogSearchContext, CatalogSearchState} from '@eg/share/catalog/search-context'; +import {CatalogService} from '@eg/share/catalog/catalog.service'; +import {StaffCatalogService} from '../catalog.service'; +import {HoldService, HoldRequest, HoldRequestTarget} + from '@eg/staff/share/hold.service'; +import {ComboboxEntry} from '@eg/share/combobox/combobox.component'; + +class HoldContext { + holdMeta: HoldRequestTarget; + holdTarget: number; + lastRequest: HoldRequest; + canOverride?: boolean; + processing: boolean; + selectedFormats: any; + + constructor(target: number) { + this.holdTarget = target; + this.processing = false; + this.selectedFormats = { + // code => selected-boolean + formats: {}, + langs: {} + } + } +} + +@Component({ + templateUrl: 'hold.component.html' +}) +export class HoldComponent implements OnInit { + + holdType: string; + holdTargets: number[]; + user: IdlObject; // + userBarcode: string; + requestor: IdlObject; + holdFor: string; + pickupLib: number; + notifyEmail: boolean; + notifyPhone: boolean; + phoneValue: string; + notifySms: boolean; + smsValue: string; + smsCarrier: string; + suspend: boolean; + activeDate: string; + + holdContexts: HoldContext[]; + recordSummaries: BibRecordSummary[]; + + currentUserBarcode: string; + smsCarriers: ComboboxEntry[]; + + smsEnabled: boolean; + placeHoldsClicked: boolean; + + constructor( + private router: Router, + private route: ActivatedRoute, + private renderer: Renderer2, + private evt: EventService, + private net: NetService, + private org: OrgService, + private auth: AuthService, + private pcrud: PcrudService, + private bib: BibRecordService, + private cat: CatalogService, + private staffCat: StaffCatalogService, + private holds: HoldService, + private perm: PermService + ) { + this.holdContexts = []; + this.smsCarriers = []; + } + + ngOnInit() { + + this.holdType = this.route.snapshot.params['type']; + this.holdTargets = this.route.snapshot.queryParams['target']; + + if (!Array.isArray(this.holdTargets)) { + this.holdTargets = [this.holdTargets]; + } + + this.holdTargets = this.holdTargets.map(t => Number(t)); + this.holdFor = 'patron'; + this.requestor = this.auth.user(); + this.pickupLib = this.auth.user().ws_ou(); + + this.holdContexts = this.holdTargets.map(target => { + const ctx = new HoldContext(target); + return ctx; + }); + + this.getTargetMeta(); + + this.org.settings('sms.enable').then(sets => { + this.smsEnabled = sets['sms.enable'] + if (!this.smsEnabled) { return; } + + this.pcrud.search('csc', {active: 't'}, {order_by: {csc: 'name'}}) + .subscribe(carrier => { + this.smsCarriers.push({ + id: carrier.id(), + label: carrier.name() + }) + }); + }); + + setTimeout(() => // Focus barcode input + this.renderer.selectRootElement('#patron-barcode').focus()); + } + + // Load the bib, call number, copy, etc. data associated with each target. + getTargetMeta() { + this.holds.getHoldTargetMeta(this.holdType, this.holdTargets) + .subscribe(meta => { + this.holdContexts.filter(ctx => ctx.holdTarget === meta.target) + .forEach(ctx => { + ctx.holdMeta = meta; + this.mrFiltersToSelectors(ctx); + }); + }); + } + + // By default, all metarecord filters options are enabled. + mrFiltersToSelectors(ctx: HoldContext) { + if (this.holdType !== 'M') { return; } + + const meta = ctx.holdMeta; + if (meta.metarecord_filters) { + if (meta.metarecord_filters.formats) { + meta.metarecord_filters.formats.forEach( + ccvm => ctx.selectedFormats.formats[ccvm.code()] = true); + } + if (meta.metarecord_filters.langs) { + meta.metarecord_filters.langs.forEach( + ccvm => ctx.selectedFormats.langs[ccvm.code()] = true); + } + } + } + + // Map the selected metarecord filters optoins to a JSON-encoded + // list of attr filters as required by the API. + // Compiles a blob of + // {target: JSON({"0": [{_attr: ctype, _val: code}, ...], "1": [...]})} + // TODO: this should live in the hold service, not in the UI code. + mrSelectorsToFilters(ctx: HoldContext): {[target: number]: string} { + + const meta = ctx.holdMeta; + const slf = ctx.selectedFormats; + const result: any = {}; + + const formats = Object.keys(slf.formats) + .filter(code => Boolean(slf.formats[code])); // user-selected + + const langs = Object.keys(slf.langs) + .filter(code => Boolean(slf.langs[code])); // user-selected + + const compiled: any = {}; + + if (formats.length > 0) { + compiled['0'] = []; + formats.forEach(code => { + const ccvm = meta.metarecord_filters.formats.filter( + format => format.code() === code)[0]; + compiled['0'].push({ + _attr: ccvm.ctype(), + _val: ccvm.code() + }); + }); + } + + if (langs.length > 0) { + compiled['1'] = []; + langs.forEach(code => { + const ccvm = meta.metarecord_filters.langs.filter( + format => format.code() === code)[0]; + compiled['1'].push({ + _attr: ccvm.ctype(), + _val: ccvm.code() + }); + }); + } + + if (Object.keys(compiled).length > 0) { + const result = {}; + result[ctx.holdTarget] = JSON.stringify(compiled); + return result; + } + + return null; + } + + holdForChanged() { + this.user = null; + + if (this.holdFor === 'patron') { + if (this.userBarcode) { + this.userBarcodeChanged(); + } + } else { + // To bypass the dupe check. + this.currentUserBarcode = '_' + this.requestor.id(); + this.getUser(this.requestor.id()); + } + } + + activeDateSelected(dateStr: string) { + this.activeDate = dateStr; + } + + userBarcodeChanged() { + + // Avoid simultaneous or duplicate lookups + if (this.userBarcode === this.currentUserBarcode) { + return; + } + + this.resetForm(); + + if (!this.userBarcode) { + this.user = null; + return; + } + + this.user = null; + this.currentUserBarcode = this.userBarcode; + + this.net.request( + 'open-ils.actor', + 'open-ils.actor.get_barcodes', + this.auth.token(), this.auth.user().ws_ou(), + 'actor', this.userBarcode + ).subscribe(barcodes => { + + // Use the first successful barcode response. + // TODO: What happens when there are multiple responses? + // Use for-loop for early exit since we have async + // action within the loop. + for (let i = 0; i < barcodes.length; i++) { + const bc = barcodes[i]; + if (!this.evt.parse(bc)) { + this.getUser(bc.id); + break; + } + } + }); + } + + resetForm() { + this.notifyEmail = true; + this.notifyPhone = true; + this.phoneValue = ''; + this.pickupLib = this.requestor.ws_ou(); + } + + getUser(id: number) { + this.pcrud.retrieve('au', id, {flesh: 1, flesh_fields: {au: ['settings']}}) + .subscribe(user => { + this.user = user; + this.applyUserSettings(); + }); + } + + applyUserSettings() { + if (!this.user || !this.user.settings()) { return; } + + // Start with defaults. + this.phoneValue = this.user.day_phone() || this.user.evening_phone(); + + // Default to work org if placing holds for staff. + if (this.user.id() !== this.requestor.id()) { + this.pickupLib = this.user.home_ou(); + } + + this.user.settings().forEach(setting => { + const name = setting.name(); + const value = setting.value(); + + if (value === '' || value === null) { return; } + + switch(name) { + case 'opac.hold_notify': + this.notifyPhone = Boolean(value.match(/phone/)); + this.notifyEmail = Boolean(value.match(/email/)); + this.notifySms = Boolean(value.match(/sms/)); + break; + + case 'opac.default_pickup_location': + this.pickupLib = value; + break; + } + }); + + if (!this.user.email()) { + this.notifyEmail = false; + } + + if (!this.phoneValue) { + this.notifyPhone = false; + } + } + + // Attempt hold placement on all targets + placeHolds(idx?: number) { + if (!idx) { idx = 0; } + if (!this.holdTargets[idx]) { return; } + this.placeHoldsClicked = true; + + const target = this.holdTargets[idx]; + const ctx = this.holdContexts.filter( + ctx => ctx.holdTarget === target)[0]; + + this.placeOneHold(ctx).then(() => this.placeHolds(idx + 1)); + } + + placeOneHold(ctx: HoldContext, override?: boolean): Promise { + + ctx.processing = true; + const selectedFormats = this.mrSelectorsToFilters(ctx); + + return this.holds.placeHold({ + holdTarget: ctx.holdTarget, + holdType: this.holdType, + recipient: this.user.id(), + requestor: this.requestor.id(), + pickupLib: this.pickupLib, + override: override, + notifyEmail: this.notifyEmail, // bool + notifyPhone: this.notifyPhone ? this.phoneValue : null, + notifySms: this.notifySms ? this.smsValue : null, + smsCarrier: this.notifySms ? this.smsCarrier : null, + thawDate: this.suspend ? this.activeDate : null, + frozen: this.suspend, + holdableFormats: selectedFormats + + }).toPromise().then( + request => { + console.log('hold returned: ', request); + ctx.lastRequest = request; + ctx.processing = false; + + // If this request failed and was not already an override, + // see of this user has permission to override. + if (!request.override && + !request.result.success && request.result.evt) { + + const txtcode = request.result.evt.textcode; + const perm = txtcode + '.override'; + + return this.perm.hasWorkPermHere(perm).then( + permResult => ctx.canOverride = permResult[perm]); + } + }, + error => { + ctx.processing = false; + console.error(error); + } + ); + } + + override(ctx: HoldContext) { + this.placeOneHold(ctx, true); + } + + canOverride(ctx: HoldContext): boolean { + return ctx.lastRequest && + !ctx.lastRequest.result.success && ctx.canOverride; + } + + iconFormatLabel(code: string): string { + return this.cat.iconFormatLabel(code); + } + + // TODO: for now, only show meta filters for meta holds. + // Add an "advanced holds" option to display these for T hold. + hasMetaFilters(ctx: HoldContext): boolean { + return ( + this.holdType === 'M' && // TODO + ctx.holdMeta.metarecord_filters && ( + ctx.holdMeta.metarecord_filters.langs.length > 1 || + ctx.holdMeta.metarecord_filters.formats.length > 1 + ) + ); + } +} + + diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/actions.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/record/actions.component.html index 6fd945414c..1a76b282f7 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/record/actions.component.html +++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/actions.component.html @@ -15,8 +15,12 @@
+ + + +
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/copies.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/record/copies.component.html index e7d82491b5..e60fb24847 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/record/copies.component.html +++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/copies.component.html @@ -20,6 +20,7 @@
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/part-merge-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/record/part-merge-dialog.component.html new file mode 100644 index 0000000000..ef702ebfea --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/part-merge-dialog.component.html @@ -0,0 +1,28 @@ + + + + + diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/part-merge-dialog.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/record/part-merge-dialog.component.ts new file mode 100644 index 0000000000..27c4d3045f --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/part-merge-dialog.component.ts @@ -0,0 +1,70 @@ +import {Component, Input, ViewChild, TemplateRef} from '@angular/core'; +import {DialogComponent} from '@eg/share/dialog/dialog.component'; +import {IdlService, IdlObject} from '@eg/core/idl.service'; +import {PcrudService} from '@eg/core/pcrud.service'; +import {NgbModal, NgbModalOptions} from '@ng-bootstrap/ng-bootstrap'; + +@Component({ + selector: 'eg-catalog-part-merge-dialog', + templateUrl: './part-merge-dialog.component.html' +}) + +/** + * Ask the user which part is the lead part then merge others parts in. + */ +export class PartMergeDialogComponent extends DialogComponent { + + // What parts are we merging + parts: IdlObject[]; + copyPartMaps: IdlObject[]; + leadPart: number; + + constructor( + private idl: IdlService, + private pcrud: PcrudService, + private modal: NgbModal) { + super(modal); + } + + mergeParts() { + console.log('Merging parts into lead part ', this.leadPart); + + if (!this.leadPart) { return; } + + this.leadPart = Number(this.leadPart); + + // 1. Migrate copy maps to the lead part. + const partIds = this.parts + .filter(p => Number(p.id()) !== this.leadPart) + .map(p => Number(p.id())); + + const maps = []; + this.pcrud.search('acpm', {part: partIds}) + .subscribe( + map => { + map.part(this.leadPart); + map.ischanged(true); + maps.push(map); + }, + err => {}, + () => { + // 2. Delete the now-empty subordinate parts. Note the + // delete must come after the part map changes are committed. + if (maps.length > 0) { + this.pcrud.autoApply(maps) + .toPromise().then(() => this.deleteParts()); + } else { + this.deleteParts(); + } + } + ); + } + + deleteParts() { + const parts = this.parts.filter(p => Number(p.id()) !== this.leadPart); + parts.forEach(p => p.isdeleted(true)); + this.pcrud.autoApply(parts).toPromise().then(res => this.close(res)); + } +} + + diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/parts.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/record/parts.component.html new file mode 100644 index 0000000000..f5693c18e5 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/parts.component.html @@ -0,0 +1,22 @@ + + + +
+ + + + + + + + + + + + + +
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/parts.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/record/parts.component.ts new file mode 100644 index 0000000000..e74fc12d42 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/parts.component.ts @@ -0,0 +1,123 @@ +import {Component, OnInit, Input, ViewChild} from '@angular/core'; +import {IdlService, IdlObject} from '@eg/core/idl.service'; +import {PcrudService} from '@eg/core/pcrud.service'; +import {Pager} from '@eg/share/util/pager'; +import {OrgService} from '@eg/core/org.service'; +import {PermService} from '@eg/core/perm.service'; +import {GridDataSource} from '@eg/share/grid/grid'; +import {GridComponent} from '@eg/share/grid/grid.component'; +import {FmRecordEditorComponent} from '@eg/share/fm-editor/fm-editor.component'; +import {PartMergeDialogComponent} from './part-merge-dialog.component'; + +@Component({ + selector: 'eg-catalog-record-parts', + templateUrl: 'parts.component.html' +}) +export class PartsComponent implements OnInit { + + recId: number; + gridDataSource: GridDataSource; + initDone: boolean; + @ViewChild('partsGrid') partsGrid: GridComponent; + @ViewChild('editDialog') editDialog: FmRecordEditorComponent; + @ViewChild('mergeDialog') mergeDialog: PartMergeDialogComponent; + + canCreate: boolean; + canDelete: boolean; + createNew: () => void; + deleteSelected: (rows: IdlObject[]) => void; + mergeSelected: (rows: IdlObject[]) => void; + permissions: {[name: string]: boolean}; + + @Input() set recordId(id: number) { + this.recId = id; + // Only force new data collection when recordId() + // is invoked after ngInit() has already run. + if (this.initDone) { + this.partsGrid.reload(); + } + } + + constructor( + private idl: IdlService, + private org: OrgService, + private pcrud: PcrudService, + private perm: PermService + ) { + this.permissions = {}; + this.gridDataSource = new GridDataSource(); + } + + ngOnInit() { + this.initDone = true; + + // Load edit perms + this.perm.hasWorkPermHere([ + 'CREATE_MONOGRAPH_PART', + 'UPDATE_MONOGRAPH_PART', + 'DELETE_MONOGRAPH_PART' + ]).then(perms => this.permissions = perms); + + this.gridDataSource.getRows = (pager: Pager, sort: any[]) => { + const orderBy: any = {}; + + if (sort.length) { // Sort provided by grid. + orderBy.bmp = sort[0].name + ' ' + sort[0].dir; + } else { + orderBy.bmp = 'label'; + } + + const searchOps = { + offset: pager.offset, + limit: pager.limit, + order_by: orderBy + }; + + return this.pcrud.search('bmp', + {record: this.recId, deleted: 'f'}, searchOps); + }; + + this.partsGrid.onRowActivate.subscribe( + (part: IdlObject) => { + this.editDialog.mode = 'update'; + this.editDialog.recId = part.id(); + this.editDialog.open().then( + ok => this.partsGrid.reload(), + err => {} + ); + } + ); + + this.createNew = () => { + + const part = this.idl.create('bmp'); + part.record(this.recId); + this.editDialog.record = part; + + this.editDialog.mode = 'create'; + this.editDialog.open().then( + ok => this.partsGrid.reload(), + err => {} + ); + }; + + this.deleteSelected = (parts: IdlObject[]) => { + parts.forEach(part => part.isdeleted(true)); + this.pcrud.autoApply(parts).subscribe( + val => console.debug('deleted: ' + val), + err => {}, + () => this.partsGrid.reload() + ); + }; + + this.mergeSelected = (parts: IdlObject[]) => { + if (parts.length < 2) { return; } + this.mergeDialog.parts = parts; + this.mergeDialog.open().then( + ok => this.partsGrid.reload(), + err => console.debug('Dialog dismissed') + ); + }; + } +} + diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html index 4c74316c4e..d1385230f3 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html +++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html @@ -19,17 +19,39 @@
+
+
+ +
+
- + - + + + + + + + + + + + + + + + +
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.ts index b217e5c9b6..0414a076b4 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.ts @@ -8,6 +8,14 @@ import {CatalogService} from '@eg/share/catalog/catalog.service'; import {BibRecordService, BibRecordSummary} from '@eg/share/catalog/bib-record.service'; import {StaffCatalogService} from '../catalog.service'; import {BibSummaryComponent} from '@eg/staff/share/bib-summary/bib-summary.component'; +import {StoreService} from '@eg/core/store.service'; + +const ANGJS_TABS: any = { + marc_edit: true, + holds: true, + holdings: true, + conjoined: true +}; @Component({ selector: 'eg-catalog-record', @@ -20,6 +28,7 @@ export class RecordComponent implements OnInit { summary: BibRecordSummary; searchContext: CatalogSearchContext; @ViewChild('recordTabs') recordTabs: NgbTabset; + defaultTab: string; // eg.cat.default_record_tab constructor( private router: Router, @@ -27,21 +36,49 @@ export class RecordComponent implements OnInit { private pcrud: PcrudService, private bib: BibRecordService, private cat: CatalogService, - private staffCat: StaffCatalogService + private staffCat: StaffCatalogService, + private store: StoreService ) {} ngOnInit() { this.searchContext = this.staffCat.searchContext; + this.defaultTab = + this.store.getLocalItem('eg.cat.default_record_tab') + || 'catalog'; + + // TODO: Implement default tab handling for tabs that require + // and AngJS redirect. + // Watch for URL record ID changes + // This includes the initial route. + // When applying the default configured tab, no navigation occurs + // to apply the tab name to the URL, it displays as the default. + // This is done so no intermediate redirect is required, which + // messes with browser back/forward navigation. this.route.paramMap.subscribe((params: ParamMap) => { - this.recordTab = params.get('tab') || 'copy_table'; + this.recordTab = params.get('tab'); this.recordId = +params.get('id'); this.searchContext = this.staffCat.searchContext; + + if (!this.recordTab) { + this.recordTab = this.defaultTab || 'catalog'; + // On initial load, if the default tab is set to one of + // the AngularJS tabs, redirect the user there. + if (this.recordTab in ANGJS_TABS) { + return this.routeToTab(); + } + } + this.loadRecord(); }); } + setDefaultTab() { + this.defaultTab = this.recordTab; + this.store.setLocalItem('eg.cat.default_record_tab', this.recordTab); + } + // Changing a tab in the UI means changing the route. // Changing the route ultimately results in changing the tab. onTabChange(evt: NgbTabChangeEvent) { @@ -50,11 +87,23 @@ export class RecordComponent implements OnInit { // prevent tab changing until after route navigation evt.preventDefault(); - let url = '/staff/catalog/record/' + this.recordId; - if (this.recordTab !== 'copy_table') { - url += '/' + this.recordTab; + this.routeToTab(); + } + + routeToTab() { + + // Route to the AngularJS catalog tab + if (this.recordTab in ANGJS_TABS) { + const angjsBase = '/eg/staff/cat/catalog/record'; + + window.location.href = + `${angjsBase}/${this.recordId}/${this.recordTab}`; + return; } + const url = + `/staff/catalog/record/${this.recordId}/${this.recordTab}`; + // Retain search parameters this.router.navigate([url], {queryParamsHandling: 'merge'}); } diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/resolver.service.ts b/Open-ILS/src/eg2/src/app/staff/catalog/resolver.service.ts index 8761c58924..02b44c90df 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/resolver.service.ts +++ b/Open-ILS/src/eg2/src/app/staff/catalog/resolver.service.ts @@ -37,22 +37,16 @@ export class CatalogResolver implements Resolve> { } fetchSettings(): Promise { - const promises = []; - promises.push( - this.store.getItem('eg.search.search_lib').then( - id => this.staffCat.defaultSearchOrg = this.org.get(id) - ) - ); - - promises.push( - this.store.getItem('eg.search.pref_lib').then( - id => this.staffCat.prefOrg = this.org.get(id) - ) - ); - - return Promise.all(promises); + return this.store.getItemBatch([ + 'eg.search.search_lib', + 'eg.search.pref_lib' + ]).then(settings => { + this.staffCat.defaultSearchOrg = + this.org.get(settings['eg.search.search_lib']); + this.staffCat.prefOrg = + this.org.get(settings['eg.search.pref_lib']); + }) } - } diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/result/facets.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/result/facets.component.ts index 44583b8780..f16215a65f 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/result/facets.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/catalog/result/facets.component.ts @@ -35,11 +35,11 @@ export class ResultFacetsComponent implements OnInit { } facetIsApplied(cls: string, name: string, value: string): boolean { - return this.searchContext.hasFacet(new FacetFilter(cls, name, value)); + return this.searchContext.termSearch.hasFacet(new FacetFilter(cls, name, value)); } applyFacet(cls: string, name: string, value: string): void { - this.searchContext.toggleFacet(new FacetFilter(cls, name, value)); + this.searchContext.termSearch.toggleFacet(new FacetFilter(cls, name, value)); this.searchContext.pager.offset = 0; this.staffCat.search(); } diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.css b/Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.css new file mode 100644 index 0000000000..3077d9ac93 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.css @@ -0,0 +1,15 @@ + +/** + * Force the jacket image column to consume a consistent amount of + * horizontal space, while allowing some room for the browser to + * render the correct aspect ratio. + */ +.record-jacket-div { + width: 68px; +} + +.record-jacket-div img { + height: 100%; + max-height:80px; + max-width: 54px; +} diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.html index 54ad3db0ee..90f066b1e9 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.html +++ b/Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.html @@ -9,44 +9,57 @@
-
- - - -
-
-
-
- - - #{{index + 1 + searchContext.pager.offset}} - - - {{summary.display.title || ' '}} - -
+ +
+ + + -
-
- - - {{summary.display.author || ' '}} - + -
-
- - - - {{iconFormatLabel(summary.attributes.icon_format[0])}} - - {{summary.display.edition}} - {{summary.display.pubdate}} + +
+
+ + + + + {{iconFormatLabel(icon)}} + + + + {{summary.display.edition}} + {{summary.display.pubdate}} +
@@ -114,6 +127,7 @@ Place Hold +
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.ts index bfcfd4572e..7510b3d108 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.ts @@ -1,4 +1,5 @@ -import {Component, OnInit, Input} from '@angular/core'; +import {Component, OnInit, OnDestroy, Input} from '@angular/core'; +import {Subscription} from 'rxjs/Subscription'; import {Router} from '@angular/router'; import {OrgService} from '@eg/core/org.service'; import {NetService} from '@eg/core/net.service'; @@ -7,16 +8,20 @@ import {BibRecordService, BibRecordSummary} from '@eg/share/catalog/bib-record.s import {CatalogSearchContext} from '@eg/share/catalog/search-context'; import {CatalogUrlService} from '@eg/share/catalog/catalog-url.service'; import {StaffCatalogService} from '../catalog.service'; +import {BasketService} from '@eg/share/catalog/basket.service'; @Component({ selector: 'eg-catalog-result-record', - templateUrl: 'record.component.html' + templateUrl: 'record.component.html', + styleUrls: ['record.component.css'] }) -export class ResultRecordComponent implements OnInit { +export class ResultRecordComponent implements OnInit, OnDestroy { @Input() index: number; // 0-index display row @Input() summary: BibRecordSummary; searchContext: CatalogSearchContext; + isRecordSelected: boolean; + basketSub: Subscription; constructor( private router: Router, @@ -25,12 +30,23 @@ export class ResultRecordComponent implements OnInit { private bib: BibRecordService, private cat: CatalogService, private catUrl: CatalogUrlService, - private staffCat: StaffCatalogService + private staffCat: StaffCatalogService, + private basket: BasketService ) {} ngOnInit() { this.searchContext = this.staffCat.searchContext; this.summary.getHoldCount(); + this.isRecordSelected = this.basket.hasRecordId(this.summary.id); + + // Watch for basket changes caused by other components + this.basketSub = this.basket.onChange.subscribe(() => { + this.isRecordSelected = this.basket.hasRecordId(this.summary.id); + }); + } + + ngOnDestroy() { + this.basketSub.unsubscribe(); } orgName(orgId: number): string { @@ -38,17 +54,21 @@ export class ResultRecordComponent implements OnInit { } iconFormatLabel(code: string): string { - if (this.cat.ccvmMap) { - const ccvm = this.cat.ccvmMap.icon_format.filter( - format => format.code() === code)[0]; - if (ccvm) { - return ccvm.search_label(); - } - } + return this.cat.iconFormatLabel(code); } placeHold(): void { - alert('Placing hold on bib ' + this.summary.id); + let holdType = 'T'; + let holdTarget = this.summary.id; + + const ts = this.searchContext.termSearch; + if (ts.isMetarecordSearch()) { + holdType = 'M'; + holdTarget = this.summary.metabibId; + } + + this.router.navigate([`/staff/catalog/hold/${holdType}`], + {queryParams: {target: holdTarget}}); } addToList(): void { @@ -57,21 +77,36 @@ export class ResultRecordComponent implements OnInit { searchAuthor(summary: any) { this.searchContext.reset(); - this.searchContext.fieldClass = ['author']; - this.searchContext.query = [summary.display.author]; + this.searchContext.termSearch.fieldClass = ['author']; + this.searchContext.termSearch.query = [summary.display.author]; this.staffCat.search(); } /** * Propagate the search params along when navigating to each record. */ - navigatToRecord(id: number) { + navigateToRecord(summary: BibRecordSummary) { const params = this.catUrl.toUrlParams(this.searchContext); + // Jump to metarecord constituent records page when a + // MR has more than 1 constituents. + if (summary.metabibId && summary.metabibRecords.length > 1) { + this.searchContext.termSearch.fromMetarecord = summary.metabibId; + this.staffCat.search(); + return; + } + this.router.navigate( - ['/staff/catalog/record/' + id], {queryParams: params}); + ['/staff/catalog/record/' + summary.id], {queryParams: params}); } + toggleBasketEntry() { + if (this.isRecordSelected) { + return this.basket.addRecordIds([this.summary.id]); + } else { + return this.basket.removeRecordIds([this.summary.id]); + } + } } diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/result/results.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/result/results.component.html index ee9ca8ddf2..902e50baa4 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/result/results.component.html +++ b/Open-ILS/src/eg2/src/app/staff/catalog/result/results.component.html @@ -1,30 +1,75 @@ -
+ +
+
+
+
+ Searching.. +
+
+
+
+ + +
+
+
+
+ No Maching Items Were Found +
+
+
+
+ + +
-
-

Search Results ({{searchContext.result.count}})

+
+ +

Results for browse "{{searchContext.termSearch.browseEntry.value()}}"

+
+ +

Search Results ({{searchContext.result.count}})

+
+
+
+

Basket View

-
-
+
+ +
+
- +
-
-
- -
-
-
-
-
- - +
+
+
+ +
+
+
+
+
+ + +
-
-
-
-
+
+
+
+
+ diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/result/results.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/result/results.component.ts index 121888d3ba..6a03b9bdd2 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/result/results.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/catalog/result/results.component.ts @@ -1,5 +1,5 @@ -import {Component, OnInit, Input} from '@angular/core'; -import {Observable} from 'rxjs'; +import {Component, OnInit, OnDestroy, Input} from '@angular/core'; +import {Observable, Subscription} from 'rxjs'; import {map, switchMap, distinctUntilChanged} from 'rxjs/operators'; import {ActivatedRoute, ParamMap} from '@angular/router'; import {CatalogService} from '@eg/share/catalog/catalog.service'; @@ -9,12 +9,13 @@ import {CatalogSearchContext, CatalogSearchState} from '@eg/share/catalog/search import {PcrudService} from '@eg/core/pcrud.service'; import {StaffCatalogService} from '../catalog.service'; import {IdlObject} from '@eg/core/idl.service'; +import {BasketService} from '@eg/share/catalog/basket.service'; @Component({ selector: 'eg-catalog-results', templateUrl: 'results.component.html' }) -export class ResultsComponent implements OnInit { +export class ResultsComponent implements OnInit, OnDestroy { searchContext: CatalogSearchContext; @@ -22,13 +23,20 @@ export class ResultsComponent implements OnInit { // reasonably small set of data w/ lots of repitition. userCache: {[id: number]: IdlObject} = {}; + allRecsSelected: boolean; + + searchSub: Subscription; + routeSub: Subscription; + basketSub: Subscription; + constructor( private route: ActivatedRoute, private pcrud: PcrudService, private cat: CatalogService, private bib: BibRecordService, private catUrl: CatalogUrlService, - private staffCat: StaffCatalogService + private staffCat: StaffCatalogService, + private basket: BasketService ) {} ngOnInit() { @@ -41,7 +49,8 @@ export class ResultsComponent implements OnInit { // searches. // // This will also fire on page load. - this.route.queryParamMap.subscribe((params: ParamMap) => { + this.routeSub = + this.route.queryParamMap.subscribe((params: ParamMap) => { // TODO: Angular docs suggest using switchMap(), but // it's not firing for some reason. Also, could avoid @@ -51,8 +60,36 @@ export class ResultsComponent implements OnInit { // .map() is not firing either. I'm missing something. this.searchByUrl(params); }); + + // After each completed search, update the record selector. + this.searchSub = this.cat.onSearchComplete.subscribe( + ctx => this.applyRecordSelection()); + + // Watch for basket changes applied by other components. + this.basketSub = this.basket.onChange.subscribe( + () => this.applyRecordSelection()); + } + + ngOnDestroy() { + this.routeSub.unsubscribe(); + this.searchSub.unsubscribe(); + this.basketSub.unsubscribe(); + } + + // Apply the select-all checkbox when all visible records + // are selected. + applyRecordSelection() { + const ids = this.searchContext.currentResultIds(); + let allChecked = true; + ids.forEach(id => { + if (!this.basket.hasRecordId(id)) { + allChecked = false; + } + }); + this.allRecsSelected = allChecked; } + // Pull values from the URL and run the requested search. searchByUrl(params: ParamMap): void { this.catUrl.applyUrlParams(this.searchContext, params); @@ -67,6 +104,25 @@ export class ResultsComponent implements OnInit { } } + // Records file into place randomly as the server returns data. + // To reduce page display shuffling, avoid showing the list of + // records until the first few are ready to render. + shouldStartRendering(): boolean { + + if (this.searchHasResults()) { + const pageCount = this.searchContext.currentResultIds().length; + switch (pageCount) { + case 1: + return this.searchContext.result.records[0]; + default: + return this.searchContext.result.records[0] + && this.searchContext.result.records[1]; + } + } + + return false; + } + fleshSearchResults(): void { const records = this.searchContext.result.records; if (!records || records.length === 0) { return; } @@ -79,6 +135,23 @@ export class ResultsComponent implements OnInit { return this.searchContext.searchState === CatalogSearchState.COMPLETE; } + searchIsActive(): boolean { + return this.searchContext.searchState === CatalogSearchState.SEARCHING; + } + + searchHasResults(): boolean { + return this.searchIsDone() && this.searchContext.result.count > 0; + } + + toggleAllRecsSelected() { + const ids = this.searchContext.currentResultIds(); + + if (this.allRecsSelected) { + this.basket.addRecordIds(ids); + } else { + this.basket.removeRecordIds(ids); + } + } } diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/routing.module.ts b/Open-ILS/src/eg2/src/app/staff/catalog/routing.module.ts index 0e3c96fd00..8bcef4f30c 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/routing.module.ts +++ b/Open-ILS/src/eg2/src/app/staff/catalog/routing.module.ts @@ -4,6 +4,8 @@ import {CatalogComponent} from './catalog.component'; import {ResultsComponent} from './result/results.component'; import {RecordComponent} from './record/record.component'; import {CatalogResolver} from './resolver.service'; +import {HoldComponent} from './hold/hold.component'; +import {BrowseComponent} from './browse.component'; const routes: Routes = [{ path: '', @@ -15,10 +17,17 @@ const routes: Routes = [{ }, { path: 'record/:id', component: RecordComponent + }, { + path: 'hold/:type', + component: HoldComponent }, { path: 'record/:id/:tab', component: RecordComponent - }] + }]}, { + // Browse is a top-level UI + path: 'browse', + component: BrowseComponent, + resolve: {catResolver : CatalogResolver}, }]; @NgModule({ diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.css b/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.css index 6201dff923..c7d59d19d3 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.css +++ b/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.css @@ -12,5 +12,17 @@ select.form-control:not([size]):not([multiple]) { } #staffcat-search-form { - border-bottom: 2px dashed rgba(0,0,0,.225); + border-radius: 0px 0px 7px 7px; + background-color: rgba(243, 127, 65, .1); + box-shadow: 3px 3px 2px rgba(185, 65, 0, .2); +} + +#staffcat-search-form .tab-content { + border: 3px; +} + +.tab-content { + padding: 5px; + margin-top: 25px; + font-weight: bold; } diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.html index da54f4a9b9..ee4abc522c 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.html +++ b/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.html @@ -1,243 +1,333 @@ -
-
-
-
-
- -
-
- -
-
-
- -
-
- -
-
-
-
- +
+
+ + + +
+
+
+ +
+
+ +
+
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+ + + +
-
- +
+
+ +
+ +
+
+ +
+
+ +
+
-
-
-
- - -
-
-
-
- - - - -
-
-
- -
-
-
- - -
-
- -
-
-
- -
-
-
-
- -
-
-
- -
-
-
-
-
-
- Searching.. +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+
+ + + + +
+
+
+ + + + +
+
+
+ + + + +
+
+
+
+
+ + +
+
+
+ + + + + + + + +
+
+
+
+
+ + +
+
+ + + + +
+
+
+
+ +
+
+
+
+
+
+
+ + + + +
-
-
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
-
-
- -
-
- -
-
- -
-
- Copy location filter goes here... +
+
+ +
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.ts index 52a26f2b2b..711ff90ac7 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.ts @@ -1,9 +1,11 @@ import {Component, OnInit, AfterViewInit, Renderer2} from '@angular/core'; +import {Router} from '@angular/router'; import {IdlObject} from '@eg/core/idl.service'; import {OrgService} from '@eg/core/org.service'; import {CatalogService} from '@eg/share/catalog/catalog.service'; import {CatalogSearchContext, CatalogSearchState} from '@eg/share/catalog/search-context'; import {StaffCatalogService} from './catalog.service'; +import {NgbTabset, NgbTabChangeEvent} from '@ng-bootstrap/ng-bootstrap'; @Component({ selector: 'eg-catalog-search-form', @@ -12,27 +14,32 @@ import {StaffCatalogService} from './catalog.service'; }) export class SearchFormComponent implements OnInit, AfterViewInit { - searchContext: CatalogSearchContext; + context: CatalogSearchContext; ccvmMap: {[ccvm: string]: IdlObject[]} = {}; cmfMap: {[cmf: string]: IdlObject} = {}; - showAdvancedSearch = false; + showSearchFilters = false; + copyLocations: IdlObject[]; + searchTab: string; constructor( private renderer: Renderer2, + private router: Router, private org: OrgService, private cat: CatalogService, private staffCat: StaffCatalogService - ) {} + ) { + this.copyLocations = []; + //this.searchTab = 'term'; + } ngOnInit() { this.ccvmMap = this.cat.ccvmMap; this.cmfMap = this.cat.cmfMap; - this.searchContext = this.staffCat.searchContext; + this.context = this.staffCat.searchContext; // Start with advanced search options open // if any filters are active. - this.showAdvancedSearch = this.hasAdvancedOptions(); - + this.showSearchFilters = this.filtersActive(); } ngAfterViewInit() { @@ -40,83 +47,180 @@ export class SearchFormComponent implements OnInit, AfterViewInit { // so they are not available until after the first render. // Search context data is extracted synchronously from the URL. - if (this.searchContext.identQuery) { - // Focus identifier query input if identQuery is in progress - this.renderer.selectRootElement('#ident-query-input').focus(); - } else { - // Otherwise focus the main query input - this.renderer.selectRootElement('#first-query-input').focus(); + // Avoid changing the tab in the lifecycle hook thread. + setTimeout(() => { + + // Apply a tab if none was already specified + if (!this.searchTab) { + // Assumes that only one type of search will be searchable + // at any given time. + if (this.context.marcSearch.isSearchable()) { + this.searchTab = 'marc'; + } else if (this.context.identSearch.isSearchable()) { + this.searchTab = 'ident'; + } else if (this.context.browseSearch.isSearchable()) { + this.searchTab = 'browse'; + } else { + // Default tab + this.searchTab = 'term'; + this.refreshCopyLocations(); + } + } + + this.focusTabInput(); + }); + } + + onTabChange(evt: NgbTabChangeEvent) { + this.searchTab = evt.nextId; + + // Focus after tab-change event has a chance to complete + // or the tab body and its input won't exist yet and no + // elements will be focus-able. + setTimeout(() => this.focusTabInput()); + } + + focusTabInput() { + // Select a DOM node to focus when the tab changes. + let selector; + switch (this.searchTab) { + case 'ident': + selector = '#ident-query-input'; + break; + case 'marc': + selector = '#first-marc-tag'; + break; + case 'browse': + selector = '#browse-term-input'; + break; + default: + this.refreshCopyLocations(); + selector = '#first-query-input'; } + + this.renderer.selectRootElement(selector).focus(); } /** * Display the advanced/extended search options when asked to * or if any advanced options are selected. */ - showAdvanced(): boolean { - return this.showAdvancedSearch; + showFilters(): boolean { + return this.showSearchFilters; } - hasAdvancedOptions(): boolean { + toggleFilters() { + this.showSearchFilters = !this.showSearchFilters; + this.refreshCopyLocations(); + } + + filtersActive(): boolean { + + if (this.context.termSearch.copyLocations[0] !== '') { return true; } + // ccvm filters may be present without any filters applied. // e.g. if filters were applied then removed. let show = false; - Object.keys(this.searchContext.ccvmFilters).forEach(ccvm => { - if (this.searchContext.ccvmFilters[ccvm][0] !== '') { + Object.keys(this.context.termSearch.ccvmFilters).forEach(ccvm => { + if (this.context.termSearch.ccvmFilters[ccvm][0] !== '') { show = true; } }); - if (this.searchContext.identQuery) { - show = true; - } - return show; } orgOnChange = (org: IdlObject): void => { - this.searchContext.searchOrg = org; + this.context.searchOrg = org; + this.refreshCopyLocations(); + } + + refreshCopyLocations() { + if (!this.showFilters()) { return; } + + // TODO: is this how we avoid displaying too many locations? + const org = this.context.searchOrg; + if (org.id() === this.org.root().id()) { + this.copyLocations = []; + return; + } + + this.cat.fetchCopyLocations(org).then(() => + this.copyLocations = this.cat.copyLocations + ); + } + + orgName(orgId: number): string { + return this.org.get(orgId).shortname(); } addSearchRow(index: number): void { - this.searchContext.query.splice(index, 0, ''); - this.searchContext.fieldClass.splice(index, 0, 'keyword'); - this.searchContext.joinOp.splice(index, 0, '&&'); - this.searchContext.matchOp.splice(index, 0, 'contains'); + this.context.termSearch.query.splice(index, 0, ''); + this.context.termSearch.fieldClass.splice(index, 0, 'keyword'); + this.context.termSearch.joinOp.splice(index, 0, '&&'); + this.context.termSearch.matchOp.splice(index, 0, 'contains'); } delSearchRow(index: number): void { - this.searchContext.query.splice(index, 1); - this.searchContext.fieldClass.splice(index, 1); - this.searchContext.joinOp.splice(index, 1); - this.searchContext.matchOp.splice(index, 1); + this.context.termSearch.query.splice(index, 1); + this.context.termSearch.fieldClass.splice(index, 1); + this.context.termSearch.joinOp.splice(index, 1); + this.context.termSearch.matchOp.splice(index, 1); + } + + addMarcSearchRow(index: number): void { + this.context.marcSearch.tags.splice(index, 0, ''); + this.context.marcSearch.subfields.splice(index, 0, ''); + this.context.marcSearch.values.splice(index, 0, ''); } - formEnter(source) { - this.searchContext.pager.offset = 0; + delMarcSearchRow(index: number): void { + this.context.marcSearch.tags.splice(index, 1); + this.context.marcSearch.subfields.splice(index, 1); + this.context.marcSearch.values.splice(index, 1); + } - switch (source) { + searchByForm(): void { + this.context.pager.offset = 0; // New search + + // Form search overrides basket display + this.context.showBasket = false; + + switch (this.searchTab) { + + case 'term': // AKA keyword search + this.context.marcSearch.reset(); + this.context.browseSearch.reset(); + this.context.identSearch.reset(); + this.context.termSearch.hasBrowseEntry = ''; + this.context.termSearch.browseEntry = null; + this.context.termSearch.fromMetarecord = null; + this.context.termSearch.facetFilters = []; + this.staffCat.search(); + break; - case 'query': // main search form query input + case 'ident': + this.context.marcSearch.reset(); + this.context.browseSearch.reset(); + this.context.termSearch.reset(); + this.staffCat.search(); + break; - // Be sure a previous ident search does not take precedence - // over the newly entered/submitted search query - this.searchContext.identQuery = null; + case 'marc': + this.context.browseSearch.reset(); + this.context.termSearch.reset(); + this.context.identSearch.reset(); + this.staffCat.search(); break; - case 'ident': // identifier query input - const iq = this.searchContext.identQuery; - const qt = this.searchContext.identQueryType; - if (iq) { - // Ident queries ignore search-specific filters. - this.searchContext.reset(); - this.searchContext.identQuery = iq; - this.searchContext.identQueryType = qt; - } + case 'browse': + this.context.marcSearch.reset(); + this.context.termSearch.reset(); + this.context.identSearch.reset(); + this.context.browseSearch.pivot = null; + this.staffCat.browse(); break; } - - this.searchByForm(); } // https://stackoverflow.com/questions/42322968/angular2-dynamic-input-field-lose-focus-when-input-changes @@ -124,14 +228,13 @@ export class SearchFormComponent implements OnInit, AfterViewInit { return index; } - searchByForm(): void { - this.staffCat.search(); - } - searchIsActive(): boolean { - return this.searchContext.searchState === CatalogSearchState.SEARCHING; + return this.context.searchState === CatalogSearchState.SEARCHING; } + goToBrowse() { + this.router.navigate(['/staff/catalog/browse']); + } } diff --git a/Open-ILS/src/eg2/src/app/staff/nav.component.html b/Open-ILS/src/eg2/src/app/staff/nav.component.html index 92209218e6..0642f493e1 100644 --- a/Open-ILS/src/eg2/src/app/staff/nav.component.html +++ b/Open-ILS/src/eg2/src/app/staff/nav.component.html @@ -26,10 +26,7 @@ assignment Search for Copies by Barcode - + search Search the Catalog @@ -143,13 +140,11 @@ Link to experimental Angular staff catalog. Leaving disabled until more functionality can be fleshed out. --> - list_alt Record Buckets diff --git a/Open-ILS/src/eg2/src/app/staff/share/bib-summary/bib-summary.component.ts b/Open-ILS/src/eg2/src/app/staff/share/bib-summary/bib-summary.component.ts index 78d2653c47..645b56cd78 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/bib-summary/bib-summary.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/share/bib-summary/bib-summary.component.ts @@ -14,6 +14,9 @@ export class BibSummaryComponent implements OnInit { initDone = false; expandDisplay = true; + @Input() set expand(e: boolean) { + this.expandDisplay = e; + } // If provided, the record will be fetched by the component. @Input() recordId: number; diff --git a/Open-ILS/src/eg2/src/app/staff/share/buckets/record-bucket-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/share/buckets/record-bucket-dialog.component.html index 4399111883..a2c88b8e34 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/buckets/record-bucket-dialog.component.html +++ b/Open-ILS/src/eg2/src/app/staff/share/buckets/record-bucket-dialog.component.html @@ -1,7 +1,14 @@