From 5a4838fa067be401fb304e1d9e83bf5eb045c521 Mon Sep 17 00:00:00 2001 From: Bill Erickson Date: Wed, 5 Dec 2018 12:26:33 -0500 Subject: [PATCH] LP#1806087 Angular catalog browse UI + API Signed-off-by: Bill Erickson --- .../src/app/share/catalog/catalog-url.service.ts | 8 +- .../eg2/src/app/share/catalog/catalog.service.ts | 22 ++ .../eg2/src/app/share/catalog/search-context.ts | 25 +- .../src/app/staff/catalog/browse.component.html | 5 + .../eg2/src/app/staff/catalog/browse.component.ts | 18 + .../app/staff/catalog/browse/form.component.html | 36 ++ .../src/app/staff/catalog/browse/form.component.ts | 64 ++++ .../staff/catalog/browse/results.component.html | 84 +++++ .../app/staff/catalog/browse/results.component.ts | 139 ++++++++ .../eg2/src/app/staff/catalog/catalog.module.ts | 8 +- .../eg2/src/app/staff/catalog/catalog.service.ts | 22 ++ .../eg2/src/app/staff/catalog/routing.module.ts | 7 +- .../app/staff/catalog/search-form.component.html | 20 +- .../src/app/staff/catalog/search-form.component.ts | 6 +- .../src/perlmods/lib/OpenILS/Application/Search.pm | 2 + .../lib/OpenILS/Application/Search/Browse.pm | 392 +++++++++++++++++++++ 16 files changed, 843 insertions(+), 15 deletions(-) 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/form.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/browse/form.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/perlmods/lib/OpenILS/Application/Search/Browse.pm 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 71cfce2f3a..116ab4a275 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 @@ -30,7 +30,9 @@ export class CatalogUrlService { org: null, limit: null, offset: null, - copyLocations: null + copyLocations: null, + browsePivot: null, + hasBrowseEntry: null }; params.org = context.searchOrg.id(); @@ -42,7 +44,7 @@ export class CatalogUrlService { // These fields can be copied directly into place ['format', 'sort', 'available', 'global', 'identQuery', - 'identQueryType', 'basket'] + 'identQueryType', 'basket', 'browsePivot', 'hasBrowseEntry'] .forEach(field => { if (context[field]) { // Only propagate applied values to the URL. @@ -106,7 +108,7 @@ export class CatalogUrlService { // These fields can be copied directly into place ['format', 'sort', 'available', 'global', 'identQuery', - 'identQueryType', 'basket'] + 'identQueryType', 'basket', 'browsePivot', 'hasBrowseEntry'] .forEach(field => { const val = params.get(field); if (val !== null) { 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 e7f3f278c6..aaa6b59f0b 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 @@ -266,4 +266,26 @@ export class CatalogService { {anonymous: true} ).pipe(tap(loc => this.copyLocations.push(loc))).toPromise() } + + browse(ctx: CatalogSearchContext): Observable { + ctx.searchState = CatalogSearchState.SEARCHING; + + 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: ctx.fieldClass[0], + term: ctx.query[0], + limit : ctx.pager.limit, + pivot: ctx.browsePivot, + 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 1807eac243..5d676a10c8 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 @@ -50,6 +50,8 @@ export class CatalogSearchContext { isStaff: boolean; basket = false; copyLocations: string[]; // ID's, but treated as strings in the UI. + browsePivot: number; + hasBrowseEntry: string; // "entryId,fieldId" // Result from most recent search. result: any = {}; @@ -124,6 +126,7 @@ export class CatalogSearchContext { this.copyLocations = ['']; } + // Returns true if we have enough information to perform a search. isSearchable(): boolean { if (this.basket) { @@ -134,7 +137,22 @@ export class CatalogSearchContext { return true; } - return this.query.length + if (this.searchOrg === null) { + return false; + } + + if (this.hasBrowseEntry) { + return true; + } + + return this.query.length && this.query[0] !== ''; + } + + // Returns true if we have enough information to perform a browse. + isBrowsable(): boolean { + return this.fieldClass.length + && this.fieldClass[0] !== '' + && this.query.length && this.query[0] !== '' && this.searchOrg !== null; } @@ -173,6 +191,11 @@ export class CatalogSearchContext { // ------- } + if (this.hasBrowseEntry) { + // stored as a comma-separated string of "entryId,fieldId" + str += ` has_browse_entry(${this.hasBrowseEntry})`; + } + if (this.format) { str += ' format(' + this.format + ')'; } diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/browse.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/browse.component.html new file mode 100644 index 0000000000..8412143d2e --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/catalog/browse.component.html @@ -0,0 +1,5 @@ + + + + + diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/browse.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/browse.component.ts new file mode 100644 index 0000000000..996f96546f --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/catalog/browse.component.ts @@ -0,0 +1,18 @@ +import {Component, OnInit} from '@angular/core'; +import {StaffCatalogService} from './catalog.service'; + +@Component({ + templateUrl: 'browse.component.html' +}) +export class BrowseComponent implements OnInit { + + constructor( + private staffCat: StaffCatalogService + ) {} + + ngOnInit() { + // A SearchContext provides all the data needed for browse. + this.staffCat.createContext(); + } +} + diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/browse/form.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/browse/form.component.html new file mode 100644 index 0000000000..6dba2508cd --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/catalog/browse/form.component.html @@ -0,0 +1,36 @@ +
+
+ + + + + + + + +
+
+
+ +
+
+
+ diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/browse/form.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/browse/form.component.ts new file mode 100644 index 0000000000..b9c4c8ef4a --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/catalog/browse/form.component.ts @@ -0,0 +1,64 @@ +import {Component, OnInit, AfterViewInit, Renderer2} from '@angular/core'; +import {Router} from '@angular/router'; +import {IdlObject} from '@eg/core/idl.service'; +import {OrgService} from '@eg/core/org.service'; +import {CatalogService} from '@eg/share/catalog/catalog.service'; +import {CatalogSearchContext, CatalogSearchState} from '@eg/share/catalog/search-context'; +import {StaffCatalogService} from '../catalog.service'; + +@Component({ + selector: 'eg-catalog-browse-form', + templateUrl: 'form.component.html' +}) +export class BrowseFormComponent implements OnInit, AfterViewInit { + + searchContext: CatalogSearchContext; + ccvmMap: {[ccvm: string]: IdlObject[]} = {}; + cmfMap: {[cmf: string]: IdlObject} = {}; + + constructor( + private renderer: Renderer2, + private router: Router, + private org: OrgService, + private cat: CatalogService, + private staffCat: StaffCatalogService + ) { + } + + ngOnInit() { + this.ccvmMap = this.cat.ccvmMap; + this.cmfMap = this.cat.cmfMap; + this.searchContext = this.staffCat.searchContext; + } + + ngAfterViewInit() { + this.renderer.selectRootElement('#browse-term-input').focus(); + } + + orgName(orgId: number): string { + return this.org.get(orgId).shortname(); + } + + formEnter(source) { + this.searchContext.pager.offset = 0; + this.browseByForm(); + } + + browseByForm(): void { + this.staffCat.browse(); + } + + searchIsActive(): boolean { + return this.searchContext.searchState === CatalogSearchState.SEARCHING; + } + + goToSearch() { + this.router.navigate(['/staff/catalog/search']); + } + + orgOnChange = (org: IdlObject): void => { + this.searchContext.searchOrg = org; + } +} + + diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/browse/results.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/browse/results.component.html new file mode 100644 index 0000000000..fdbb05408c --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/catalog/browse/results.component.html @@ -0,0 +1,84 @@ + + +
+
+
+
+ Searching.. +
+
+
+
+ + +
+
+
+
+ No Maching Items Were Found +
+
+
+
+ + +
+ +
+
+ + +
+
+ +
+
+
+
+ + + {{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..f706cd50c8 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/catalog/browse/results.component.ts @@ -0,0 +1,139 @@ +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); + + // SearchContext applies a default fieldClass value of 'keyword'. + // Replace with 'title', since there is no 'keyword' browse. + if (this.searchContext.fieldClass[0] === 'keyword') { + this.searchContext.fieldClass = ['title']; + } + + if (this.searchContext.isBrowsable()) { + 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.browsePivot = firstResult.pivot_point; + this.staffCat.browse(); + } + } + + nextPage() { + const lastResult = this.results[this.results.length - 1]; + if (lastResult) { + this.searchContext.browsePivot = lastResult.pivot_point; + this.staffCat.browse(); + } + } + + searchByBrowseEntry(result) { + + // avoid propagating the browse query to the search form + this.searchContext.query[0] = ''; + + this.searchContext.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.query[0] = heading.heading; + this.staffCat.browse(); + } +} + + 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 d5b0eeb357..b083f67350 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 @@ -19,6 +19,9 @@ 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 {BrowseFormComponent} from './browse/form.component'; +import {BrowseResultsComponent} from './browse/results.component'; @NgModule({ declarations: [ @@ -35,7 +38,10 @@ import {PartMergeDialogComponent} from './record/part-merge-dialog.component'; BasketActionsComponent, HoldComponent, PartsComponent, - PartMergeDialogComponent + PartMergeDialogComponent, + BrowseComponent, + BrowseFormComponent, + BrowseResultsComponent ], imports: [ StaffCommonModule, 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..681e159e81 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.isBrowsable()) { 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/routing.module.ts b/Open-ILS/src/eg2/src/app/staff/catalog/routing.module.ts index 7c7690376b..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 @@ -5,6 +5,7 @@ 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: '', @@ -22,7 +23,11 @@ const routes: Routes = [{ }, { 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.html b/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.html index 1ecc91e562..cdcae0c98f 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 @@ -4,7 +4,7 @@ TODO focus search input
-
+