record checkboxes continued; anon cache, etc.
authorBill Erickson <berickxx@gmail.com>
Mon, 12 Nov 2018 23:22:30 +0000 (18:22 -0500)
committerBill Erickson <berickxx@gmail.com>
Fri, 30 Nov 2018 16:34:20 +0000 (11:34 -0500)
Signed-off-by: Bill Erickson <berickxx@gmail.com>
Open-ILS/src/eg2/src/app/share/catalog/basket.service.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/catalog/catalog-common.module.ts
Open-ILS/src/eg2/src/app/share/util/anon-cache.service.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/catalog/catalog.component.ts
Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.css [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.html
Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.ts
Open-ILS/src/eg2/src/app/staff/catalog/result/results.component.html
Open-ILS/src/eg2/src/app/staff/catalog/result/results.component.ts
Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.html

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 (file)
index 0000000..46efcc4
--- /dev/null
@@ -0,0 +1,82 @@
+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);
+    }
+}
+
+
index c370b30..eeaf38a 100644 (file)
@@ -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/util/anon-cache.service.ts b/Open-ILS/src/eg2/src/app/share/util/anon-cache.service.ts
new file mode 100644 (file)
index 0000000..29c168d
--- /dev/null
@@ -0,0 +1,59 @@
+/**
+ * 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();
+    }
+}
+
+
index 8b2206c..0e2fc98 100644 (file)
@@ -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/result/record.component.css b/Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.css
new file mode 100644 (file)
index 0000000..3077d9a
--- /dev/null
@@ -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;
+}
index 971f8de..6bf3ec9 100644 (file)
@@ -9,49 +9,54 @@
 <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 || '&nbsp;'}}
-            </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 || '&nbsp;'}}
+              </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 || '&nbsp;'}}
-            </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 || '&nbsp;'}}
+              </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>
index bfcfd45..d0b12e8 100644 (file)
@@ -7,16 +7,19 @@ 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 {
 
     @Input() index: number;  // 0-index display row
     @Input() summary: BibRecordSummary;
     searchContext: CatalogSearchContext;
+    isRecordInBasket: boolean;
 
     constructor(
         private router: Router,
@@ -25,12 +28,14 @@ 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.isRecordInBasket = this.basket.hasRecordId(this.summary.id);
     }
 
     orgName(orgId: number): string {
@@ -72,6 +77,13 @@ export class ResultRecordComponent implements OnInit {
           ['/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]);
+        }
+    }
 }
 
 
index 47589b0..c484595 100644 (file)
@@ -1,11 +1,44 @@
 
-<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>
@@ -30,3 +63,4 @@
   </div>
 </div>
 
+
index e2eadc8..39f4e5c 100644 (file)
@@ -91,6 +91,13 @@ 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;
+    }
 }
 
 
index da54f4a..b604c71 100644 (file)
@@ -132,7 +132,7 @@ TODO focus search input
         <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>
@@ -140,7 +140,7 @@ TODO focus search input
         <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>
@@ -149,6 +149,7 @@ TODO focus search input
       </div>
     </div>
     <div class="col-lg-3">
+      <!--
       <div *ngIf="searchIsActive()">
         <div class="progress">
           <div class="progress-bar progress-bar-striped active w-100"
@@ -158,6 +159,7 @@ TODO focus search input
           </div>
         </div>
       </div>
+      -->
     </div>
   </div>
   <div class="row pt-2" *ngIf="showAdvanced()">