LP#1779158 Match set UI managing nodes
authorBill Erickson <berickxx@gmail.com>
Mon, 23 Jul 2018 21:43:51 +0000 (17:43 -0400)
committerBill Erickson <berickxx@gmail.com>
Thu, 11 Oct 2018 18:56:30 +0000 (14:56 -0400)
Signed-off-by: Bill Erickson <berickxx@gmail.com>
Open-ILS/src/eg2/src/app/staff/cat/vandelay/match-set-expression.component.html
Open-ILS/src/eg2/src/app/staff/cat/vandelay/match-set-expression.component.ts

index 4f206d5..98404ee 100644 (file)
+<ng-template #nodeStrTmpl let-point="point" let-showmatch="showmatch" i18n>
+  <ng-container *ngIf="point">
+    <span *ngIf="point.negate()">NOT </span>
+    <span *ngIf="point.heading()">Normalized Heading</span>
+    <span>{{point.bool_op()}}{{point.svf()}}{{point.tag()}}</span>
+    <span *ngIf="point.subfield()"> ‡{{point.subfield()}}</span>
+    <span *ngIf="showmatch && !point.bool_op()"> | Match score {{point.quality()}}</span>
+  </ng-container>
+</ng-template>
+<eg-string key="staff.cat.vandelay.matchpoint.label" 
+  [template]="nodeStrTmpl"></eg-string>
 
 <div class="row mt-2">
-  <div class="col-lg-4">
+  <div class="col-lg-7">
+    <div class="row ml-2">
+      <span class="text-white bg-dark p-2" i18n>
+        Your Expression: {{expressionAsString()}}
+      </span>
+    </div>
+    <div class="row ml-2 mt-4">
+      <span class="mr-2" i18n>Add New:</span>
+      <button class="btn btn-outline-dark mr-2" *ngIf="matchSetType=='biblio'"
+        (click)="setNewPointType('attr')" i18n>Record Attribute</button>
+      <button class="btn btn-outline-dark mr-2" 
+        (click)="setNewPointType('marc')" i18n>MARC Tag and Subfield</button>
+      <button class="btn btn-outline-dark mr-2" *ngIf="matchSetType=='authority'"
+        (click)="setNewPointType('heading')" i18n>Normalized Authority Heading</button>
+      <button class="btn btn-outline-dark mr-2" 
+        (click)="setNewPointType('bool')" i18n>Boolean Operator</button>
+    </div>
+    <div class="row ml-2 mt-4 p-2 border border-secondary" *ngIf="newPointType">
+      <div class="col-lg-12 common-form striped-odd form-validated">
+        <ng-container *ngIf="newPointType=='attr'">
+          <div class="row mb-1">
+            <div class="col-lg-3" i18n>Record Attribute:</div>
+            <div class="col-lg-4">
+              <eg-combobox [entries]="bibAttrDefEntries"
+                [required]="true" 
+                (onChange)="newRecordAttr=$event ? $event.id : ''"
+                placeholder="Record Attribute..." i18n-placeholder>                       
+              </eg-combobox>  
+            </div>
+          </div>
+        </ng-container>
+        <ng-container *ngIf="newPointType=='marc'">
+          <div class="row mb-1">
+            <div class="col-lg-3" i18n>Tag:</div>
+            <div class="col-lg-2">
+              <input required type="text" class="form-control" [(ngModel)]="newMarcTag"/>
+            </div>
+          </div>
+          <div class="row mb-1">
+            <div class="col-lg-3" i18n>Subfield ‡:</div>
+            <div class="col-lg-2">
+              <input required type="text" class="form-control" [(ngModel)]="newMarcSf"/>
+            </div>
+          </div>
+        </ng-container>
+        <ng-container *ngIf="newPointType=='heading'">
+          <div class="row mb-1">
+            <div class="col-lg-3" i18n>Normalized Heading:</div>
+            <div class="col-lg-2">
+              <input type="checkbox" class="form-check-input" checked disabled/>
+            </div>
+          </div>
+        </ng-container>
+        <ng-container *ngIf="newPointType!='bool'">
+          <div class="row mb-1">
+            <div class="col-lg-3">Match Score:</div>
+            <div class="col-lg-2">
+              <input required type="number" class="form-control" 
+                [(ngModel)]="newMatchScore" step="0.1"/>
+            </div>
+          </div>
+          <div class="row mb-1">
+            <div class="col-lg-3">Negate:</div>
+            <div class="col-lg-2">
+              <input type="checkbox" 
+                class="form-check-input" [(ngModel)]="newNegate"/>
+            </div>
+          </div>
+        </ng-container>
+        <ng-container *ngIf="newPointType=='bool'">
+          <div class="row mb-1">
+            <div class="col-lg-3">Operator:</div>
+            <div class="col-lg-2">
+              <select class="form-control" [(ngModel)]="newBoolOp">
+                <option value='AND' i18n>AND</option>
+                <option value='OR' i18n>OR</option>
+              </select>
+            </div>
+          </div>
+        </ng-container>
+        <div class="row mt-2">
+          <button class="btn btn-success" (click)="addChildNode()" 
+            [disabled]="!selectedIsBool()" i18n>
+            Add To Selected Node
+          </button>
+        </div>
+        <div class="row mt-2 font-italic">
+          <ol i18n>
+            <li>Define a new match point using the above fields.</li>
+            <li>Select a boolean node in the tree.</li>
+            <li>Click the "Add..." button to add the new matchpoint
+              as a child of the selected node.</li>
+          </ol>
+        </div>
+      </div>
+    </div>
+  </div>
+  <div class="col-lg-5">
     <ng-container *ngIf="tree">
       <div class="d-flex">
         <button class="btn btn-warning mr-1" (click)="deleteNode()" 
-          [disabled]="!hasActiveNode()" i18n>
+          [disabled]="!hasSelectedNode()" i18n>
           Remove Selected Node
         </button>
+        <button class="btn btn-success mr-1" (click)="saveTree()"
+          [disabled]="!changesMade" i18n>
+          Save Changes 
+        </button>
       </div>
       <div class="pt-2">
         <eg-tree
         </eg-tree>
       </div>
     </ng-container>
+  </div>
 </div>
 
index 33eb9be..5a77ed9 100644 (file)
@@ -1,8 +1,10 @@
 import {Component, OnInit, ViewChild, AfterViewInit, Input} from '@angular/core';
-import {IdlObject} from '@eg/core/idl.service';
+import {IdlObject, IdlService} from '@eg/core/idl.service';
 import {PcrudService} from '@eg/core/pcrud.service';
 import {OrgService} from '@eg/core/org.service';
 import {Tree, TreeNode} from '@eg/share/tree/tree';
+import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
+import {StringService} from '@eg/share/string/string.service';
 
 @Component({
   selector: 'eg-match-set-expression',
@@ -15,6 +17,7 @@ export class MatchSetExpressionComponent implements OnInit {
     @Input() set matchSet(ms: IdlObject) {
         this.matchSet_ = ms;
         if (ms && !this.initDone) {
+            this.matchSetType = ms.mtype();
             this.initDone = true;
             this.refreshTree();
         }
@@ -22,35 +25,72 @@ export class MatchSetExpressionComponent implements OnInit {
 
     tree: Tree;
     initDone: boolean;
+    matchSetPoints: {[id: string]: IdlObject};
+    matchSetType: string;
+    changesMade: boolean;
+
+    // Current type of new match point
+    newPointType: string;
+    newRecordAttr: string;
+    newMatchScore: number;
+    newNegate: boolean;
+    newMarcTag: string;
+    newMarcSf: string;
+    newHeading: string;
+    newBoolOp: string;
+    newId: number;
+
+    bibAttrDefs: IdlObject[];
+    bibAttrDefEntries: ComboboxEntry[];
 
     constructor(
+        private idl: IdlService,
         private pcrud: PcrudService,
-        private org: OrgService
-    ) { }
+        private org: OrgService,
+        private strings: StringService
+    ) {
+        this.bibAttrDefs = [];
+        this.bibAttrDefEntries = [];
+        this.matchSetPoints = {};
+        this.newId = -1;
+    }
 
     ngOnInit() {
+
+        this.pcrud.retrieveAll('crad', {order_by: {crad: 'label'}})
+        .subscribe(attr => {
+            this.bibAttrDefs.push(attr);
+            this.bibAttrDefEntries.push({id: attr.name(), label: attr.label()});
+        });
     }
 
-    refreshTree() {
+    refreshTree(): Promise<any> {
         if (!this.matchSet_) { return; }
+        this.matchSetPoints = {};
 
-        this.pcrud.search('vmsp', 
-            {match_set: this.matchSet_.id()}, {}, {atomic: true}
+        return this.pcrud.search('vmsp',
+            {match_set: this.matchSet_.id()}, {}, 
+            {atomic: true, authoritative: true}
         ).toPromise().then(points => {
 
             // create tree nodes
             const nodes = [];
             const idmap: any = {};
             points.forEach(point => {
+
+                // Track the from-database point objects
+                this.matchSetPoints[point.id()] = point;
+
+                point.negate(point.negate() === 't' ? true : false);
+                point.heading(point.heading() === 't' ? true : false);
+
                 const node = new TreeNode({
                     id: point.id(),
                     expanded: true,
-                    label: point.bool_op() 
-                        || point.svf() 
-                        || (point.tag() + ' ‡' + point.subfield())
+                    callerData: {point: point}
                 });
-                nodes.push(node);
                 idmap[node.id + ''] = node;
+                this.setNodeLabel(node, point).then(() => nodes.push(node));
             });
 
             // then apply the parent/child relationship
@@ -66,17 +106,129 @@ export class MatchSetExpressionComponent implements OnInit {
         });
     }
 
-    nodeClicked(node: TreeNode) {
-        console.log('node clicked: ' + node.label 
-            + ' expanded ' + node.expanded);
+    setNodeLabel(node: TreeNode, point: IdlObject): Promise<any> {
+        if (node.label) { return Promise.resolve(null); }
+        return Promise.all([
+            this.getPointLabel(point, true).then(txt => node.label = txt),
+            this.getPointLabel(point, false).then(
+                txt => node.callerData.slimLabel = txt)
+        ]);
+    }
+
+    getPointLabel(point: IdlObject, showmatch?: boolean): Promise<string> {
+        return this.strings.interpolate(
+            'staff.cat.vandelay.matchpoint.label', 
+            {point: point, showmatch: showmatch}
+        );
     }
 
+    nodeClicked(node: TreeNode) {}
+
     deleteNode() {
-        this.tree.removeNode(this.tree.activeNode());
+        this.changesMade = true;
+        const node = this.tree.selectedNode()
+        const point = this.matchSetPoints[node.id];
+        if (point) {
+            // point won't be cached if it's new during this session.
+            point.isdeleted(true);
+        }
+        this.tree.removeNode(node);
+    }
+
+    hasSelectedNode(): boolean {
+        return Boolean(this.tree.selectedNode());
+    }
+
+    selectedIsBool(): boolean {
+        if (this.tree) {
+            const node = this.tree.selectedNode();
+            return node && node.callerData.point.bool_op();
+        }
+        return false;
+    }
+
+    addChildNode() {
+        this.changesMade = true;
+
+        const pnode = this.tree.selectedNode();
+        const point = this.idl.create('vmsp');
+        point.id(this.newId--);
+        point.isnew(true);
+        point.parent(pnode.id);
+        point.match_set(this.matchSet_.id());
+
+        if (this.newPointType === 'bool') {
+            point.bool_op(this.newBoolOp);
+
+        } else {
+
+            if (this.newPointType == 'attr') {
+                point.svf(this.newRecordAttr);
+
+            } else if (this.newPointType == 'marc') {
+                point.tag(this.newMarcTag);
+                point.subfield(this.newMarcSf ? this.newMarcSf : null);
+            } else if (this.newPointType == 'heading') {
+                point.heading(true);
+            }
+
+            point.negate(this.newNegate);
+            point.quality(this.newMatchScore);
+        }
+
+        const node: TreeNode = new TreeNode({
+            id: point.id(), 
+            callerData: {point: point}
+        });
+
+        // Match points are added to the DB only when the tree is saved.
+        this.setNodeLabel(node, point).then(() => pnode.children.push(node));
+    }
+
+    setNewPointType(type_: string) {
+        this.newPointType = type_;
+        this.newRecordAttr = '';
+        this.newMatchScore = 1;
+        this.newNegate = false;
+        this.newMarcTag = '';
+        this.newMarcSf = '';
+        this.newBoolOp = 'AND';
+    }
+
+    expressionAsString(): string {
+        if (!this.tree) { return ''; }
+
+        const renderNode = (node: TreeNode): string => {
+            if (!node) { return ''; }
+
+            if (node.children.length) {
+                return '(' + node.children.map(renderNode).join(
+                    ' ' + node.callerData.slimLabel + ' ') + ')'
+            } else if (!node.callerData.point.bool_op()) {
+                return node.callerData.slimLabel;
+            } else {
+                return '()';
+            }
+        }
+
+        return renderNode(this.tree.rootNode);
     }
 
-    hasActiveNode(): boolean {
-        return Boolean(this.tree.activeNode());
+    saveTree(): Promise<any> {
+
+        // New nodes
+        let nodes = this.tree.nodeList()
+            .filter(node => node.callerData.point.isnew())
+            .map(node => node.callerData.point);
+
+        // Deleted nodes
+        nodes = nodes.concat(
+            Object.values(this.matchSetPoints)
+            .filter(point => point.isdeleted())
+        );
+
+        return this.pcrud.autoApply(nodes)
+            .toPromise().then(() => this.refreshTree())
     }
 }