From 763fe1a0b39474f07465ec446d7b3724dc518e7d Mon Sep 17 00:00:00 2001 From: Bill Erickson Date: Mon, 22 Jul 2019 17:50:01 -0400 Subject: [PATCH] LP1837478 Angular Catalog Recent Searches & Templates Adds two new dropdowns (below basket actions) for recent searches (similar to those found in the staff TPAC) and search templates. Search templates are a new feature which allow staff to save canned search filters/settings without the query content, so common searches may be easily recalled. For UI consistency and to preserve some space, the Basket Actions selector is now a dropdown instead of a select element. Adds a new workstation setting 'eg.catalog.search_templates' for storing templates. Includes a number of improvements to the underlying Catalog code and a new ArrayUtil class, which adds a simple equals() function for comparing arrays. Signed-off-by: Bill Erickson Signed-off-by: Chris Sharp Signed-off-by: Galen Charlton --- .../app/share/catalog/catalog-url.service.ts | 8 +- .../src/app/share/catalog/catalog.service.ts | 9 +- .../src/app/share/catalog/search-context.ts | 196 +++++++++- .../src/eg2/src/app/share/util/array.spec.ts | 29 ++ Open-ILS/src/eg2/src/app/share/util/array.ts | 39 ++ .../catalog/basket-actions.component.html | 36 +- .../staff/catalog/basket-actions.component.ts | 3 +- .../src/app/staff/catalog/catalog.module.ts | 2 + .../src/app/staff/catalog/catalog.service.ts | 2 + .../src/app/staff/catalog/resolver.service.ts | 3 +- .../staff/catalog/search-form.component.html | 8 +- .../staff/catalog/search-form.component.ts | 55 +-- .../catalog/search-templates.component.html | 99 ++++++ .../catalog/search-templates.component.ts | 336 ++++++++++++++++++ Open-ILS/src/sql/Pg/950.data.seed-values.sql | 11 + .../Pg/upgrade/XXXX.data.search-templates.sql | 17 + 16 files changed, 791 insertions(+), 62 deletions(-) create mode 100644 Open-ILS/src/eg2/src/app/share/util/array.spec.ts create mode 100644 Open-ILS/src/eg2/src/app/share/util/array.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/search-templates.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/search-templates.component.ts create mode 100644 Open-ILS/src/sql/Pg/upgrade/XXXX.data.search-templates.sql 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 91922d48d5..4c45a4e3c7 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 @@ -176,18 +176,18 @@ export class CatalogUrlService { context.showBasket = val; } - if (params.get('marcValue')) { + if (params.has('marcValue')) { context.marcSearch.tags = params.getAll('marcTag'); context.marcSearch.subfields = params.getAll('marcSubfield'); context.marcSearch.values = params.getAll('marcValue'); } - if (params.get('identQuery')) { + if (params.has('identQuery')) { context.identSearch.value = params.get('identQuery'); context.identSearch.queryType = params.get('identQueryType'); } - if (params.get('browseTerm')) { + if (params.has('browseTerm')) { context.browseSearch.value = params.get('browseTerm'); context.browseSearch.fieldClass = params.get('browseClass'); if (params.has('browsePivot')) { @@ -195,7 +195,7 @@ export class CatalogUrlService { } } - if (params.get('cnBrowseTerm')) { + if (params.has('cnBrowseTerm')) { context.cnBrowseSearch.value = params.get('cnBrowseTerm'); context.cnBrowseSearch.offset = Number(params.get('cnBrowsePage')); } 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 9cff2c4e5d..2aaaf1f3ae 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, EventEmitter} from '@angular/core'; import {Observable} from 'rxjs'; -import {map, tap} from 'rxjs/operators'; +import {map, tap, finalize} 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'; @@ -358,9 +358,10 @@ export class CatalogService { pivot: bs.pivot, org_unit: ctx.searchOrg.id() } - ).pipe(tap(result => { - ctx.searchState = CatalogSearchState.COMPLETE; - })); + ).pipe( + tap(result => ctx.searchState = CatalogSearchState.COMPLETE), + finalize(() => this.onSearchComplete.emit(ctx)) + ); } cnBrowse(ctx: CatalogSearchContext): Observable { 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 ef0fd552ea..041d710a4b 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 @@ -1,6 +1,7 @@ import {OrgService} from '@eg/core/org.service'; import {IdlObject} from '@eg/core/idl.service'; import {Pager} from '@eg/share/util/pager'; +import {ArrayUtil} from '@eg/share/util/array'; // CCVM's we care about in a catalog context // Don't fetch them all because there are a lot. @@ -41,6 +42,11 @@ export class FacetFilter { this.facetValue === filter.facetValue ); } + + clone(): FacetFilter { + return new FacetFilter( + this.facetClass, this.facetName, this.facetValue); + } } export class CatalogSearchResults { @@ -71,6 +77,18 @@ export class CatalogBrowseContext { this.fieldClass !== '' ); } + + clone(): CatalogBrowseContext { + const ctx = new CatalogBrowseContext(); + ctx.value = this.value; + ctx.pivot = this.pivot; + ctx.fieldClass = this.fieldClass; + return ctx; + } + + equals(ctx: CatalogBrowseContext): boolean { + return ctx.value === this.value && ctx.fieldClass === this.fieldClass; + } } export class CatalogMarcContext { @@ -91,6 +109,19 @@ export class CatalogMarcContext { ); } + clone(): CatalogMarcContext { + const ctx = new CatalogMarcContext(); + ctx.tags = [].concat(this.tags); + ctx.values = [].concat(this.values); + ctx.subfields = [].concat(this.subfields); + return ctx; + } + + equals(ctx: CatalogMarcContext): boolean { + return ArrayUtil.equals(ctx.tags, this.tags) + && ArrayUtil.equals(ctx.values, this.values) + && ArrayUtil.equals(ctx.subfields, this.subfields); + } } export class CatalogIdentContext { @@ -109,6 +140,16 @@ export class CatalogIdentContext { ); } + clone(): CatalogIdentContext { + const ctx = new CatalogIdentContext(); + ctx.value = this.value; + ctx.queryType = this.queryType; + return ctx; + } + + equals(ctx: CatalogIdentContext): boolean { + return ctx.value === this.value && ctx.queryType === this.queryType; + } } export class CatalogCnBrowseContext { @@ -123,7 +164,18 @@ export class CatalogCnBrowseContext { } isSearchable() { - return this.value !== ''; + return this.value !== '' && this.value !== undefined; + } + + clone(): CatalogCnBrowseContext { + const ctx = new CatalogCnBrowseContext(); + ctx.value = this.value; + ctx.offset = this.offset; + return ctx; + } + + equals(ctx: CatalogCnBrowseContext): boolean { + return ctx.value === this.value; } } @@ -169,6 +221,63 @@ export class CatalogTermContext { CATALOG_CCVM_FILTERS.forEach(code => this.ccvmFilters[code] = ['']); } + clone(): CatalogTermContext { + const ctx = new CatalogTermContext(); + + ctx.query = [].concat(this.query); + ctx.fieldClass = [].concat(this.fieldClass); + ctx.matchOp = [].concat(this.matchOp); + ctx.joinOp = [].concat(this.joinOp); + ctx.copyLocations = [].concat(this.copyLocations); + ctx.format = this.format; + ctx.hasBrowseEntry = this.hasBrowseEntry; + ctx.date1 = this.date1; + ctx.date2 = this.date2; + ctx.dateOp = this.dateOp; + ctx.fromMetarecord = this.fromMetarecord; + + ctx.facetFilters = this.facetFilters.map(f => f.clone()); + + ctx.ccvmFilters = {}; + Object.keys(this.ccvmFilters).forEach( + key => ctx.ccvmFilters[key] = this.ccvmFilters[key]); + + return ctx; + } + + equals(ctx: CatalogTermContext): boolean { + if ( ArrayUtil.equals(ctx.query, this.query) + && ArrayUtil.equals(ctx.fieldClass, this.fieldClass) + && ArrayUtil.equals(ctx.matchOp, this.matchOp) + && ArrayUtil.equals(ctx.joinOp, this.joinOp) + && ArrayUtil.equals(ctx.copyLocations, this.copyLocations) + && ctx.format === this.format + && ctx.hasBrowseEntry === this.hasBrowseEntry + && ctx.date1 === this.date1 + && ctx.date2 === this.date2 + && ctx.dateOp === this.dateOp + && ctx.fromMetarecord === this.fromMetarecord + && ArrayUtil.equals( + ctx.facetFilters, this.facetFilters, (a, b) => a.equals(b)) + && Object.keys(this.ccvmFilters).length === + Object.keys(ctx.ccvmFilters).length + ) { + + // So far so good, compare ccvm hash contents + let mismatch = false; + Object.keys(this.ccvmFilters).forEach(key => { + if (!ArrayUtil.equals(this.ccvmFilters[key], ctx.ccvmFilters[key])) { + mismatch = true; + } + }); + + return !mismatch; + } + + return false; + } + + // True when grouping by metarecord but not when displaying the // contents of a metarecord. isMetarecordSearch(): boolean { @@ -252,6 +361,38 @@ export class CatalogSearchContext { this.reset(); } + // Performs a deep clone of the search context as-is. + clone(): CatalogSearchContext { + const ctx = new CatalogSearchContext(); + + ctx.sort = this.sort; + ctx.isStaff = this.isStaff; + ctx.global = this.global; + + // OK to share since the org object won't be changing. + ctx.searchOrg = this.searchOrg; + + ctx.termSearch = this.termSearch.clone(); + ctx.marcSearch = this.marcSearch.clone(); + ctx.identSearch = this.identSearch.clone(); + ctx.browseSearch = this.browseSearch.clone(); + ctx.cnBrowseSearch = this.cnBrowseSearch.clone(); + + return ctx; + } + + equals(ctx: CatalogSearchContext): boolean { + return ( + this.termSearch.equals(ctx.termSearch) + && this.marcSearch.equals(ctx.marcSearch) + && this.identSearch.equals(ctx.identSearch) + && this.browseSearch.equals(ctx.browseSearch) + && this.cnBrowseSearch.equals(ctx.cnBrowseSearch) + && this.sort === ctx.sort + && this.global === ctx.global + ); + } + /** * Return search context to its default state, resetting search * parameters and clearing any cached result data. @@ -267,6 +408,7 @@ export class CatalogSearchContext { this.marcSearch.reset(); this.identSearch.reset(); this.browseSearch.reset(); + this.cnBrowseSearch.reset(); } isSearchable(): boolean { @@ -483,5 +625,57 @@ export class CatalogSearchContext { return str; } + + // A search context can collect enough data for multiple search + // types to be searchable (e.g. users navigate through parts of a + // search form). Calling this method and providing a search type + // ensures the context is cleared of any data unrelated to the + // desired type. + scrub(searchType: string): void { + + switch (searchType) { + + case 'term': // AKA keyword search + this.marcSearch.reset(); + this.browseSearch.reset(); + this.identSearch.reset(); + this.cnBrowseSearch.reset(); + this.termSearch.hasBrowseEntry = ''; + this.termSearch.browseEntry = null; + this.termSearch.fromMetarecord = null; + this.termSearch.facetFilters = []; + break; + + case 'ident': + this.marcSearch.reset(); + this.browseSearch.reset(); + this.termSearch.reset(); + this.cnBrowseSearch.reset(); + break; + + case 'marc': + this.browseSearch.reset(); + this.termSearch.reset(); + this.identSearch.reset(); + this.cnBrowseSearch.reset(); + break; + + case 'browse': + this.marcSearch.reset(); + this.termSearch.reset(); + this.identSearch.reset(); + this.cnBrowseSearch.reset(); + this.browseSearch.pivot = null; + break; + + case 'cnbrowse': + this.marcSearch.reset(); + this.termSearch.reset(); + this.identSearch.reset(); + this.browseSearch.reset(); + this.cnBrowseSearch.offset = 0; + break; + } + } } diff --git a/Open-ILS/src/eg2/src/app/share/util/array.spec.ts b/Open-ILS/src/eg2/src/app/share/util/array.spec.ts new file mode 100644 index 0000000000..10125be2af --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/util/array.spec.ts @@ -0,0 +1,29 @@ +import {ArrayUtil} from './array'; + +describe('ArrayUtil', () => { + + const arr1 = [1, '2', true, undefined, null]; + const arr2 = [1, '2', true, undefined, null]; + const arr3 = [1, '2', true, undefined, null, 'foo']; + const arr4 = [[1, 2, 3], [4, 3, 2]]; + const arr5 = [[1, 2, 3], [4, 3, 2]]; + const arr6 = [[1, 2, 3], [1, 2, 3]]; + + it('Compare matching arrays', () => { + expect(ArrayUtil.equals(arr1, arr2)).toBe(true); + }); + + it('Compare non-matching arrays', () => { + expect(ArrayUtil.equals(arr1, arr3)).toBe(false); + }); + + // Using ArrayUtil.equals as a comparator -- testception! + it('Compare matching arrays with comparator', () => { + expect(ArrayUtil.equals(arr4, arr5, ArrayUtil.equals)).toBe(true); + }); + + it('Compare non-matching arrays with comparator', () => { + expect(ArrayUtil.equals(arr5, arr6, ArrayUtil.equals)).toBe(false); + }); + +}); diff --git a/Open-ILS/src/eg2/src/app/share/util/array.ts b/Open-ILS/src/eg2/src/app/share/util/array.ts new file mode 100644 index 0000000000..a66f32607b --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/util/array.ts @@ -0,0 +1,39 @@ + +/* Utility code for arrays */ + +export class ArrayUtil { + + // Returns true if the two arrays contain the same values as + // reported by the provided comparator function or === + static equals(arr1: any[], arr2: any[], + comparator?: (a: any, b: any) => boolean): boolean { + + if (!Array.isArray(arr1) || !Array.isArray(arr2)) { + return false; + } + + if (arr1 === arr2) { + // Same array + return true; + } + + if (arr1.length !== arr2.length) { + return false; + } + + for (let i = 0; i < arr1.length; i++) { + if (comparator) { + if (!comparator(arr1[i], arr2[i])) { + return false; + } + } else { + if (arr1[i] !== arr2[i]) { + return false; + } + } + } + + return true; + } +} + diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/basket-actions.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/basket-actions.component.html index 9fcd873cee..2f32c22fc5 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/basket-actions.component.html +++ b/Open-ILS/src/eg2/src/app/staff/catalog/basket-actions.component.html @@ -1,8 +1,8 @@ -
-
+ diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/basket-actions.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/basket-actions.component.ts index c42b7ddc75..fa4f49f0a2 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/basket-actions.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/catalog/basket-actions.component.ts @@ -37,7 +37,8 @@ export class BasketActionsComponent implements OnInit { // TODO: confirmation dialogs? - applyAction() { + applyAction(action: string) { + this.basketAction = action; console.debug('Performing basket action', this.basketAction); switch (this.basketAction) { 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 e78a951e62..e0fbff851d 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 @@ -26,6 +26,7 @@ import {HoldingsMaintenanceComponent} from './record/holdings.component'; import {ConjoinedComponent} from './record/conjoined.component'; import {CnBrowseComponent} from './cnbrowse.component'; import {CnBrowseResultsComponent} from './cnbrowse/results.component'; +import {SearchTemplatesComponent} from './search-templates.component'; @NgModule({ declarations: [ @@ -47,6 +48,7 @@ import {CnBrowseResultsComponent} from './cnbrowse/results.component'; BrowseResultsComponent, ConjoinedComponent, HoldingsMaintenanceComponent, + SearchTemplatesComponent, CnBrowseComponent, CnBrowseResultsComponent ], 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 3c1ba9579a..86501fc10f 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 @@ -17,6 +17,8 @@ export class StaffCatalogService { routeIndex = 0; defaultSearchOrg: IdlObject; defaultSearchLimit: number; + // Track the current template through route changes. + selectedTemplate: string; // TODO: does unapi support pref-lib for result-page copy counts? prefOrg: IdlObject; 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 1dac53644e..1b6dac1d4a 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 @@ -48,7 +48,8 @@ export class CatalogResolver implements Resolve> { 'cat.marcedit.stack_subfields', 'cat.marcedit.flateditor', 'cat.holdings_show_copies', - 'cat.holdings_show_vols' + 'cat.holdings_show_vols', + 'opac.staff_saved_search.size' ]).then(settings => { this.staffCat.defaultSearchOrg = this.org.get(settings['eg.search.search_lib']); 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 8d6e34878a..72386f2621 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 @@ -340,7 +340,13 @@ TODO focus search input
-
+
+
+ + +
+
+
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 c8cee02105..0e010eb5ca 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,5 +1,5 @@ import {Component, OnInit, AfterViewInit, Renderer2} from '@angular/core'; -import {Router} from '@angular/router'; +import {ActivatedRoute} 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'; @@ -23,12 +23,22 @@ export class SearchFormComponent implements OnInit, AfterViewInit { constructor( private renderer: Renderer2, - private router: Router, + private route: ActivatedRoute, private org: OrgService, private cat: CatalogService, private staffCat: StaffCatalogService ) { this.copyLocations = []; + + // Some search scenarios, like rendering a search template, + // will not be searchable and thus not resovle to a specific + // search tab. Check to see if a specific tab is requested + // via the URL. + this.route.queryParams.subscribe(params => { + if (params.searchTab) { + this.searchTab = params.searchTab; + } + }); } ngOnInit() { @@ -114,7 +124,10 @@ export class SearchFormComponent implements OnInit, AfterViewInit { * or if any advanced options are selected. */ showFilters(): boolean { - return this.showSearchFilters; + // Note that filters may become active due to external + // actions on the search context. Always show the filters + // if filter values are applied. + return this.showSearchFilters || this.filtersActive(); } toggleFilters() { @@ -194,51 +207,21 @@ export class SearchFormComponent implements OnInit, AfterViewInit { // Form search overrides basket display this.context.showBasket = false; - switch (this.searchTab) { + this.context.scrub(this.searchTab); - case 'term': // AKA keyword search - this.context.marcSearch.reset(); - this.context.browseSearch.reset(); - this.context.identSearch.reset(); - this.context.cnBrowseSearch.reset(); - this.context.termSearch.hasBrowseEntry = ''; - this.context.termSearch.browseEntry = null; - this.context.termSearch.fromMetarecord = null; - this.context.termSearch.facetFilters = []; - this.staffCat.search(); - break; + switch (this.searchTab) { + case 'term': case 'ident': - this.context.marcSearch.reset(); - this.context.browseSearch.reset(); - this.context.termSearch.reset(); - this.context.cnBrowseSearch.reset(); - this.staffCat.search(); - break; - case 'marc': - this.context.browseSearch.reset(); - this.context.termSearch.reset(); - this.context.identSearch.reset(); - this.context.cnBrowseSearch.reset(); this.staffCat.search(); break; case 'browse': - this.context.marcSearch.reset(); - this.context.termSearch.reset(); - this.context.identSearch.reset(); - this.context.cnBrowseSearch.reset(); - this.context.browseSearch.pivot = null; this.staffCat.browse(); break; case 'cnbrowse': - this.context.marcSearch.reset(); - this.context.termSearch.reset(); - this.context.identSearch.reset(); - this.context.browseSearch.reset(); - this.context.cnBrowseSearch.offset = 0; this.staffCat.cnBrowse(); break; } diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/search-templates.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/search-templates.component.html new file mode 100644 index 0000000000..2698bef7fc --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/catalog/search-templates.component.html @@ -0,0 +1,99 @@ + + + + + + + + + + + + Search: + Identifier: + MARC: + Browse: + {{query}} + + + + + +
+ + +
+ +
+ + + + +
+
+
+ +
+ +
+ + + + + + +
+
+
+ + + + + + + diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/search-templates.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/search-templates.component.ts new file mode 100644 index 0000000000..67b024f893 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/catalog/search-templates.component.ts @@ -0,0 +1,336 @@ +import {Component, OnInit, Input, ViewChild} from '@angular/core'; +import {OrgService} from '@eg/core/org.service'; +import {StoreService} from '@eg/core/store.service'; +import {ServerStoreService} from '@eg/core/server-store.service'; +import {PcrudService} from '@eg/core/pcrud.service'; +import {DialogComponent} from '@eg/share/dialog/dialog.component'; +import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component'; +import {StringService} from '@eg/share/string/string.service'; +import {CatalogService} from '@eg/share/catalog/catalog.service'; +import {CatalogUrlService} from '@eg/share/catalog/catalog-url.service'; +import {CatalogSearchContext, CatalogSearchState} from '@eg/share/catalog/search-context'; +import {StaffCatalogService} from './catalog.service'; +import {AnonCacheService} from '@eg/share/util/anon-cache.service'; +import {NgbModal, NgbModalOptions} from '@ng-bootstrap/ng-bootstrap'; + +const SAVED_TEMPLATES_SETTING = 'eg.catalog.search_templates'; +const RECENT_SEARCHES_KEY = 'eg.catalog.recent_searches'; + +class SearchTemplate { + name: string; + params: any = {}; // routerLink-compatible URL params object + addTime?: number; + constructor(name: string, params: any) { + this.name = name; + this.params = params; + } +} + +@Component({ + selector: 'eg-catalog-search-templates', + templateUrl: 'search-templates.component.html' +}) +export class SearchTemplatesComponent extends DialogComponent implements OnInit { + + recentSearchesCount = 0; + context: CatalogSearchContext; + templates: SearchTemplate[] = []; + searches: SearchTemplate[] = []; + searchesCacheKey: string; + templateName: string; + + @Input() searchTab: string; + + @ViewChild('confirmDelete') confirmDelete: ConfirmDialogComponent; + @ViewChild('confirmDeleteAll') confirmDeleteAll: ConfirmDialogComponent; + @ViewChild('confirmDeleteSearches') confirmDeleteSearches: ConfirmDialogComponent; + + constructor( + private org: OrgService, + private store: StoreService, // anon cache key + private serverStore: ServerStoreService, // search templates + private cache: AnonCacheService, // recent searches + private strings: StringService, + private cat: CatalogService, + private catUrl: CatalogUrlService, + private staffCat: StaffCatalogService, + private modal: NgbModal) { + super(modal); + + this.store.addLoginSessionKey(RECENT_SEARCHES_KEY); + } + + ngOnInit() { + this.context = this.staffCat.searchContext; + console.log('ngOnInit() with selected = ', this.staffCat.selectedTemplate); + + this.org.settings('opac.staff_saved_search.size').then(sets => { + const size = sets['opac.staff_saved_search.size'] || 0; + if (!size) { return; } + + this.recentSearchesCount = Number(size); + + this.getSearches().then(_ => { + this.searches.forEach( + s => s.params.ridx = ++this.staffCat.routeIndex); + + // Save the search that runs on page load. + this.saveSearch(this.context); + // Watch for new searches + this.cat.onSearchComplete.subscribe(ctx => this.saveSearch(ctx)); + }); + }); + + this.getTemplates(); + } + + selectedTemplate(): string { + return this.staffCat.selectedTemplate; + } + + getSearches(): Promise { + this.searches = []; + + if (this.searchesCacheKey) { + // We've already started saving searches in the current instance. + + return this.cache.getItem(this.searchesCacheKey, 'searches') + .then(searches => this.searches = searches || []); + } + + const cacheKey = this.store.getLoginSessionItem(RECENT_SEARCHES_KEY); + + if (cacheKey) { + // We have a saved search key, see if we have any searches. + + this.searchesCacheKey = cacheKey; + return this.cache.getItem(this.searchesCacheKey, 'searches') + .then(searches => this.searches = searches || []); + + } else { + // No saved searches in progress. Start from scratch. + + return this.cache.setItem(null, 'searches', []) // generates cache key + .then(cKey => { + this.searchesCacheKey = cKey; + this.store.setLoginSessionItem(RECENT_SEARCHES_KEY, cKey); + }); + } + } + + searchSelected(search: SearchTemplate) { + // increment the router index in case the template is used + // twice in a row. + search.params.ridx = ++this.staffCat.routeIndex; + } + + // Returns searches most recent first + sortSearches(): SearchTemplate[] { + return this.searches.sort((a, b) => a.addTime > b.addTime ? -1 : 1); + } + + deleteSearches() { + this.confirmDeleteSearches.open().subscribe(yes => { + if (!yes) { return; } + this.searches = []; + this.cache.setItem(this.searchesCacheKey, 'searches', []); + }); + } + + getSearchPath(search: SearchTemplate): string { + return search.params.searchTab === 'browse' ? + '/staff/catalog/browse' : '/staff/catalog/search'; + } + + saveSearch(context: CatalogSearchContext) { + + let matchFound = false; + this.searches.forEach(sch => { + const tmpCtx = this.catUrl.fromUrlHash(sch.params); + if (tmpCtx.equals(context)) { + matchFound = true; + } + }); + + if (matchFound) { return; } + + let query: string; + switch (this.searchTab) { + case 'term': + query = context.termSearch.query[0]; + break; + case 'marc': + query = context.marcSearch.values[0]; + break; + case 'ident': + query = context.identSearch.value; + break; + case 'browse': + query = context.browseSearch.value; + break; + case 'cnbrowse': + query = context.cnBrowseSearch.value; + break; + } + + if (!query) { + // no query means nothing was searchable. + return; + } + + this.strings.interpolate( + 'eg.catalog.recent_search.label', + {query: query, tab: this.searchTab} + + ).then(txt => { + + const urlParams = this.prepareSearch(context); + const search = new SearchTemplate(txt, urlParams); + search.addTime = new Date().getTime(); + + this.searches.unshift(search); + + if (this.searches.length > this.recentSearchesCount) { + // this bit of magic will lop off the end of the array. + this.searches.length = this.recentSearchesCount; + } + + this.cache.setItem( + this.searchesCacheKey, 'searches', this.searches) + .then(_ => search.params.ridx = ++this.staffCat.routeIndex); + }); + } + + getTemplates(): Promise { + this.templates = []; + + return this.serverStore.getItem(SAVED_TEMPLATES_SETTING).then( + templates => { + if (templates && templates.length) { + this.templates = templates; + + // route index required to force the route to take + // effect. See ./catalog.service.ts + this.templates.forEach(tmpl => + tmpl.params.ridx = ++this.staffCat.routeIndex); + } + } + ); + } + + sortTemplates(): SearchTemplate[] { + return this.templates.sort((a, b) => + a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1); + } + + templateSelected(tmpl: SearchTemplate) { + this.staffCat.selectedTemplate = tmpl.name; + // increment the router index in case the template is used + // twice in a row. + tmpl.params.ridx = ++this.staffCat.routeIndex; + console.log('selected template = ', this.staffCat.selectedTemplate); + } + + // Adds dummy query content to the context object so the + // CatalogUrlService will recognize the content as searchable + // and therefor URL-encodable. + addDummyQueries(context: CatalogSearchContext) { + context.termSearch.query = context.termSearch.query.map(q => 'x'); + context.marcSearch.values = context.marcSearch.values.map(q => 'x'); + context.browseSearch.value = 'x'; + context.identSearch.value = 'x'; + } + + // Remove the dummy query content before saving the search template. + removeDummyQueries(urlParams: any) { + + if (Array.isArray(urlParams.query)) { + const arr = urlParams.query as Array; + urlParams.query = arr.map(q => ''); + } else { + urlParams.query = ''; + } + + if (Array.isArray(urlParams.marcValue)) { + const arr = urlParams.marcValue as Array; + urlParams.marcValue = arr.map(q => ''); + } else { + urlParams.marcValue = ''; + } + + urlParams.identQuery = ''; + urlParams.browseTerm = ''; + } + + // Prepares a save-able URL params hash from the current context. + prepareSearch(ctx: CatalogSearchContext, + withDummyData?: boolean): {[key: string]: string | string[]} { + + const context = ctx.clone(); + + if (withDummyData) { + this.addDummyQueries(context); + } + + context.scrub(this.searchTab); + + const urlParams = this.catUrl.toUrlParams(context); + + if (withDummyData) { + this.removeDummyQueries(urlParams); + } + + // Some data should not go into the template. + delete urlParams.org; + delete urlParams.ridx; + + urlParams.searchTab = this.searchTab; + + return urlParams; + } + + saveTemplate(): Promise { + if (!this.templateName) { return Promise.resolve(); } + + this.staffCat.selectedTemplate = this.templateName; + + const urlParams = this.prepareSearch(this.context, true); + + this.templates.push( + new SearchTemplate(this.templateName, urlParams)); + + return this.applyTemplateChanges().then(_ => this.close()); + } + + applyTemplateChanges(): Promise { + return this.serverStore.setItem(SAVED_TEMPLATES_SETTING, this.templates); + } + + deleteTemplate() { + this.confirmDelete.open().subscribe(yes => { + if (!yes) { return; } + + const templates: SearchTemplate[] = []; + this.templates.forEach(tmpl => { + if (tmpl.name !== this.staffCat.selectedTemplate) { + templates.push(tmpl); + } + }); + + this.templates = templates; + this.staffCat.selectedTemplate = ''; + this.applyTemplateChanges(); + }); + } + + deleteAllTemplates() { + this.confirmDeleteAll.open().subscribe(yes => { + if (!yes) { return; } + this.templates = []; + this.staffCat.selectedTemplate = ''; + this.applyTemplateChanges(); + }); + } +} + + diff --git a/Open-ILS/src/sql/Pg/950.data.seed-values.sql b/Open-ILS/src/sql/Pg/950.data.seed-values.sql index 67b04715fd..e8dc76a1fa 100644 --- a/Open-ILS/src/sql/Pg/950.data.seed-values.sql +++ b/Open-ILS/src/sql/Pg/950.data.seed-values.sql @@ -19970,3 +19970,14 @@ VALUES ( 'cwst', 'label' ) ); + +INSERT INTO config.workstation_setting_type (name, grp, datatype, label) +VALUES ( + 'eg.catalog.search_templates', 'gui', 'object', + oils_i18n_gettext( + 'eg.catalog.search_templates', + 'Staff Catalog Search Templates', + 'cwst', 'label' + ) +); + diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.data.search-templates.sql b/Open-ILS/src/sql/Pg/upgrade/XXXX.data.search-templates.sql new file mode 100644 index 0000000000..9526bd6ba9 --- /dev/null +++ b/Open-ILS/src/sql/Pg/upgrade/XXXX.data.search-templates.sql @@ -0,0 +1,17 @@ + +BEGIN; + +--SELECT evergreen.upgrade_deps_block_check('TODO', :eg_version); + +INSERT INTO config.workstation_setting_type (name, grp, datatype, label) +VALUES ( + 'eg.catalog.search_templates', 'gui', 'object', + oils_i18n_gettext( + 'eg.catalog.search_templates', + 'Staff Catalog Search Templates', + 'cwst', 'label' + ) +); + +COMMIT; + -- 2.43.2