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')) {
}
}
- if (params.get('cnBrowseTerm')) {
+ if (params.has('cnBrowseTerm')) {
context.cnBrowseSearch.value = params.get('cnBrowseTerm');
context.cnBrowseSearch.offset = Number(params.get('cnBrowsePage'));
}
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';
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<any> {
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.
this.facetValue === filter.facetValue
);
}
+
+ clone(): FacetFilter {
+ return new FacetFilter(
+ this.facetClass, this.facetName, this.facetValue);
+ }
}
export class CatalogSearchResults {
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 {
);
}
+ 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 {
);
}
+ 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 {
}
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;
}
}
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 {
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.
this.marcSearch.reset();
this.identSearch.reset();
this.browseSearch.reset();
+ this.cnBrowseSearch.reset();
}
isSearchable(): boolean {
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;
+ }
+ }
}
--- /dev/null
+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);
+ });
+
+});
--- /dev/null
+
+/* 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;
+ }
+}
+
<eg-bucket-dialog #addBasketToBucketDialog>
</eg-bucket-dialog>
-<div class="row">
- <div class="col-lg-4 pr-1">
+<div class="d-flex justify-content-end">
+ <div class="pr-1">
<div class="float-right">
<!-- note basket view link does not propagate search params -->
<a routerLink="/staff/catalog/search" [queryParams]="{showBasket: true}"
</a>
</div>
</div>
- <div class="col-lg-8 pl-1">
- <select class="form-control"
+ <div class="">
+ <div ngbDropdown placement="bottom-right">
+ <button class="btn btn-light" id="basketActions"
[disabled]="!basketCount()"
- [(ngModel)]="basketAction" (change)="applyAction()">
- <option value='' [disabled]="true" i18n>Basket Actions...</option>
- <option value="view" i18n>View Basket</option>
- <option value="hold" i18n>Place Hold</option>
- <option value="print" i18n>Print Title Details</option>
- <option value="email" i18n>Email Title Details</option>
- <option value="bucket" i18n>Add Basket to Bucket</option>
- <option value="export_marc" i18n>Export Records</option>
- <option value="clear" i18n>Clear Basket</option>
- </select>
+ ngbDropdownToggle i18n>Basket Actions</button>
+ <div ngbDropdownMenu aria-labelledby="basketActions">
+ <button class="dropdown-item"
+ (click)="applyAction('view')" i18n>View Basket</button>
+ <button class="dropdown-item"
+ (click)="applyAction('hold')" i18n>Place Hold</button>
+ <button class="dropdown-item"
+ (click)="applyAction('print')" i18n>Print Title Details</button>
+ <button class="dropdown-item"
+ (click)="applyAction('email')" i18n>Email Title Details</button>
+ <button class="dropdown-item"
+ (click)="applyAction('bucket')" i18n>Add Basket to Bucket</button>
+ <button class="dropdown-item"
+ (click)="applyAction('export_marc')" i18n>Export Records</button>
+ <button class="dropdown-item"
+ (click)="applyAction('clear')" i18n>Clear Basket</button>
+ </div>
</div>
</div>
// TODO: confirmation dialogs?
- applyAction() {
+ applyAction(action: string) {
+ this.basketAction = action;
console.debug('Performing basket action', this.basketAction);
switch (this.basketAction) {
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: [
BrowseResultsComponent,
ConjoinedComponent,
HoldingsMaintenanceComponent,
+ SearchTemplatesComponent,
CnBrowseComponent,
CnBrowseResultsComponent
],
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;
'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']);
</div>
</div>
</div>
- <div class="row mt-2">
+ <div class="row mt-1">
+ <div class="col-lg-12">
+ <eg-catalog-search-templates [searchTab]="searchTab">
+ </eg-catalog-search-templates>
+ </div>
+ </div>
+ <div class="row mt-1">
<div class="col-lg-12">
<eg-catalog-basket-actions></eg-catalog-basket-actions>
</div>
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';
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() {
* 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() {
// 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;
}
--- /dev/null
+<eg-confirm-dialog #confirmDelete
+ i18n-dialogTitle i18n-dialogBody
+ dialogTitle="Confirm Delete"
+ dialogBody="Delete saved search template '{{selectedTemplate()}}'?">
+</eg-confirm-dialog>
+
+<eg-confirm-dialog #confirmDeleteAll
+ i18n-dialogTitle i18n-dialogBody
+ dialogTitle="Confirm Delete All"
+ dialogBody="Delete all saved templates?">
+</eg-confirm-dialog>
+
+<eg-confirm-dialog #confirmDeleteSearches
+ i18n-dialogTitle i18n-dialogBody
+ dialogTitle="Confirm clear searches"
+ dialogBody="Clear all recent searches?">
+</eg-confirm-dialog>
+
+<ng-template #searchName let-tab="tab" let-query="query" i18n>
+ <ng-container [ngSwitch]="tab">
+ <span *ngSwitchCase="'term'">Search:</span>
+ <span *ngSwitchCase="'ident'">Identifier:</span>
+ <span *ngSwitchCase="'marc'">MARC:</span>
+ <span *ngSwitchCase="'browse'">Browse:</span>
+ </ng-container> {{query}}
+</ng-template>
+
+<eg-string key='eg.catalog.recent_search.label' [template]="searchName">
+</eg-string>
+
+<div class="d-flex justify-content-end">
+
+ <ng-container *ngIf="recentSearchesCount > 0">
+ <div ngbDropdown placement="bottom-right">
+ <button class="btn btn-light" id="recentSearches"
+ ngbDropdownToggle i18n>Recent Searches</button>
+ <div ngbDropdownMenu aria-labelledby="recentSearches">
+ <button class="dropdown-item" (click)="deleteSearches()"
+ [disabled]="searches.length === 0" i18n>Clear Recent Searches</button>
+ <div class="dropdown-divider"></div>
+ <button [disabled]="true" *ngIf="searches.length === 0"
+ class="dropdown-item font-italic" i18n>No Recent Searches</button>
+ <button *ngFor="let search of sortSearches()"
+ class="dropdown-item"
+ (click)="searchSelected(search)"
+ [routerLink]="getSearchPath(search)"
+ [queryParams]="search.params">{{search.name}}</button>
+ </div>
+ </div>
+ </ng-container>
+
+ <div ngbDropdown placement="bottom-right">
+ <button class="btn btn-light" id="searchTemplates"
+ ngbDropdownToggle i18n>Search Templates</button>
+ <div ngbDropdownMenu aria-labelledby="searchTemplates">
+ <button class="dropdown-item" i18n (click)="open()"
+ [disabled]="searchTab === 'cnbrowse'">Save Template</button>
+ <button class="dropdown-item" (click)="deleteTemplate()"
+ [disabled]="!selectedTemplate()" i18n>Delete Selected</button>
+ <button class="dropdown-item" (click)="deleteAllTemplates()"
+ [disabled]="templates.length === 0" i18n>Delete All Templates</button>
+ <div class="dropdown-divider"></div>
+ <button [disabled]="true" *ngIf="templates.length === 0"
+ class="dropdown-item font-italic" i18n>No Saved Templates</button>
+ <button *ngFor="let tmpl of sortTemplates()"
+ class="dropdown-item"
+ (click)="templateSelected(tmpl)"
+ [ngClass]="{'font-weight-bold': tmpl.name === selectedTemplate()}"
+ [routerLink]="getSearchPath(tmpl)"
+ [queryParams]="tmpl.params">{{tmpl.name}}</button>
+ </div>
+ </div>
+</div>
+
+<ng-template #dialogContent>
+ <div class="modal-header bg-info">
+ <h4 class="modal-title" i18n>Save Template</h4>
+ <button type="button" class="close"
+ i18n-aria-label aria-label="Close" (click)="close()">
+ <span aria-hidden="true">×</span>
+ </button>
+ </div>
+ <div class="modal-body">
+ <div class="row">
+ <div class="col-lg-4" i18n id="templateNameLabel">Template Name:</div>
+ <div class="col-lg-6">
+ <input class="form-control" [(ngModel)]="templateName"
+ aria-labelledby="templateNameLabel"/>
+ </div>
+ </div>
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-success"
+ (click)="saveTemplate()" i18n>Save</button>
+ <button type="button" class="btn btn-warning"
+ (click)="close()" i18n>Cancel</button>
+ </div>
+</ng-template>
+
--- /dev/null
+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<any> {
+ 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<any> {
+ 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<string>;
+ urlParams.query = arr.map(q => '');
+ } else {
+ urlParams.query = '';
+ }
+
+ if (Array.isArray(urlParams.marcValue)) {
+ const arr = urlParams.marcValue as Array<string>;
+ 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<any> {
+ 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<any> {
+ 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();
+ });
+ }
+}
+
+
'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'
+ )
+);
+
--- /dev/null
+
+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;
+