LPXXX Angular Volcopy
authorBill Erickson <berickxx@gmail.com>
Tue, 23 Jun 2020 20:57:11 +0000 (16:57 -0400)
committerBill Erickson <berickxx@gmail.com>
Thu, 25 Jun 2020 14:36:18 +0000 (10:36 -0400)
Signed-off-by: Bill Erickson <berickxx@gmail.com>
Open-ILS/src/eg2/src/app/staff/cat/volcopy/config.component.html
Open-ILS/src/eg2/src/app/staff/cat/volcopy/copy-attrs.component.html
Open-ILS/src/eg2/src/app/staff/cat/volcopy/copy-attrs.component.ts
Open-ILS/src/eg2/src/app/staff/cat/volcopy/vol-edit.component.ts
Open-ILS/src/eg2/src/app/staff/cat/volcopy/volcopy.service.ts
Open-ILS/src/eg2/src/app/staff/cat/volcopy/volcopy.ts
Open-ILS/src/eg2/src/app/staff/common.module.ts

index 93b264c..00fbe36 100644 (file)
 </div>
 
 <hr class="p-2"/>
+<h3 i18n>Item Attribute Settings</h3>
+
+<div class="row">
+  <div class="col-lg-6">
+    <div class="card">
+      <div class="card-header" i18n>Item Attributes Behavior</div>
+      <ul class="list-group list-group-flush">
+        <li class="list-group-item">
+          <div class="form-check form-check-inline">
+            <input class="form-check-input" type="checkbox" 
+              id="hide-classification-column" 
+              [(ngModel)]="volcopy.defaults.values.circ_lib_mod_with_owning_lib">
+            <label class="form-check-label" 
+              for="hide-circ_lib_mod_with_owning_lib-column" i18n>
+              Change Circ Lib When Owning Lib Changes
+            </label>
+          </div>
+        </li>
+      </ul>
+    </div>
+  </div>
+</div>
+
+
+<hr class="p-2"/>
 
 <h3 i18n>Hide Item Attributes</h3>
 <span class="font-italic" i18n>
-  Selected Attributes Will be <b>Hidden</b> from the Item Attributes Form.
+  Selected Fields Will be <span class="font-weight-bold">Hidden</span>
+  from the Item Attributes Form.
 </span>
 
 <div class="row d-flex">
index 4ea94ab..e20dcac 100644 (file)
 <div class="row border rounded border-dark pt-2 pb-2 bg-faint">
   <div class="col-lg-1 font-weight-bold" i18n>Templates:</div>
   <div class="col-lg-4">
-    <eg-combobox domId="template-select" #copyTemplateCbox></eg-combobox>
+    <eg-combobox #copyTemplateCbox domId="template-select" 
+      [allowFreeText]="true" [entries]="volcopy.templateNames" 
+      [(ngModel)]="currentTemplate">
+    </eg-combobox>
   </div>
   <div class="col-lg-7 d-flex">
     <button class="btn btn-outline-dark mr-2" (click)="applyTemplate()" i18n>Apply</button>
     <button class="btn btn-outline-dark mr-2" (click)="saveTemplate()" i18n>Save</button>
+    <!--
     <button class="btn btn-outline-dark mr-2" (click)="importTemplate()" i18n>Import</button>
-    <button class="btn btn-outline-dark mr-2" (click)="exportTemplate()" i18n>Export</button>
+    -->
+
+    <!-- 
+      The type typical approach of wrapping a file input in a <label>
+      results in button-ish things that have slightly different dimensions
+    -->
+    <button class="btn btn-outline-dark mr-2" (click)="templateFile.click()">
+      <input type="file" class="d-none" #templateFile
+        (change)="importTemplate($event)" id="template-file-upload"/>
+      <span i18n>Import</span>
+    </button>
+
+    <a (click)="exportTemplate($event)"
+      download="export_copy_template.json" [href]="exportTemplateUrl()">
+      <button class="btn btn-outline-dark mr-2" i18n>Export</button>
+    </a>
+    
     <div class="flex-1"> </div>
-    <button class="btn btn-outline-dark mr-2" 
-      (click)="copyTemplateCbox.selectedId = null" i18n>Clear</button>
-    <button class="btn btn-outline-danger mr-2" (click)="deleteTemplate()" i18n>Delete Template</button>
+    <button class="btn btn-outline-danger mr-2" 
+      (click)="deleteTemplate()" i18n>Delete Template</button>
   </div>
 </div>
 
     </div>
 
     <div *ngIf="displayAttr('owning_lib')">
+      <eg-string #olLabel text="Owning Library" i18n-text></eg-string>
       <ng-template #owningLibTemplate>
         <eg-org-select 
           domId="owning-lib-input"
         </eg-org-select>
       </ng-template>
       <ng-container *ngTemplateOutlet="batchAttr;
-        context:{field:'owning_lib',template:owningLibTemplate}">
+        context:{field:'owning_lib',template:owningLibTemplate,label:olLabel.text}">
       </ng-container>
     </div>
 
           [emptyIsUnset]="true"
           [editTemplate]="statCatTemplate"
           [labelCounts]="statCatCounts(cat.id())"
+          (valueCleared)="statCatChanged(cat.id(), true)"
           (changesSaved)="statCatChanged(cat.id())">
         </eg-batch-item-attr>
       </div>
index 44d2858..bad44c6 100644 (file)
@@ -1,6 +1,7 @@
 import {Component, Input, OnInit, AfterViewInit, ViewChild,
     QueryList, ViewChildren} from '@angular/core';
 import {Router, ActivatedRoute, ParamMap} from '@angular/router';
+import {SafeUrl} from '@angular/platform-browser';
 import {tap} from 'rxjs/operators';
 import {IdlObject, IdlService} from '@eg/core/idl.service';
 import {EventService} from '@eg/core/event.service';
@@ -18,6 +19,7 @@ import {CopyAlertsDialogComponent
     } from '@eg/staff/share/holdings/copy-alerts-dialog.component';
 import {ComboboxComponent, ComboboxEntry} from '@eg/share/combobox/combobox.component';
 import {BatchItemAttrComponent} from '@eg/staff/share/holdings/batch-item-attr.component';
+import {FileExportService} from '@eg/share/util/file-export.service';
 
 @Component({
   selector: 'eg-copy-attrs',
@@ -83,6 +85,7 @@ export class CopyAttrsComponent implements OnInit, AfterViewInit {
         private holdings: HoldingsService,
         private format: FormatService,
         private store: StoreService,
+        private fileExport: FileExportService,
         public  volcopy: VolCopyService
     ) { }
 
@@ -92,7 +95,10 @@ export class CopyAttrsComponent implements OnInit, AfterViewInit {
     ngAfterViewInit() {
 
         const tmpl = this.store.getLocalItem('cat.copy.last_template');
-        if (tmpl) { this.copyTemplateCbox.selectedId = tmpl; }
+        if (tmpl) {
+            // avoid Express Changed warning w/ timeout
+            setTimeout(() => this.copyTemplateCbox.selectedId = tmpl);
+        }
 
         this.loanDurationLabelMap[1] = this.loanDurationShort.text;
         this.loanDurationLabelMap[2] = this.loanDurationNormal.text;
@@ -231,9 +237,9 @@ export class CopyAttrsComponent implements OnInit, AfterViewInit {
             this.values[field] = value;
         }
 
-        // TODO: handle circ_lib, owning_lib changes specially
-
-        console.debug('APPLYING', field, value);
+        if (field === 'owning_lib') {
+            return this.owningLibChanged(value);
+        }
 
         this.context.copyList().forEach(copy => {
             if (copy[field] && copy[field]() !== value) {
@@ -243,16 +249,82 @@ export class CopyAttrsComponent implements OnInit, AfterViewInit {
         });
     }
 
+    owningLibChanged(orgId: number) {
+        if (!orgId) { return; }
+
+        let promise = Promise.resolve();
+
+        // Map existing vol IDs to their replacments.
+        const newVols: any = {};
+
+        this.context.copyList().forEach(copy => {
+
+            // Change the copy circ lib to match the new owning lib
+            // if configured to do so.
+            if (this.volcopy.defaults.values.circ_lib_mod_with_owning_lib) {
+                if (copy.circ_lib() !== orgId) {
+                    copy.circ_lib(orgId);
+                    copy.ischanged(true);
+
+                    this.batchAttrs
+                        .filter(ba => ba.name === 'circ_lib')
+                        .forEach(attr => attr.hasChanged = true);
+                }
+            }
+
+            const vol = copy.call_number();
+
+            if (vol.owning_lib() === orgId) { return; } // No change needed
+
+            let newVol;
+            if (newVols[vol.id()]) {
+                newVol = newVols[vol.id()];
+
+            } else {
+
+                // The open-ils.cat.asset.volume.fleshed.batch.update API
+                // will use the existing volume when trying to create a
+                // new volume with the same parameters as an existing volume.
+                newVol = this.idl.clone(vol);
+                newVol.owning_lib(orgId);
+                newVol.id(this.volcopy.autoId--);
+                newVol.isnew(true);
+                newVols[vol.id()] = newVol;
+            }
+
+            copy.call_number(newVol);
+            copy.ischanged();
+
+            this.context.removeCopyNode(copy.id());
+            this.context.findOrCreateCopyNode(copy);
+        });
+
+        // If any of the above actions results in an empty volume
+        // remove it from the tree.  Note this does not delete the
+        // volume at the server, since other items could be attached
+        // of which this instance of the editor is not aware.
+        Object.keys(newVols).forEach(volId => {
+            const volNode = this.context.volNodes().filter(
+                volNode => volNode.target.id() === +volId)[0];
+
+            if (volNode && volNode.children.length === 0) {
+                this.context.removeVolNode(+volId);
+            }
+        });
+    }
+
     // Create or modify a stat cat entry for each copy that does not
     // already match the new value.
-    statCatChanged(catId: number) {
+    statCatChanged(catId: number, clear?: boolean) {
         catId = Number(catId);
 
         const entryId = this.statCatValues[catId];
+
         this.context.copyList().forEach(copy => {
 
             let entry = copy.stat_cat_entries()
                 .filter(e => e.stat_cat() === catId)[0];
+                console.log('0', entry);
 
             if (entry) {
                 if (entry.id() === entryId) {
@@ -265,6 +337,7 @@ export class CopyAttrsComponent implements OnInit, AfterViewInit {
                 entry = this.idl.create('asce');
                 entry.stat_cat(catId);
                 copy.stat_cat_entries().push(entry);
+                console.log('1', entry);
             }
 
             entry.id(entryId);
@@ -299,6 +372,8 @@ export class CopyAttrsComponent implements OnInit, AfterViewInit {
 
         this.store.setLocalItem('cat.copy.last_template', entry.id);
 
+        // TODO: handle owning_lib
+
         const template = this.volcopy.templates[entry.id];
 
         Object.keys(template).forEach(field => {
@@ -306,14 +381,128 @@ export class CopyAttrsComponent implements OnInit, AfterViewInit {
 
             if (value === null || value === undefined) { return; }
 
-            this.applyCopyValue(field, value);
+            if (field === 'statcats') {
+                Object.keys(value).forEach(catId => {
+                    this.statCatValues[+catId] = value[+catId];
+                    this.statCatChanged(+catId);
+                });
+                return;
+            }
+
+            // In some cases, we may have to fetch the data since
+            // the local code assumes copy field is fleshed.
+            let promise = Promise.resolve(value);
+
+            if (field === 'location') {
+                // May be a 'remote' location.  Fetch as needed.
+                promise = this.volcopy.getLocation(value);
+            }
+
+            promise.then(val => {
+                this.applyCopyValue(field, val);
+
+                // Indicate in the form these values have changed
+                this.batchAttrs
+                    .filter(ba => ba.name === field)
+                    .forEach(attr => attr.hasChanged = true);
+            });
         });
     }
 
     saveTemplate() {
+        const entry: ComboboxEntry = this.copyTemplateCbox.selected;
+        if (!entry) { return; }
+
+        let name;
+        let template;
+
+        if (entry.freetext) {
+            name = entry.label;
+            // freetext entries don't have an ID, but we may need one later.
+            entry.id = entry.label;
+            template = {};
+        } else {
+            name = entry.id;
+            template = this.volcopy.templates[name];
+        }
+
+        // Changes will have applied to all items.
+        const copy = this.context.copyList()[0];
+
         this.batchAttrs.forEach(comp => {
-            console.log(comp.editInputDomId);
+            if (!comp.hasChanged) { return; }
+
+            const value = copy[comp.name]();
+            const name = comp.name;
+
+            if (value === null) {
+                delete template[name];
+                return;
+            }
+
+            if (name.match(/stat_cat_/)) {
+                const statId = name.match(/stat_cat_(\d+)/)[1];
+                if (!template.statcats) { template.statcats = {}; }
+
+                template.statcats[statId] = value;
+
+            } else {
+
+                // some values are fleshed.
+                // this assumes fleshed objects have an 'id' value,
+                // which is true so far.
+                template[name] =
+                    typeof value === 'object' ?  value.id() : value;
+            }
         });
+
+        this.volcopy.templates[name] = template;
+        this.volcopy.saveTemplates();
+    }
+
+    exportTemplate($event) {
+        if (this.fileExport.inProgress()) { return; }
+
+        this.fileExport.exportFile(
+            $event, JSON.stringify(this.volcopy.templates), 'text/json');
+    }
+
+    importTemplate($event) {
+        const file: File = $event.target.files[0];
+        if (!file) { return; }
+
+        const reader = new FileReader();
+
+        reader.addEventListener('load', () => {
+
+            try {
+                const template = JSON.parse(reader.result as string);
+                const name = Object.keys(template)[0];
+                this.volcopy.templates[name] = template[name];
+            } catch (E) {
+                console.error('Invalid Item Attribute template', E);
+                return;
+            }
+
+            this.volcopy.saveTemplates();
+            // Adds the new one to the list and re-sorts the labels.
+            this.volcopy.fetchTemplates();
+        });
+
+        reader.readAsText(file);
+    }
+
+    // Returns null when no export is in progress.
+    exportTemplateUrl(): SafeUrl {
+        return this.fileExport.safeUrl;
+    }
+
+    deleteTemplate() {
+        const entry: ComboboxEntry = this.copyTemplateCbox.selected;
+        if (!entry) { return; }
+        delete this.volcopy.templates[entry.id];
+        this.volcopy.saveTemplates();
+        this.copyTemplateCbox.selected = null;
     }
 
     displayAttr(field: string): boolean {
index b6948a4..f01c346 100644 (file)
@@ -40,8 +40,6 @@ export class VolEditComponent implements OnInit {
     autoBarcodeInProgress = false;
     useCheckdigit = false;
 
-    autoId = -1;
-
     deleteVolCount: number = null;
     deleteCopyCount: number = null;
 
index d9ce8a0..63081e3 100644 (file)
@@ -15,6 +15,8 @@ import {ComboboxComponent, ComboboxEntry} from '@eg/share/combobox/combobox.comp
 
 /* Managing volcopy data */
 
+
+
 interface VolCopyDefaults {
     values: {[field: string]: any},
     hidden: {[field: string]: boolean}
@@ -25,10 +27,15 @@ export class VolCopyService {
 
     autoId = -1;
 
+    localOrgs: number[];
     defaults: VolCopyDefaults = null;
     defaultLocation: IdlObject;
     copyStatuses: {[id: number]: IdlObject} = null;
 
+    // This will be all 'local' copy locations plus any remote
+    // locations that we are required to interact with.
+    copyLocationMap: {[id: number]: IdlObject} = {};
+
     // Track this here so it can survive route changes.
     currentContext: VolCopyContext;
 
@@ -63,9 +70,10 @@ export class VolCopyService {
 
         if (this.itemTypeMaps.length > 0) { return Promise.resolve(); }
 
-        const myOrgs = this.org.fullPath(this.auth.user().ws_ou(), true);
+        this.localOrgs = this.org.fullPath(this.auth.user().ws_ou(), true);
 
         return this.fetchDefaults()
+        .then(_ => this.getLocations())
         .then(_ => this.holdings.fetchCallNumberClasses())
         .then(cls => this.volClasses = cls)
 
@@ -73,7 +81,7 @@ export class VolCopyService {
             return this.holdings.fetchCallNumberPrefixes().then(prefixes =>
                 this.volPrefixes = prefixes
                     .filter(sfx => sfx.id() !== -1)
-                    .filter(pfx => myOrgs.includes(pfx.owning_lib()))
+                    .filter(pfx => this.localOrgs.includes(pfx.owning_lib()))
             );
         })
 
@@ -81,7 +89,7 @@ export class VolCopyService {
             return this.holdings.fetchCallNumberSuffixes().then(suffixes =>
                 this.volSuffixes = suffixes
                     .filter(sfx => sfx.id() !== -1)
-                    .filter(sfx => myOrgs.includes(sfx.owning_lib()))
+                    .filter(sfx => this.localOrgs.includes(sfx.owning_lib()))
             );
         })
 
@@ -157,6 +165,26 @@ export class VolCopyService {
         });
     }
 
+    getLocation(id: number): Promise<IdlObject> {
+        if (this.copyLocationMap[id]) {
+            return Promise.resolve(this.copyLocationMap[id]);
+        }
+
+        return this.pcrud.retrieve('acpl', id)
+            .pipe(tap(loc => this.copyLocationMap[loc.id()] = loc))
+            .toPromise();
+    }
+
+    getLocations(): Promise<any> {
+        this.localOrgs = this.org.fullPath(this.auth.user().ws_ou(), true);
+
+        return this.pcrud.search('acpl',
+            {deleted: 'f', owning_lib: this.localOrgs},
+            {order_by: {acpl: 'name'}}
+        ).pipe(tap(loc => this.copyLocationMap[loc.id()] = loc)
+        ).toPromise();
+    }
+
     fetchTemplates(): Promise<any> {
 
         // TODO: copy templates should be server settings
@@ -171,6 +199,14 @@ export class VolCopyService {
         return Promise.resolve();
     }
 
+
+    saveTemplates(): Promise<any> {
+        // TODO: templates should be stored on the server.
+        this.store.setLocalItem('cat.copy.templates', this.templates);
+        // Re-sort, etc.
+        return this.fetchTemplates();
+    }
+
     fetchDefaults(): Promise<any> {
         if (this.defaults) { return Promise.resolve(); }
 
@@ -184,9 +220,9 @@ export class VolCopyService {
             // Use the first non-deleted copy location within org unit
             // range as the default.  Typically "Stacks".
 
-            const myOrgs = this.org.fullPath(this.auth.user().ws_ou(), true);
+            this.localOrgs = this.org.fullPath(this.auth.user().ws_ou(), true);
             return this.pcrud.search('acpl',
-                {deleted: 'f', owning_lib: myOrgs},
+                {deleted: 'f', owning_lib: this.localOrgs},
                 {order_by: {acpl: 'id'}, limit: 1}
             ).toPromise().then(loc => this.defaultLocation = loc);
         });
index d751c7e..b47b59d 100644 (file)
@@ -149,6 +149,28 @@ export class VolCopyContext {
         return node;
     }
 
+    removeVolNode(volId: number) {
+        this.orgNodes().forEach(orgNode => {
+            for (let idx = 0; idx < orgNode.children.length; idx++) {
+                if (orgNode.children[idx].target.id() === volId) {
+                    orgNode.children.splice(idx, 1);
+                    break;
+                }
+            }
+        });
+    }
+
+    removeCopyNode(copyId: number) {
+        this.volNodes().forEach(volNode => {
+            for (let idx = 0; idx < volNode.children.length; idx++) {
+                if (volNode.children[idx].target.id() === copyId) {
+                    volNode.children.splice(idx, 1);
+                    break;
+                }
+            }
+        });
+    }
+
     sortHoldings() {
 
         this.orgNodes().forEach(orgNode => {
index 85dd7fc..f6f4140 100644 (file)
@@ -18,6 +18,7 @@ import {MultiSelectComponent} from '@eg/share/multi-select/multi-select.componen
 import {NotBeforeMomentValidatorDirective} from '@eg/share/validators/not_before_moment_validator.directive';
 import {PatronBarcodeValidatorDirective} from '@eg/share/validators/patron_barcode_validator.directive';
 import {BroadcastService} from '@eg/share/util/broadcast.service';
+import {FileExportService} from '@eg/share/util/file-export.service';
 
 /**
  * Imports the EG common modules and adds modules common to all staff UI's.
@@ -71,7 +72,8 @@ export class StaffCommonModule {
             providers: [ // Export staff-wide services
                 AccessKeyService,
                 AudioService,
-                BroadcastService
+                BroadcastService,
+                FileExportService
             ]
         };
     }