LP1843969 Composite Attribute Entry Defs
authorMike Risher <mrisher@catalyte.io>
Thu, 22 Oct 2020 20:19:51 +0000 (20:19 +0000)
committerJane Sandberg <sandbej@linnbenton.edu>
Fri, 26 Feb 2021 23:12:03 +0000 (15:12 -0800)
Create a port of the Coded Value Maps UI from Angular JS to
Angular. Allow creation and edits of the Composite Attribute
Entry Definitions.

Signed-off-by: Mike Risher <mrisher@catalyte.io>
Signed-off-by: Jason Boyer <boyer.jason@gmail.com>
Signed-off-by: Jane Sandberg <sandbej@linnbenton.edu>
Open-ILS/src/eg2/src/app/staff/admin/server/coded-value-maps/coded-value-maps-routing.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/admin/server/coded-value-maps/coded-value-maps.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/admin/server/coded-value-maps/coded-value-maps.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/admin/server/coded-value-maps/coded-value-maps.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/admin/server/coded-value-maps/composite-def.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/admin/server/coded-value-maps/composite-def.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/admin/server/coded-value-maps/composite-new.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/admin/server/coded-value-maps/composite-new.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/admin/server/routing.module.ts

diff --git a/Open-ILS/src/eg2/src/app/staff/admin/server/coded-value-maps/coded-value-maps-routing.module.ts b/Open-ILS/src/eg2/src/app/staff/admin/server/coded-value-maps/coded-value-maps-routing.module.ts
new file mode 100644 (file)
index 0000000..fe1a58b
--- /dev/null
@@ -0,0 +1,19 @@
+import {NgModule} from '@angular/core';
+import {RouterModule, Routes} from '@angular/router';
+import {CodedValueMapsComponent} from './coded-value-maps.component';
+import {CompositeDefComponent} from './composite-def.component';
+
+const routes: Routes = [{
+  path: '',
+  component: CodedValueMapsComponent
+}, {
+  path: 'composite_def/:id',
+  component: CompositeDefComponent
+}];
+
+@NgModule({
+  imports: [RouterModule.forChild(routes)],
+  exports: [RouterModule]
+})
+
+export class CodedValueMapsRoutingModule {}
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/server/coded-value-maps/coded-value-maps.component.html b/Open-ILS/src/eg2/src/app/staff/admin/server/coded-value-maps/coded-value-maps.component.html
new file mode 100644 (file)
index 0000000..228e514
--- /dev/null
@@ -0,0 +1,46 @@
+<eg-staff-banner bannerText="SVF Record Attribute Coded Value Map Configuration" i18n-bannerText>
+</eg-staff-banner>
+
+<eg-grid #grid idlClass="ccvm" [dataSource]="gridDataSource" [sortable]="true">
+  <eg-grid-toolbar-button
+    label="New SVF Record Attribute Coded Value Map" i18n-label (onClick)="createNew()">
+  </eg-grid-toolbar-button>
+  <eg-grid-toolbar-action label="Edit Selected" i18n-label [action]="editSelected">
+  </eg-grid-toolbar-action>
+  <eg-grid-toolbar-action label="Delete Selected" i18n-label
+    [action]="deleteSelected"></eg-grid-toolbar-action>
+  
+  <eg-grid-column path='id' i18n-label label="ID"></eg-grid-column> 
+  <eg-grid-column path='ctype' i18n-label label="SVF Attribute"></eg-grid-column>
+  <eg-grid-column path='code' i18n-label label="Code"></eg-grid-column>
+  <eg-grid-column path='value' i18n-label label='value'></eg-grid-column>
+  <eg-grid-column path='description' i18n-label label='description'></eg-grid-column>
+  <eg-grid-column path='opac_visible' i18n-label label='OPAC Visible'></eg-grid-column>
+  <eg-grid-column path='search_label' i18n-label label='Search Label'></eg-grid-column>
+  <eg-grid-column path='is_simple' i18n-label label='Is Simple Selector'></eg-grid-column>
+  <eg-grid-column path='concept_uri' i18n-label label='Concept URI'></eg-grid-column>
+  <ng-template #compDefTmpl let-row="row">
+    <div *ngIf="row.ctype().composite() == 't'">
+      <a href="staff/admin/server/config/coded_value_map/composite_def/{{row.id()}}">
+        Manage
+      </a>
+    </div>
+  </ng-template>
+  <eg-grid-column i18n-label label="Composite Definition"
+      [cellTemplate]="compDefTmpl"></eg-grid-column>
+</eg-grid>
+
+<eg-fm-record-editor #editDialog idlClass="ccvm">
+</eg-fm-record-editor>
+
+<eg-string #createString i18n-text text="New Map Added"></eg-string>
+<eg-string #createErrString i18n-text text="Failed to Create New Map">
+</eg-string>
+<eg-string #deleteFailedString i18n-text 
+  text="Delete of Map failed or was not allowed"></eg-string>
+<eg-string #deleteSuccessString i18n-text 
+  text="Delete of Map succeeded"></eg-string>
+<eg-string #updateFailedString i18n-text 
+  text="Update of Map failed"></eg-string>
+<eg-string #updateSuccessString i18n-text 
+  text="Update of Map succeeded"></eg-string>
\ No newline at end of file
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/server/coded-value-maps/coded-value-maps.component.ts b/Open-ILS/src/eg2/src/app/staff/admin/server/coded-value-maps/coded-value-maps.component.ts
new file mode 100644 (file)
index 0000000..f89a79e
--- /dev/null
@@ -0,0 +1,106 @@
+import {Pager} from '@eg/share/util/pager';
+import {Component, ViewChild, OnInit} from '@angular/core';
+import {IdlObject} from '@eg/core/idl.service';
+import {GridDataSource} from '@eg/share/grid/grid';
+import {GridComponent} from '@eg/share/grid/grid.component';
+import {ToastService} from '@eg/share/toast/toast.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {FmRecordEditorComponent} from '@eg/share/fm-editor/fm-editor.component';
+import {StringComponent} from '@eg/share/string/string.component';
+
+@Component({
+    templateUrl: './coded-value-maps.component.html'
+})
+
+export class CodedValueMapsComponent implements OnInit {
+
+    gridDataSource: GridDataSource = new GridDataSource();
+    @ViewChild('createString', { static: true }) createString: StringComponent;
+    @ViewChild('createErrString', { static: true }) createErrString: StringComponent;
+    @ViewChild('updateSuccessString', { static: true }) updateSuccessString: StringComponent;
+    @ViewChild('updateFailedString', { static: true }) updateFailedString: StringComponent;
+    @ViewChild('deleteFailedString', { static: true }) deleteFailedString: StringComponent;
+    @ViewChild('deleteSuccessString', { static: true }) deleteSuccessString: StringComponent;
+
+    @ViewChild('grid', {static: true}) grid: GridComponent;
+    @ViewChild('editDialog', { static: true }) editDialog: FmRecordEditorComponent;
+
+    constructor(
+        private pcrud: PcrudService,
+        private toast: ToastService,
+    ) {
+    }
+
+    ngOnInit() {
+        this.gridDataSource.getRows = (pager: Pager, sort: any[]) => {
+            return this.pcrud.retrieveAll('ccvm', {order_by: {ccvm: 'id'}}, {fleshSelectors: true});
+        };
+        this.grid.onRowActivate.subscribe(
+            (idlThing: IdlObject) => this.showEditDialog(idlThing)
+        );
+    }
+
+    showEditDialog(standingPenalty: IdlObject): Promise<any> {
+        this.editDialog.mode = 'update';
+        this.editDialog.recordId = standingPenalty['id']();
+        return new Promise((resolve, reject) => {
+            this.editDialog.open({size: 'lg'}).subscribe(
+                result => {
+                    this.updateSuccessString.current()
+                        .then(str => this.toast.success(str));
+                    this.grid.reload();
+                    resolve(result);
+                },
+                error => {
+                    this.updateFailedString.current()
+                        .then(str => this.toast.danger(str));
+                    reject(error);
+                }
+            );
+        });
+    }
+
+    editSelected = (maps: IdlObject[]) => {
+        const editOneThing = (map: IdlObject) => {
+            this.showEditDialog(map).then(
+                () => editOneThing(maps.shift()));
+        };
+        editOneThing(maps.shift());
+    }
+
+    deleteSelected = (idlThings: IdlObject[]) => {
+        idlThings.forEach(idlThing => idlThing.isdeleted(true));
+        this.pcrud.autoApply(idlThings).subscribe(
+            val => {
+                console.debug('deleted: ' + val);
+                this.deleteSuccessString.current()
+                    .then(str => this.toast.success(str));
+            },
+            err => {
+                this.deleteFailedString.current()
+                    .then(str => this.toast.danger(str));
+            },
+            ()  => this.grid.reload()
+        );
+    }
+
+    createNew = () => {
+        this.editDialog.mode = 'create';
+        this.editDialog.recordId = null;
+        this.editDialog.record = null;
+        this.editDialog.open({size: 'lg'}).subscribe(
+            ok => {
+                this.createString.current()
+                    .then(str => this.toast.success(str));
+                this.grid.reload();
+            },
+            rejection => {
+                if (!rejection.dismissed) {
+                    this.createErrString.current()
+                        .then(str => this.toast.danger(str));
+                }
+            }
+        );
+    }
+
+ }
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/server/coded-value-maps/coded-value-maps.module.ts b/Open-ILS/src/eg2/src/app/staff/admin/server/coded-value-maps/coded-value-maps.module.ts
new file mode 100644 (file)
index 0000000..36457f1
--- /dev/null
@@ -0,0 +1,29 @@
+import {NgModule} from '@angular/core';
+import {StaffCommonModule} from '@eg/staff/common.module';
+import {FmRecordEditorModule} from '@eg/share/fm-editor/fm-editor.module';
+import {TreeModule} from '@eg/share/tree/tree.module';
+import {CodedValueMapsComponent} from './coded-value-maps.component';
+import {CompositeDefComponent} from './composite-def.component';
+import {CompositeNewPointComponent} from './composite-new.component';
+import {CodedValueMapsRoutingModule} from './coded-value-maps-routing.module';
+
+@NgModule({
+  declarations: [
+    CodedValueMapsComponent,
+    CompositeDefComponent,
+    CompositeNewPointComponent,
+  ],
+  imports: [
+    StaffCommonModule,
+    FmRecordEditorModule,
+    TreeModule,
+    CodedValueMapsRoutingModule
+  ],
+  exports: [
+  ],
+  providers: [
+  ]
+})
+
+export class CodedValueMapsModule {
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/server/coded-value-maps/composite-def.component.html b/Open-ILS/src/eg2/src/app/staff/admin/server/coded-value-maps/composite-def.component.html
new file mode 100644 (file)
index 0000000..84f9418
--- /dev/null
@@ -0,0 +1,82 @@
+<eg-staff-banner bannerText="Composite Attribute Entry Definitions" i18n-bannerText></eg-staff-banner>
+
+<div class="row justify-content-center mb-4">
+  <button class="btn btn-outline-dark text-center" (click)="back()">
+    &#8592; Return to Coded Value Map Configuration
+  </button>
+</div>
+
+<h5>Record Attribute: &nbsp;&nbsp; {{attribute}}</h5>
+<h5>Coded Value: &nbsp;&nbsp; {{code}} / {{value}}</h5>
+
+<div *ngIf="tree">
+  <h3 class="text-center mt-4">Composite Data Expression</h3>
+  <div class="p-2 text-white bg-dark rounded col-lg-8 offset-lg-2">
+    {{expressionAsString()}}
+  </div>
+</div>
+<h3 class="mt-4">Composite Data Tree</h3>
+<div class="row">
+  <div class="col-lg-6">
+    <div class="d-flex mb-4" *ngIf="tree">
+      <button class="btn btn-warning mr-2" (click)="deleteNode()"
+        [disabled]="!hasSelectedNode()" i18n>
+        Remove Selected Node
+      </button>
+      <button class="btn btn-success mr-1" (click)="saveTree()"
+        [disabled]="!changesMade" i18n>
+        Save Changes
+      </button>
+    </div>
+    <div>
+      <eg-tree [tree]="tree" (nodeClicked)="nodeClicked($event)" *ngIf="tree">
+      </eg-tree>
+      <p *ngIf="!tree" class="font-italic mt-4 ml-3">No tree</p>
+    </div>
+    <button class="btn btn-danger mt-4" (click)="deleteTree()" i18n *ngIf="tree">
+      Delete Tree
+    </button>
+  </div>
+  <div class="col-lg-6">
+    <div class="d-flex">
+      <button class="btn btn-outline-dark mr-3" 
+        (click)="newPointType='bool'" i18n>Add New Boolean Operator</button>
+      <button class="btn btn-outline-dark"
+        (click)="newPointType='attr'" i18n>Add New Record Attribute</button>
+    </div>
+    
+    <eg-composite-new-point #newPoint [pointType]="newPointType"></eg-composite-new-point>
+    
+    <div class="row mt-2 ml-2 font-italic" *ngIf="newPointType && tree">
+      <ol i18n>
+        <li>Define a new node using the above fields.</li>
+        <li>Select a boolean node in the tree.</li>
+        <li>Click the "Add..." button to add the new node
+          as a child of the selected node.</li>
+      </ol>
+      <p><b>Note</b> - A NOT boolean node can only have one child: a record attribute, or an 
+      AND / OR boolean node with its own children.</p>
+    </div>
+    <div *ngIf="!tree" class="row mt-2 ml-2 font-italic">To start a new tree add a boolean operator
+      or record attribute and click "New Tree".</div>
+    <div class="row ml-2 mt-2" *ngIf="!tree">
+      <button class="btn btn-success" (click)="createNewTree()" i18n
+        [disabled]="newTreeButtonDisabled()">
+        New tree
+      </button>
+    </div>
+    <div class="row ml-2" *ngIf="newPointType && tree">
+      <button class="btn btn-success" (click)="addChildNode()" 
+        [disabled]="addButtonDisabled()" i18n>
+        Add To Selected Node
+      </button>
+      <button class="btn btn-success ml-3" (click)="addChildNode(true)"
+        [disabled]="replaceButtonDisabled()" i18n>
+        Replace Selected Node
+      </button>
+    </div>
+  </div>
+</div>
+
+<eg-string #saveSuccess i18n-text text="Saved Composite Data Tree"></eg-string>
+<eg-string #saveFail i18n-text text="Failed to Save Composite Data Tree"></eg-string>
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/server/coded-value-maps/composite-def.component.ts b/Open-ILS/src/eg2/src/app/staff/admin/server/coded-value-maps/composite-def.component.ts
new file mode 100644 (file)
index 0000000..d5f5c89
--- /dev/null
@@ -0,0 +1,478 @@
+import {Component, ViewChild, OnInit} from '@angular/core';
+import {Router, ActivatedRoute} from '@angular/router';
+import {Tree, TreeNode} from '@eg/share/tree/tree';
+import {IdlService} from '@eg/core/idl.service';
+import {ToastService} from '@eg/share/toast/toast.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {CompositeNewPointComponent} from './composite-new.component';
+import {StringComponent} from '@eg/share/string/string.component';
+
+@Component({
+    templateUrl: './composite-def.component.html'
+})
+
+export class CompositeDefComponent implements OnInit {
+    currentId: number; // ccvm id
+
+    // these values displayed at top of page
+    code: string;
+    attribute: string;
+    value: string;
+
+    // data used to build tree
+    tree: Tree;
+    treeIndex = 2; // 1 is always root, so start at 2
+    idmap: any = {};
+    recordAttrDefs: any = {};
+    fetchAttrs: any[] = [];
+    codedValueMaps: any = {};
+
+    newPointType: string;
+    @ViewChild('newPoint', { static: true }) newPoint: CompositeNewPointComponent;
+
+    changesMade = false;
+    noSavedTreeData = false;
+
+    @ViewChild('saveSuccess', { static: true }) saveSuccess: StringComponent;
+    @ViewChild('saveFail', { static: true }) saveFail: StringComponent;
+
+    constructor(
+        private pcrud: PcrudService,
+        private router: Router,
+        private route: ActivatedRoute,
+        private idl: IdlService,
+        private toast: ToastService,
+    ) {
+    }
+
+    ngOnInit() {
+        this.currentId = parseInt(this.route.snapshot.paramMap.get('id'), 10);
+        this.getRecordAttrDefs();
+    }
+
+    getRecordAttrDefs = () => {
+        this.pcrud.retrieveAll('crad', {order_by: {crad: 'name'}}, {atomic: true}).subscribe(defs => {
+            defs.forEach((def) => {
+                this.recordAttrDefs[def.name()] = def;
+            });
+            this.getCodedMapValues();
+        });
+    }
+
+    getCodedMapValues = () => {
+        this.pcrud.search('ccvm', {'id': this.currentId},
+            {flesh: 1, flesh_fields: {ccvm: ['composite_def', 'ctype']} }).toPromise().then(
+            res => {
+                this.code = res.code();
+                this.value = res.value();
+                this.attribute = res.ctype().label();
+                if (res.composite_def()) {
+                    this.buildTreeStart(res.composite_def().definition());
+                } else {
+                    this.noSavedTreeData = true;
+                }
+            });
+    }
+
+    createNodeLabels = () => {
+        for (const key of Object.keys(this.idmap)) {
+            const nodeCallerData = this.idmap[key].callerData.point;
+            if (nodeCallerData.typeId) {
+                for (const id of Object.keys(this.codedValueMaps)) {
+                    const m = this.codedValueMaps[id];
+                    if ((m.code() === nodeCallerData.valueId) &&
+                        (m.ctype() === nodeCallerData.typeId)) {
+                        nodeCallerData.valueLabel = m.value();
+                    }
+                }
+                this.idmap[key].label = this.buildLabel(nodeCallerData.typeLabel, nodeCallerData.typeId,
+                    nodeCallerData.valueLabel, nodeCallerData.valueId);
+            }
+        }
+    }
+
+    expressionAsString = () => {
+        if (!this.tree) { return ''; }
+
+        const renderNode = (node: TreeNode): string => {
+            const lbl = node.label;
+            if (!node) { return ''; }
+            if (node.children.length) {
+                let negative = '';
+                let startParen = '( ';
+                let endParen = ' )';
+                if (lbl === 'NOT') {
+                    negative = 'NOT ';
+                    startParen = ''; // parentheses for NOT are redundant
+                    endParen = '';
+                }
+                if (this.tree.findParentNode(node) === null) { // no parentheses for root node
+                    startParen = '';
+                    endParen = '';
+                }
+                return negative + startParen + node.children.map(renderNode).join(
+                    ' ' + node.label +  ' ') + endParen;
+            } else if ((lbl !== 'NOT') && (lbl !== 'AND') && (lbl !== 'OR')) {
+                return node.callerData.point.valueLabel;
+            } else {
+                return '()';
+            }
+        };
+        return renderNode(this.tree.rootNode);
+    }
+
+    buildTreeStart = (def) => {
+        if (def) {
+            const nodeData = JSON.parse(def);
+            let rootNode;
+            if (Array.isArray(nodeData)) {
+                rootNode = this.addBooleanRootNode('OR');
+                nodeData.forEach(n => {
+                    this.buildTree(rootNode, n);
+                });
+            } else {
+                if (nodeData['_not']) {
+                    rootNode = this.addBooleanRootNode('NOT');
+                    this.buildTree(rootNode, nodeData['_not']);
+                } else if (nodeData['0']) {
+                    rootNode = this.addBooleanRootNode('AND');
+                    for (const key of Object.keys(nodeData)) {
+                        this.buildTree(rootNode, nodeData[key]);
+                    }
+                } else { // root node is record
+                    const newRootValues = {
+                        typeLabel: this.recordAttrDefs[nodeData._attr].label(),
+                        typeId: nodeData['_attr'],
+                        valueLabel: null,
+                        valueId: nodeData['_val'],
+                    };
+                    rootNode = {
+                        values: newRootValues
+                    };
+                    rootNode = this.addRecordRootNode(rootNode);
+                    this.fetchAttrs.push({'-and' : {ctype: nodeData['_attr'], code: nodeData['_val']}});
+                }
+            }
+            if (this.fetchAttrs.length > 0) {
+                this.pcrud.search('ccvm', {'-or' : this.fetchAttrs}).subscribe(
+                    data => {
+                        this.codedValueMaps[data.id()] = data;
+                    },
+                    err => {
+                        console.debug(err);
+                    },
+                    () => {
+                        this.createNodeLabels();
+                    }
+                );
+            }
+        }
+    }
+
+    buildTree = (parentNode, nodeData) => {
+        let dataIsArray = false;
+        if (Array.isArray(nodeData)) { dataIsArray = true; }
+        const point = {
+            id: null,
+            expanded: true,
+            children: [],
+            parent: parentNode.id,
+            label: null,
+            typeLabel: null,
+            typeId: null,
+            valueLabel: null,
+            valueId: null,
+        };
+        if (nodeData[0] || (nodeData['_not']) || dataIsArray) {
+            this.buildTreeBoolean(nodeData, dataIsArray, point, parentNode);
+        } else { // not boolean. it's a record
+            this.buildTreeRecord(nodeData, point, parentNode);
+        }
+    }
+
+    buildTreeBoolean = (nodeData: any, dataIsArray: any, point: any, parentNode) => {
+        if (dataIsArray) {
+            point.label = 'OR';
+        } else if (nodeData['_not']) {
+            point.label = 'NOT';
+        } else if (nodeData[0]) {
+            point.label = 'AND';
+        } else {
+            console.debug('Error.  No boolean value found');
+        }
+        point.id = this.treeIndex++;
+        const newNode: TreeNode = new TreeNode({
+            id: point.id,
+            expanded: true,
+            label:  point.label,
+            callerData: {point: point}
+        });
+        parentNode.children.push(newNode);
+        this.idmap[point.id + ''] = newNode;
+        if (dataIsArray) {
+            nodeData.forEach(n => {
+                this.buildTree(newNode, n);
+            });
+        } else if (nodeData['_not']) {
+            this.buildTree(newNode, nodeData['_not']);
+        } else if (nodeData[0]) {
+            for (const key of Object.keys(nodeData)) {
+                this.buildTree(newNode, nodeData[key]);
+            }
+        } else {
+            console.debug('Error building tree');
+        }
+    }
+
+    buildTreeRecord = (nodeData: any, point: any, parentNode) => {
+        point.typeLabel = this.recordAttrDefs[nodeData._attr].label();
+        point.typeId = nodeData._attr;
+        point.valueId = nodeData._val;
+        this.fetchAttrs.push({'-and' : {ctype : nodeData._attr, code : nodeData._val}});
+        point.id = this.treeIndex++;
+        const newNode: TreeNode = new TreeNode({
+            id: point.id,
+            expanded: true,
+            label:  null,
+            callerData: {point: point}
+        });
+        parentNode.children.push(newNode);
+        this.idmap[point.id + ''] = newNode;
+    }
+
+    createNewTree = () => {
+        this.changesMade = true;
+        this.treeIndex = 2;
+        if (this.newPointType === 'bool') {
+            this.addBooleanRootNode(this.newPoint.values.boolOp);
+        } else {
+            this.addRecordRootNode(this.newPoint);
+        }
+    }
+
+    addBooleanRootNode = (boolOp: any) => {
+        const point = { id: 1, label: boolOp, children: []};
+        const node: TreeNode = new TreeNode({id: 1, label: boolOp, children: [],
+            callerData: {point: point}});
+        this.idmap['1'] = node;
+        this.tree = new Tree(node);
+        return node;
+    }
+
+    addRecordRootNode = (record: any) => {
+        const point = { id: 1, expanded: true, children: [], label: null, typeLabel: null,
+            typeId: null, valueLabel: null, valueId: null};
+        point.typeLabel = record.values.typeLabel;
+        point.typeId = record.values.typeId;
+        point.valueLabel = record.values.valueLabel;
+        point.valueId = record.values.valueId;
+        const fullLabel = this.buildLabel(point.typeLabel, point.typeId, point.valueLabel, point.valueId);
+        const node: TreeNode = new TreeNode({ id: 1, label: fullLabel, children: [],
+            callerData: {point: point}});
+        this.idmap['1'] = node;
+        this.tree = new Tree(node);
+        return node;
+    }
+
+    buildLabel = (tlbl, tid, vlbl, vid) => {
+        return tlbl + ' (' + tid + ') => ' + vlbl + ' (' + vid + ')';
+    }
+
+    nodeClicked(node: TreeNode) {
+        console.debug('Node clicked on: ' + node.label);
+    }
+
+    deleteTree = () => {
+        this.tree = null;
+        this.idmap = {};
+        this.treeIndex = 2;
+        this.changesMade = true;
+    }
+
+    deleteNode = () => {
+        this.changesMade = true;
+        if (this.isRootNode()) {
+            this.deleteTree();
+        } else {
+            this.tree.removeNode(this.tree.selectedNode());
+        }
+    }
+
+    hasSelectedNode(): boolean {
+        if (this.tree) {
+            return Boolean(this.tree.selectedNode());
+        }
+    }
+
+    isRootNode(): boolean {
+        const node = this.tree.selectedNode();
+        if (node && this.tree.findParentNode(node) === null) {
+            return true;
+        }
+        return false;
+    }
+
+    selectedIsBool(): boolean {
+        if (!this.tree) { return false; }
+        if (this.tree.selectedNode()) {
+            const label = this.tree.selectedNode().label;
+            if (label === 'AND' || label === 'NOT' || label === 'OR') { return true; }
+        }
+        return false;
+    }
+
+    // Disable this:
+    // 1. if no node selected
+    // 2. if trying to add to a non-boolean record
+    // 3. if trying to add more than 1 child to a NOT
+    // 4. if trying to add NOT to an existing NOT
+    // 5. if trying to add before user has made selection of new value or operator
+    addButtonDisabled(): boolean {
+        if (!this.hasSelectedNode()) { return true; }
+        if (!this.selectedIsBool()) { return true; }
+        if ((this.tree.selectedNode().label === 'NOT') &&
+            (this.tree.selectedNode().children.length > 0)) { return true; }
+        if ((this.tree.selectedNode().label === 'NOT') &&
+            (this.newPoint.values.boolOp === 'NOT')) { return true; }
+        if (this.newPointType === 'attr' &&
+            (this.newPoint.values.typeId.length > 0) &&
+            (this.newPoint.values.valueId.length > 0)) { return false; }
+        if (this.newPointType === 'bool' &&
+            (this.newPoint.values.boolOp.length > 0)) { return false; }
+        return true;
+    }
+
+    // Disable this:
+    // 1. if no node selected
+    // 2. if trying to replace a boolean with a non-boolean or vice versa
+    // 3. if trying to replace before user has made selection of new value or operator
+    replaceButtonDisabled(): boolean {
+        if (!this.hasSelectedNode()) { return true; }
+        if (this.newPointType === 'attr' && !this.selectedIsBool() &&
+            (this.newPoint.values.typeId.length > 0) &&
+            (this.newPoint.values.valueId.length > 0)) { return false; }
+        if (this.newPointType === 'bool' && this.selectedIsBool() &&
+            (this.newPoint.values.boolOp.length > 0)) { return false; }
+        return true;
+    }
+
+    // disabled until you select a type and select values for that type
+    newTreeButtonDisabled(): boolean {
+        if ((this.newPointType === 'bool') && (this.newPoint.values.boolOp.length > 0)) {
+            return false;
+        }
+        if ((this.newPointType === 'attr') && (this.newPoint.values.typeId.length > 0) &&
+            (this.newPoint.values.valueId.length > 0)) { return false; }
+        return true;
+    }
+
+    back() {
+        this.router.navigate(['/staff/admin/server/config/coded_value_map']);
+    }
+
+    saveTree = () => {
+        const recordToSave = this.idl.create('ccraed');
+        recordToSave.coded_value(this.currentId);
+        const expression = this.exportTree(this.idmap['1']);
+        const jsonStr = JSON.stringify(expression);
+        recordToSave.definition(jsonStr);
+        if (this.noSavedTreeData) {
+            this.pcrud.create(recordToSave).subscribe(
+                ok => {
+                    this.saveSuccess.current().then(str => this.toast.success(str));
+                    this.noSavedTreeData = false;
+                },
+                err => {
+                    this.saveFail.current().then(str => this.toast.danger(str));
+                }
+            );
+        } else {
+            this.pcrud.update(recordToSave).subscribe(
+                async (ok) => {
+                    this.saveSuccess.current().then(str => this.toast.success(str));
+                },
+                async (err) => {
+                    this.saveFail.current().then(str => this.toast.danger(str));
+                }
+            );
+        }
+    }
+
+    exportTree(node: TreeNode): any {
+        const lbl = node.label;
+        if ((lbl !== 'NOT') && (lbl !== 'AND') && (lbl !== 'OR')) {
+            const retval = {_attr: node.callerData.point.typeId, _val: node.callerData.point.valueId};
+            return retval;
+        }
+        if (lbl === 'NOT') {
+            return {_not : this.exportTree(node.children[0])}; // _not nodes may only have one child
+        }
+        let compiled;
+        for (let i = 0; i < node.children.length; i++) {
+            const child = node.children[i];
+            if (!compiled) {
+                if (node.label === 'OR') {
+                    compiled = [];
+                } else {
+                    compiled = {};
+                }
+            }
+            compiled[i] = this.exportTree(child);
+        }
+        return compiled;
+    }
+
+    addChildNode(replace?: boolean) {
+        const targetNode: TreeNode = this.tree.selectedNode();
+        this.changesMade = true;
+        const point = {
+            id: null,
+            expanded: true,
+            children: [],
+            parent: targetNode.id,
+            label: null,
+            typeLabel: null,
+            typeId: null,
+            valueLabel: null,
+            valueId: null,
+        };
+
+        const node: TreeNode = new TreeNode({
+            callerData: {point: point},
+            id: point.id,
+            label: null
+        });
+
+        if (this.newPoint.values.pointType === 'bool') {
+            point.label = this.newPoint.values.boolOp;
+            node.label = point.label;
+        } else {
+            point.typeLabel = this.newPoint.values.typeLabel;
+            point.valueLabel = this.newPoint.values.valueLabel;
+            point.typeId = this.newPoint.values.typeId;
+            point.valueId = this.newPoint.values.valueId;
+        }
+        if (replace) {
+            if (this.newPoint.values.pointType === 'bool') {
+                targetNode.label = point.label;
+            } else {
+                targetNode.label = this.buildLabel(point.typeLabel, point.typeId, point.valueLabel,
+                    point.valueId);
+            }
+            targetNode.callerData.point = point;
+        } else {
+            point.id = this.treeIndex;
+            node.id = this.treeIndex++;
+            if (this.newPoint.values.pointType === 'bool') {
+                node.label = point.label;
+            } else {
+                node.label = this.buildLabel(point.typeLabel, point.typeId, point.valueLabel,
+                    point.valueId);
+            }
+            point.parent = targetNode.id;
+            targetNode.children.push(node);
+            this.idmap[point.id + ''] = node;
+        }
+    }
+
+ }
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/server/coded-value-maps/composite-new.component.html b/Open-ILS/src/eg2/src/app/staff/admin/server/coded-value-maps/composite-new.component.html
new file mode 100644 (file)
index 0000000..1d08983
--- /dev/null
@@ -0,0 +1,48 @@
+<div class="row ml-2 mt-4 p-2 border border-secondary" *ngIf="values.pointType">
+  <div class="col-lg-12">
+    <h3 class="text-center" *ngIf="values.pointType=='attr'">
+      Record Attribute
+    </h3>
+    <h3 class="text-center" *ngIf="values.pointType=='bool'">
+      Boolean Operator
+    </h3>
+  </div>
+  <div class="col-lg-12 common-form striped-odd form-validated">
+    <ng-container *ngIf="values.pointType=='attr'">
+      <div class="row mb-1">
+        <div class="col-lg-4">
+          <label id="type" class="col-form-label" i18n>Select Attribute Type:</label>
+        </div>
+        <div class="col-lg-8">
+          <eg-combobox [entries]="attrTypes" [required]="true" aria-labelledby="type"
+            (onChange)="typeChange($event)" placeholder="Attribute Type" i18n-placeholder>
+          </eg-combobox>  
+        </div>
+      </div>
+      <div class="row mb-1">
+        <div class="col-lg-4">
+          <label id="value" class="col-form-label" i18n>Select Value:</label>
+        </div>
+        <div class="col-lg-8">
+          <eg-combobox #valComboBox [entries]="attrVals" [required]="true" aria-labelledby="value"
+            (onChange)="valueChange($event)" placeholder="Value" i18n-placeholder>
+          </eg-combobox>
+        </div>
+      </div>
+    </ng-container>
+    <ng-container *ngIf="values.pointType=='bool'">
+      <div class="row mb-1">
+        <div class="col-lg-4 col-form-label">Operator:</div>
+        <div class="col-lg-8">
+          <select class="form-control" [(ngModel)]="values.boolOp">
+            <option value='AND' i18n>AND</option>
+            <option value='OR' i18n>OR</option>
+            <option value='NOT' i18n>NOT</option>
+          </select>
+        </div>
+      </div>
+    </ng-container>
+  </div>
+</div>
+  
+  
\ No newline at end of file
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/server/coded-value-maps/composite-new.component.ts b/Open-ILS/src/eg2/src/app/staff/admin/server/coded-value-maps/composite-new.component.ts
new file mode 100644 (file)
index 0000000..38408cd
--- /dev/null
@@ -0,0 +1,84 @@
+import {Component, OnInit, Input, ViewChild} from '@angular/core';
+import {IdlObject} from '@eg/core/idl.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {ComboboxComponent, ComboboxEntry} from '@eg/share/combobox/combobox.component';
+
+export class CompositeNewPointValues {
+    pointType: string;
+    boolOp: string;
+    typeLabel: string;
+    typeId: string;
+    valueLabel: string;
+    valueId: string;
+}
+
+@Component({
+  selector: 'eg-composite-new-point',
+  templateUrl: 'composite-new.component.html'
+})
+export class CompositeNewPointComponent implements OnInit {
+
+    public values: CompositeNewPointValues;
+
+    attrTypeDefs: IdlObject[];
+    attrValDefs: IdlObject[];
+    attrTypes: ComboboxEntry[];
+    attrVals: ComboboxEntry[];
+
+    @Input() set pointType(type_: string) {
+        this.values.pointType = type_;
+        this.values.boolOp = '';
+        this.values.valueLabel = '';
+        this.values.valueId = '';
+        this.values.typeId = '';
+        this.values.typeLabel = '';
+    }
+
+    @ViewChild('valComboBox', {static: false}) valComboBox: ComboboxComponent;
+
+    constructor(
+        private pcrud: PcrudService
+    ) {
+        this.values = new CompositeNewPointValues();
+        this.attrTypeDefs = [];
+        this.attrTypes = [];
+    }
+
+    ngOnInit() {
+        this.pcrud.retrieveAll('crad', {order_by: {crad: 'label'}})
+        .subscribe(attr => {
+            this.attrTypeDefs.push(attr);
+            this.attrTypes.push({id: attr.name(), label: attr.label()});
+        });
+    }
+
+    typeChange(evt) {
+        this.values.typeId = evt.id;
+        this.values.typeLabel = evt.label;
+        this.valComboBox.selected = null;  // reset other combobox
+        this.values.valueId = ''; // don't allow save with old valueId or valueLabel
+        this.values.valueLabel = '';
+        this.attrVals = [];
+        this.attrValDefs = [];
+        this.pcrud.search('ccvm', {'ctype': evt.id},
+            {flesh: 1, flesh_fields: {ccvm: ['composite_def', 'ctype']} }).subscribe(
+            data => {
+                this.attrValDefs.push(data);
+                this.attrVals.push({id: data.code(), label: data.value()});
+            },
+            err => {
+                console.debug(err);
+                this.attrVals = [];
+                this.attrValDefs = [];
+            }
+        );
+    }
+
+    valueChange(evt) {
+        if (evt) {
+            this.values.valueId = evt.id;
+            this.values.valueLabel = evt.label;
+        }
+    }
+}
+
index 961d806..caadbcb 100644 (file)
@@ -13,6 +13,10 @@ const routes: Routes = [{
     path: 'actor/org_unit_type',
     component: OrgUnitTypeComponent
 }, {
+    path: 'config/coded_value_map',
+    loadChildren: () =>
+      import('./coded-value-maps/coded-value-maps.module').then(m => m.CodedValueMapsModule)
+}, {
     path: 'config/floating_group',
     loadChildren: () =>
       import('./floating-group/floating-group.module').then(m => m.FloatingGroupModule)