LP#1775466 AngJS style minimal template parser WIP
authorBill Erickson <berickxx@gmail.com>
Wed, 20 Jun 2018 17:12:05 +0000 (13:12 -0400)
committerBill Erickson <berickxx@gmail.com>
Wed, 20 Jun 2018 17:12:07 +0000 (13:12 -0400)
So we can continue supporting AngularJS-style print templates
-- without having to use AngularJS -- which will buy some time
for determining how we want to handle print templates in the
future and to ease existing template migration.

Signed-off-by: Bill Erickson <berickxx@gmail.com>
Open-ILS/src/eg2/src/app/share/util/parser.service.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/common.module.ts
Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.html
Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.ts

diff --git a/Open-ILS/src/eg2/src/app/share/util/parser.service.ts b/Open-ILS/src/eg2/src/app/share/util/parser.service.ts
new file mode 100644 (file)
index 0000000..b33fa20
--- /dev/null
@@ -0,0 +1,169 @@
+import {Injectable, EventEmitter} from '@angular/core';
+import {FormatService} from '@eg/share/util/format.service';
+
+class ParserInstance {
+
+    private context: any;
+    private domNode: HTMLElement;
+    private format: FormatService;
+    private evalEnv: string;
+
+    // FormatService injected by ParserService
+    constructor(format: FormatService) {
+        this.format = format;
+    }
+
+    // Given an HTML string and an interpolation context/scope,
+    // process the HTML template declarations, apply variable 
+    // replacements, and return the restulting HTML string.
+    parse(html: string, context: any): string {
+        this.context = context;
+        const parser = new DOMParser();
+
+        const doc = parser.parseFromString(html, "text/html");
+
+        // Parsing as html wraps the content in an <html><body> wrapper
+        this.domNode = doc.getElementsByTagName('body')[0];
+
+        this.traverse(this.domNode);
+
+        return this.domNode.innerHTML;
+    }
+
+    // Process each node in the source document.
+    traverse(node: Node) {
+        if (!node) return;
+
+        switch (node.nodeType) {
+            case Node.ELEMENT_NODE:
+                this.processElement(node as Element);
+                break;
+
+            case Node.TEXT_NODE:
+                this.replaceText(node as Text);
+                break;
+        }
+
+        // Array.from() avoids TS compiler warnings
+        Array.from(node.childNodes).forEach(child => this.traverse(child));
+    }
+
+    // Process expressions found on each element.
+    processElement(node: Element) {
+
+        const ifExp = node.getAttribute('ng-if');
+        if (ifExp && !this.testIfExpression(ifExp)) {
+            // A failing IF expression means the node does not render.
+            node.remove();
+        }
+
+        const loopExp = node.getAttribute('ng-repeat');
+        if (loopExp) {
+            this.processLoopExpression(node, loopExp);
+        }
+    }
+
+    processLoopExpression(node: Element, expr: string) {
+        const parts = expr.split('in');
+        const key = parts[0].trim();
+        const listPath = parts[1].trim();
+
+        this.generateEvalEnv();
+        const list = this.getContextValueAt(listPath);
+
+        if (!Array.isArray(list)) {
+            throw new Error(`Template value ${listPath} is not an Array`);
+        }
+    }
+
+    // To evaluate free-form expressions, create an environment
+    // where references to elements in the context are available.
+    // For example:  ng-if="foo.bar" -- the 'foo' key from the 
+    // context must be defined in advance for evaluation to succeed.
+    generateEvalEnv() {
+        if (this.evalEnv) return;
+        this.evalEnv = '';
+        Object.keys(this.context).forEach(key => {
+            this.evalEnv += `var ${key} = this.context['${key}'];\n`;
+        });
+    }
+
+    // Returns true of the IF expression evaluates to true, 
+    // false otherwise.
+    testIfExpression(expr: string): boolean {
+
+        this.generateEvalEnv();
+        const evalStr = `${this.evalEnv}; Boolean(${expr})`;
+        //console.debug('ng-if eval string: ', evalStr);
+
+        try {
+            return eval(evalStr);
+        } catch (err) {
+            //console.debug('IF expression failed with: ', err);
+            return false;
+        }
+    }
+
+    // Replace variable {{...}} instances in a given Text node
+    // with the matching data from the context.
+    replaceText(node: Text) {
+        if (!node || !node.data) { return; }
+
+        const matches = node.data.match(/{{.*?}}/g);
+        if (!matches) { return };
+
+        matches.forEach(match => {
+            let dotpath = match.replace(/[{}]/g, '');
+            node.replaceData(
+                node.data.indexOf(match), 
+                match.length,
+                this.getContextStringValue(dotpath)
+            );
+        });
+    }
+
+    // Returns the context item at the given path
+    getContextValueAt(dotpath: string): any {
+        let idx;
+        let obj = this.context;
+        const parts = dotpath.split('.');
+
+        for (idx = 0; idx < parts.length; idx++) {
+            obj = obj[parts[idx]];
+            if (obj === null || typeof obj !== 'object') {
+                break;
+            }
+        }
+
+        if (idx < parts.length - 1) {
+            // Loop exited before inspecting the whole path.
+            return null;
+        }
+
+        return obj;
+    }
+
+    // Find the value within the context at the given path.
+    getContextStringValue(dotpath: string): string {
+        const value = this.getContextValueAt(dotpath);
+        // TODO IDL stuff when possible for formatting (e.g. dates)
+        return this.format.transform({value: value}); 
+    }
+}
+
+
+@Injectable()
+export class TemplateParserService {
+
+    constructor(private format: FormatService) {}
+
+    // make async for now, may be needed later
+    public apply(html: string, context: any): Promise<string> {
+        const parser = new ParserInstance(this.format);
+
+        return new Promise((resolve, reject) => {
+            resolve(parser.parse(html, context));
+        });
+    }
+}
+
index 2dfbb3c..6527a66 100644 (file)
@@ -10,6 +10,7 @@ import {ToastService} from '@eg/share/toast/toast.service';
 import {ToastComponent} from '@eg/share/toast/toast.component';
 import {StringComponent} from '@eg/share/string/string.component';
 import {StringService} from '@eg/share/string/string.service';
+import {TemplateParserService} from '@eg/share/util/parser.service';
 import {FmRecordEditorComponent} from '@eg/share/fm-editor/fm-editor.component';
 import {DateSelectComponent} from '@eg/share/date-select/date-select.component';
 
@@ -53,7 +54,8 @@ export class StaffCommonModule {
             providers: [ // Export staff-wide services
                 AccessKeyService,
                 StringService,
-                ToastService
+                ToastService,
+                TemplateParserService
             ]
         };
     }
index dbea15a..2f8cea4 100644 (file)
 
 <br/><br/>
 
+<button class="btn btn-info" (click)="testParser()">Test Template Parser</button>
+
+<br/><br/>
+
 <!-- grid stuff -->
 <ng-template #cellTmpl let-row="row" let-col="col" let-userContext="userContext">
   HELLO {{userContext.hello}}
index eb0fdb0..8e5c162 100644 (file)
@@ -13,6 +13,7 @@ import {OrgService} from '@eg/core/org.service';
 import {Pager} from '@eg/share/util/pager';
 import {DateSelectComponent} from '@eg/share/date-select/date-select.component';
 import {PrintService} from '@eg/share/print/print.service';
+import {TemplateParserService} from '@eg/share/util/parser.service';
 
 @Component({
   templateUrl: 'sandbox.component.html'
@@ -53,7 +54,8 @@ export class SandboxComponent implements OnInit {
         private pcrud: PcrudService,
         private strings: StringService,
         private toast: ToastService,
-        private printer: PrintService
+        private printer: PrintService,
+        private parser: TemplateParserService
     ) {}
 
     ngOnInit() {
@@ -70,7 +72,7 @@ export class SandboxComponent implements OnInit {
             if (sort.length) {
                 orderBy.cbt = sort[0].name + ' ' + sort[0].dir;
             }
-            
+
             return this.pcrud.retrieveAll('cbt', {
                 offset: pager.offset,
                 limit: pager.limit,
@@ -124,6 +126,54 @@ export class SandboxComponent implements OnInit {
                 .then(txt => this.toast.success(txt));
         }, 4000);
     }
+
+
+
+    testParser() {
+
+        const template = `
+            <div>
+              <div>This item needs to be routed to
+                <b>{{dest_location.shortname}} AKA {{dest_location.name}}</b>
+              </div>
+              <div ng-if="foo">i should disappear</div>
+              <div ng-if="boolFalse">i should also disappear</div>
+              <div ng-if="boolTrue">i should stick around</div>
+              <div ng-if="dest_address">
+                <div>{{dest_address.street1}}</div>
+                <div>{{dest_address.street2}}</div>
+              </div>
+              <div>Slip Date: {{today}}</div>
+              <ol>
+                <li ng-repeat="copy in copies">
+                  <div>Barcode: {{copy.barcode}}</div>
+                  <div>Title: {{copy.title}}</div>
+                </li>
+              </ol>
+        `;
+
+        const context = {
+            dest_location: {
+                shortname: 'BR1',
+                name: 'Branch Uno',
+            },
+            dest_address: {
+                street1: '123 Pineapple Rd',
+                street2: 'APT #3',
+            },
+            boolTrue: true,
+            boolFalse: false,
+            today: new Date(),
+            copies: [
+                {barcode: 'abc123', title: 'welcome to the jungle'},
+                {barcode: 'def456', title: 'hello mudda, hello fadda'}
+            ]
+        }
+
+        this.parser.apply(template, context).then(html => {
+            console.log('parsed: ', html);
+        });
+    }
 }