--- /dev/null
+import {Injectable} 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[];
+
+ constructor(
+ private net: NetService,
+ private pcrud: PcrudService,
+ private store: StoreService,
+ private anonCache: AnonCacheService
+ ) { this.idList = []; }
+
+ hasRecordId(id: number): boolean {
+ return this.idList.indexOf(Number(id)) > -1;
+ }
+
+ recordCount(): number {
+ return this.idList.length;
+ }
+
+ // TODO: Add server-side API for sorting a set of bibs by ID.
+ // See EGCatLoader/Container::fetch_mylist
+ getRecordIds(): Promise<number[]> {
+ const cacheKey = this.store.getLoginSessionItem(BASKET_CACHE_KEY_COOKIE);
+ this.idList = [];
+
+ if (!cacheKey) { return Promise.resolve(this.idList); }
+
+ return this.anonCache.getItem(cacheKey, BASKET_CACHE_ATTR).then(
+ list => {
+ if (!list) {return this.idList};
+ this.idList = list.map(id => Number(id));
+ return this.idList;
+ }
+ );
+ }
+
+ setRecordIds(ids: number[]): Promise<number[]> {
+ this.idList = ids;
+
+ // If we have no cache key, that's OK, assume this is the first
+ // attempt at adding a value and let the server create the cache
+ // key for us, then store the value in our cookie.
+ const cacheKey = this.store.getLoginSessionItem(BASKET_CACHE_KEY_COOKIE);
+
+ return this.anonCache.setItem(cacheKey, BASKET_CACHE_ATTR, this.idList)
+ .then(cacheKey => {
+ this.store.setLoginSessionItem(BASKET_CACHE_KEY_COOKIE, cacheKey);
+ return this.idList;
+ });
+ }
+
+ addRecordIds(ids: number[]): Promise<number[]> {
+ ids = ids.filter(id => !this.hasRecordId(id)); // avoid dupes
+ return this.setRecordIds(
+ this.idList.concat(ids.map(id => Number(id))));
+ }
+
+ removeRecordIds(ids: number[]): Promise<number[]> {
+ const wantedIds = this.idList.filter(
+ id => ids.indexOf(Number(id)) < 0);
+ return this.setRecordIds(wantedIds);
+ }
+}
+
+
import {NgModule} from '@angular/core';
import {EgCommonModule} from '@eg/common.module';
import {CatalogService} from './catalog.service';
+import {AnonCacheService} from '@eg/share/util/anon-cache.service'
+import {BasketService} from './basket.service';
import {CatalogUrlService} from './catalog-url.service';
import {BibRecordService} from './bib-record.service';
import {UnapiService} from './unapi.service';
MarcHtmlComponent
],
providers: [
+ AnonCacheService,
CatalogService,
CatalogUrlService,
UnapiService,
- BibRecordService
+ BibRecordService,
+ BasketService,
]
})
--- /dev/null
+/**
+ * Service for communicating with the server-side "anonymous" cache.
+ */
+import {Injectable} from '@angular/core';
+import {Observable} from 'rxjs/Observable';
+import {StoreService} from '@eg/core/store.service';
+import {NetService} from '@eg/core/net.service';
+
+// All anon-cache data is stored in a single blob per user session.
+// Value is generated on the server with the first call to set_value
+// and stored locally as a LoginSession item (cookie).
+
+@Injectable()
+export class AnonCacheService {
+
+ constructor(private store: StoreService, private net: NetService) {}
+
+ getItem(cacheKey: string, attr: string): Promise<any> {
+ return this.net.request(
+ 'open-ils.actor',
+ 'open-ils.actor.anon_cache.get_value', cacheKey, attr
+ ).toPromise();
+ }
+
+ // Apply 'value' to field 'attr' in the object cached at 'cacheKey'.
+ // If no cacheKey is provided, the server will generate one.
+ // Returns a promised resolved with the cache key.
+ setItem(cacheKey: string, attr: string, value: any): Promise<string> {
+ return this.net.request(
+ 'open-ils.actor',
+ 'open-ils.actor.anon_cache.set_value',
+ cacheKey, attr, value
+ ).toPromise().then(cacheKey => {
+ if (cacheKey) {
+ return cacheKey;
+ } else {
+ return Promise.reject(
+ `Could not apply a value for attr=${attr} cacheKey=${cacheKey}`);
+ }
+ })
+ }
+
+ removeItem(cacheKey: string, attr: string): Promise<string> {
+ return this.net.request(
+ 'open-ils.actor',
+ 'open-ils.actor.anon_cache.set_value',
+ cacheKey, attr, null
+ ).toPromise();
+ }
+
+ clear(cacheKey: string): Promise<string> {
+ return this.net.request(
+ 'open-ils.actor',
+ 'open-ils.actor.anon_cache.delete_session', cacheKey
+ ).toPromise();
+ }
+}
+
+
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();
}
}
--- /dev/null
+
+/**
+ * Force the jacket image column to consume a consistent amount of
+ * horizontal space, while allowing some room for the browser to
+ * render the correct aspect ratio.
+ */
+.record-jacket-div {
+ width: 68px;
+}
+
+.record-jacket-div img {
+ height: 100%;
+ max-height:80px;
+ max-width: 54px;
+}
<div class="col-lg-12 card tight-card mb-2 bg-light">
<div class="card-body">
<div class="row">
- <div class="col-lg-2 d-flex">
- <div class="checkbox">
+ <!-- Checkbox, jacket image, and title blob live in a flex row
+ because there's no way to give them col-lg-* columns that
+ don't waste a lot of space. -->
+ <div class="col-lg-6 d-flex">
+ <label class="checkbox">
<span class="font-weight-bold font-italic">
{{index + 1 + searchContext.pager.offset}}.
</span>
- <input type='checkbox'/>
- </div>
- <div class="pl-2">
+ <input class="pl-1" type='checkbox' [(ngModel)]="isRecordInBasket"
+ (change)="toggleBasketEntry()"/>
+ </label>
+ <!-- XXX hard-coded width so columns align vertically regardless
+ of the presence of a jacket image -->
+ <div class="pl-2 record-jacket-div" >
<a href="javascript:void(0)" (click)="navigatToRecord(summary.id)">
- <img style="height:80px"
- src="/opac/extras/ac/jacket/small/r/{{summary.id}}"/>
+ <img src="/opac/extras/ac/jacket/small/r/{{summary.id}}"/>
</a>
</div>
- </div>
- <div class="col-lg-4">
- <div class="row">
- <div class="col-lg-12 font-weight-bold">
- <!-- nbsp allows the column to take shape when no value exists -->
- <a href="javascript:void(0)"
- (click)="navigatToRecord(summary.id)">
- {{summary.display.title || ' '}}
- </a>
+ <div class="flex-1 pl-2">
+ <div class="row">
+ <div class="col-lg-12 font-weight-bold">
+ <!-- nbsp allows the column to take shape when no value exists -->
+ <a href="javascript:void(0)"
+ (click)="navigatToRecord(summary.id)">
+ {{summary.display.title || ' '}}
+ </a>
+ </div>
</div>
- </div>
- <div class="row pt-2">
- <div class="col-lg-12">
- <!-- nbsp allows the column to take shape when no value exists -->
- <a href="javascript:void(0)"
- (click)="searchAuthor(summary)">
- {{summary.display.author || ' '}}
- </a>
+ <div class="row pt-2">
+ <div class="col-lg-12">
+ <!-- nbsp allows the column to take shape when no value exists -->
+ <a href="javascript:void(0)"
+ (click)="searchAuthor(summary)">
+ {{summary.display.author || ' '}}
+ </a>
+ </div>
</div>
- </div>
- <div class="row pt-2">
- <div class="col-lg-12">
- <!-- only shows the first icon format -->
- <span *ngIf="summary.attributes.icon_format && summary.attributes.icon_format[0]">
- <img class="pr-1"
- src="/images/format_icons/icon_format/{{summary.attributes.icon_format[0]}}.png"/>
- <span>{{iconFormatLabel(summary.attributes.icon_format[0])}}</span>
- </span>
- <span class='pl-1'>{{summary.display.edition}}</span>
- <span class='pl-1'>{{summary.display.pubdate}}</span>
+ <div class="row pt-2">
+ <div class="col-lg-12">
+ <!-- only shows the first icon format -->
+ <span *ngIf="summary.attributes.icon_format && summary.attributes.icon_format[0]">
+ <img class="pr-1"
+ src="/images/format_icons/icon_format/{{summary.attributes.icon_format[0]}}.png"/>
+ <span>{{iconFormatLabel(summary.attributes.icon_format[0])}}</span>
+ </span>
+ <span class='pl-1'>{{summary.display.edition}}</span>
+ <span class='pl-1'>{{summary.display.pubdate}}</span>
+ </div>
</div>
</div>
</div>
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 {
@Input() index: number; // 0-index display row
@Input() summary: BibRecordSummary;
searchContext: CatalogSearchContext;
+ isRecordInBasket: boolean;
constructor(
private router: Router,
private bib: BibRecordService,
private cat: CatalogService,
private catUrl: CatalogUrlService,
- private staffCat: StaffCatalogService
+ private staffCat: StaffCatalogService,
+ private basket: BasketService
) {}
ngOnInit() {
this.searchContext = this.staffCat.searchContext;
this.summary.getHoldCount();
+ this.isRecordInBasket = this.basket.hasRecordId(this.summary.id);
}
orgName(orgId: number): string {
['/staff/catalog/record/' + id], {queryParams: params});
}
+ toggleBasketEntry() {
+ if (this.isRecordInBasket) {
+ return this.basket.addRecordIds([this.summary.id]);
+ } else {
+ return this.basket.removeRecordIds([this.summary.id]);
+ }
+ }
}
-<div id="staff-catalog-results-container" *ngIf="searchIsDone()">
+<!-- search results progress bar -->
+<div class="row" *ngIf="searchIsActive()">
+ <div class="col-lg-6 offset-lg-3 pt-3">
+ <div class="progress">
+ <div class="progress-bar progress-bar-striped active w-100"
+ role="progressbar" aria-valuenow="100"
+ aria-valuemin="0" aria-valuemax="100">
+ <span class="sr-only" i18n>Searching..</span>
+ </div>
+ </div>
+ </div>
+</div>
+
+<!-- no items found -->
+<div *ngIf="searchIsDone() && !searchHasResults()">
+ <div class="row pt-3">
+ <div class="col-lg-6 offset-lg-3">
+ <div class="alert alert-warning">
+ <span i18n>No Maching Items Were Found</span>
+ </div>
+ </div>
+ </div>
+</div>
+
+
+<!-- header, pager, and list of records -->
+<div id="staff-catalog-results-container" *ngIf="searchHasResults()">
<div class="row">
<div class="col-lg-2"><!--match pagination margin-->
<h3 i18n>Search Results ({{searchContext.result.count}})</h3>
</div>
- <div class="col-lg-1"></div>
- <div class="col-lg-9">
+ <div class="col-lg-2">
+ <label class="checkbox">
+ <input type='checkbox'/>
+ <span class="pl-1" i18n>Select {{searchContext.pager.rowNumber(0)}} -
+ {{searchContext.pager.rowNumber(searchContext.pager.limit - 1)}}
+ </span>
+ </label>
+ </div>
+ <div class="col-lg-8">
<div class="float-right">
<eg-catalog-result-pagination></eg-catalog-result-pagination>
</div>
</div>
</div>
+
return this.searchContext.searchState === CatalogSearchState.COMPLETE;
}
+ searchIsActive(): boolean {
+ return this.searchContext.searchState === CatalogSearchState.SEARCHING;
+ }
+
+ searchHasResults(): boolean {
+ return this.searchIsDone() && this.searchContext.result.count > 0;
+ }
}
<div class="checkbox">
<label>
<input type="checkbox" [(ngModel)]="searchContext.available"/>
- <span i18n>Limit to Available</span>
+ <span class="pl-1" i18n>Limit to Available</span>
</label>
</div>
</div>
<div class="checkbox">
<label>
<input type="checkbox" [(ngModel)]="searchContext.global"/>
- <span i18n>Show Results from All Libraries</span>
+ <span class="pl-1" i18n>Show Results from All Libraries</span>
</label>
</div>
</div>
</div>
</div>
<div class="col-lg-3">
+ <!--
<div *ngIf="searchIsActive()">
<div class="progress">
<div class="progress-bar progress-bar-striped active w-100"
</div>
</div>
</div>
+ -->
</div>
</div>
<div class="row pt-2" *ngIf="showAdvanced()">