Support toggle+widget buttons in plugin-framework

Change-Id: Ibf2b80efc1eeda2f51dedf4a7f3cd5ac67325ef9
diff --git a/dev/js/spec/buttongroupSpec.js b/dev/js/spec/buttongroupSpec.js
index 189fb24..1812607 100644
--- a/dev/js/spec/buttongroupSpec.js
+++ b/dev/js/spec/buttongroupSpec.js
@@ -275,15 +275,15 @@
       expect(check.classList.contains("checked")).toBeFalsy();
       expect(count).toEqual(0);
 
-      // Clicking the check toggles active state, but does not trigger callback.
+      // Clicking the check toggles active state and triggers the callback.
       check.click();
       expect(active.get()).toBeTruthy();
       expect(check.classList.contains("checked")).toBeTruthy();
-      expect(count).toEqual(0);
-
-      // Clicking the button still triggers the callback.
-      button.click();
       expect(count).toEqual(1);
+
+      // Clicking the button also triggers the callback.
+      button.click();
+      expect(count).toEqual(2);
     });
 
     it('should allow adoption', function () {
diff --git a/dev/js/spec/pipeSpec.js b/dev/js/spec/pipeSpec.js
index e1f47a0..e47a9a0 100644
--- a/dev/js/spec/pipeSpec.js
+++ b/dev/js/spec/pipeSpec.js
@@ -55,6 +55,22 @@
       expect(p.toString()).toEqual('service2,service1');
     });
     
+    it('should not add duplicates', function () {
+      let p = pipeClass.create();
+      p.append('service1');
+      p.append('service2');
+      expect(p.size()).toEqual(2);
+      expect(p.toString()).toEqual('service1,service2');
+
+      p.append('service1');
+      expect(p.size()).toEqual(2);
+      expect(p.toString()).toEqual('service1,service2');
+
+      p.prepend('service2');
+      expect(p.size()).toEqual(2);
+      expect(p.toString()).toEqual('service1,service2');
+    });
+
     it('should be deletable', function () {
       let p = pipeClass.create();
       p.append('service1');
diff --git a/dev/js/spec/pluginSpec.js b/dev/js/spec/pluginSpec.js
index 32a6e5e..107f44d 100644
--- a/dev/js/spec/pluginSpec.js
+++ b/dev/js/spec/pluginSpec.js
@@ -244,6 +244,457 @@
       KorAP.Panel["result"] = undefined;
     });
 
+    it('should accept valid registrations for setWidget with active', function () {
+      let p = KorAP.Panel["result"] = panelClass.create();
+      
+      let manager = pluginServerClass.create();
+
+      // Attach services container so addService works
+      document.body.appendChild(manager.element());
+
+      manager.register({
+        name : 'Check',
+        embed : [{
+          panel : 'result',
+          title : 'Map',
+          onClick : {
+            template : 'about:blank',
+            action : 'setWidget',
+            active : false
+          }
+        }]
+      });
+
+      let b = p.actions().element().firstChild;
+      expect(b.getAttribute("title")).toEqual("Map");
+
+      // Button should have a check element (marker-box)
+      expect(b.firstChild.classList.contains('check')).toBeTruthy();
+      expect(b.firstChild.classList.contains('button-icon')).toBeTruthy();
+
+      // active state should be set to false initially
+      expect(b.active).toBeDefined();
+      expect(b.active.get()).toBe(false);
+
+      // Check is not checked initially
+      expect(b.firstChild.classList.contains('checked')).toBeFalsy();
+
+      expect(p.element().querySelectorAll("iframe").length).toEqual(0);
+
+      // Click on check (marker-box) - loads background service,
+      // no visible widget in the panel
+      b.firstChild.click();
+
+      // No iframe in the panel - service is in the services container
+      expect(p.element().querySelectorAll("iframe").length).toEqual(0);
+      expect(manager.element().querySelectorAll("iframe").length).toEqual(1);
+
+      // No widget view element should exist in the panel
+      expect(p.element().querySelectorAll("div.view").length).toEqual(0);
+
+      // active state should have toggled to true
+      expect(b.active.get()).toBe(true);
+      expect(b.firstChild.classList.contains('checked')).toBeTruthy();
+
+      // Click on check again - should not add another service, just toggle active
+      b.firstChild.click();
+      expect(manager.element().querySelectorAll("iframe").length).toEqual(1);
+      expect(b.active.get()).toBe(false);
+      expect(b.firstChild.classList.contains('checked')).toBeFalsy();
+
+      // Click on title - should open the widget visibly
+      b.click();
+      expect(p.element().querySelectorAll("iframe").length).toEqual(1);
+      expect(p.element().querySelectorAll("div.view.show.widget").length).toEqual(1);
+
+      // Click title again - should toggle visibility (hide via state)
+      b.click();
+      expect(p.element().querySelectorAll("div.view.show.widget").length).toEqual(0);
+
+      manager.destroy();
+      KorAP.Panel["result"] = undefined;
+    });
+
+    it('should not show any view element when checkbox is clicked', function () {
+      let p = KorAP.Panel["result"] = panelClass.create();
+      let manager = pluginServerClass.create();
+
+      document.body.appendChild(manager.element());
+
+      manager.register({
+        name : 'NoBar',
+        embed : [{
+          panel : 'result',
+          title : 'Map',
+          onClick : {
+            template : 'about:blank',
+            action : 'setWidget',
+            active : false
+          }
+        }]
+      });
+
+      let b = p.actions().element().firstChild;
+
+      // Click checkbox
+      b.firstChild.click();
+
+      // No view element must exist in the panel at all —
+      // not visible, not hidden, not minimized. The checkbox
+      // must only create a background service, never a widget.
+      expect(p.element().querySelectorAll("div.view").length).toEqual(0);
+      expect(p.element().querySelectorAll("iframe").length).toEqual(0);
+
+      // The iframe must be in the services container instead
+      expect(manager.element().querySelectorAll("iframe").length).toEqual(1);
+
+      // Clicking the title afterwards must properly open the widget
+      b.click();
+      expect(p.element().querySelectorAll("div.view.show.widget").length).toEqual(1);
+      expect(p.element().querySelectorAll("iframe").length).toEqual(1);
+
+      manager.destroy();
+      KorAP.Panel["result"] = undefined;
+    });
+
+    it('should toggle active via checkbox when widget is open', function () {
+      let p = KorAP.Panel["result"] = panelClass.create();
+      let manager = pluginServerClass.create();
+
+      document.body.appendChild(manager.element());
+
+      manager.register({
+        name : 'Check',
+        embed : [{
+          panel : 'result',
+          title : 'Map',
+          onClick : {
+            template : 'about:blank',
+            action : 'setWidget',
+            active : false
+          }
+        }]
+      });
+
+      let b = p.actions().element().firstChild;
+
+      // Open the widget via title-click
+      b.click();
+      expect(p.element().querySelectorAll("div.view.show.widget").length).toEqual(1);
+      expect(b.active.get()).toBe(false);
+
+      let id = b['widgetID'];
+      expect(id).toBeDefined();
+
+      // Active association should exist on the widget
+      expect(b.active.associates()).toBeGreaterThan(0);
+
+      // Click checkbox to activate - should toggle active state
+      b.firstChild.click();
+      expect(b.active.get()).toBe(true);
+      expect(b.firstChild.classList.contains('checked')).toBeTruthy();
+
+      // Widget should still be visible
+      expect(p.element().querySelectorAll("div.view.show.widget").length).toEqual(1);
+
+      // widgetID should remain the same (no new service created)
+      expect(b['widgetID']).toEqual(id);
+
+      // Click checkbox again to deactivate
+      b.firstChild.click();
+      expect(b.active.get()).toBe(false);
+      expect(b.firstChild.classList.contains('checked')).toBeFalsy();
+
+      // Widget should still be visible
+      expect(p.element().querySelectorAll("div.view.show.widget").length).toEqual(1);
+
+      manager.destroy();
+      KorAP.Panel["result"] = undefined;
+    });
+
+    it('should not duplicate pipes when opening widget after checkbox', function () {
+      var tempPipe = KorAP.Pipe;
+      KorAP.Pipe = pipeClass.create();
+
+      let p = KorAP.Panel["result"] = panelClass.create();
+      let manager = pluginServerClass.create();
+
+      document.body.appendChild(manager.element());
+
+      manager.register({
+        name : 'PipeCheck',
+        embed : [{
+          panel : 'result',
+          title : 'Map',
+          onClick : {
+            template : 'about:blank',
+            action : 'setWidget',
+            active : false
+          }
+        }]
+      });
+
+      let b = p.actions().element().firstChild;
+
+      // Click checkbox - background service created, active becomes true
+      b.firstChild.click();
+      expect(b.active.get()).toBe(true);
+      expect(manager.element().querySelectorAll("iframe").length).toEqual(1);
+
+      // Simulate the plugin adding a pipe via the background service
+      let bgId = b['widgetID'];
+      manager._receiveMsg({
+        "data" : {
+          "originID" : bgId,
+          "action" : "pipe",
+          "job" : "add",
+          "service" : "https://mapper.example"
+        }
+      });
+      expect(KorAP.Pipe.toString()).toEqual("https://mapper.example");
+
+      // Click title - opens widget, closes background service
+      b.click();
+      expect(p.element().querySelectorAll("div.view.show.widget").length).toEqual(1);
+
+      // The new widget's plugin re-initializes and tries to add the
+      // same pipe again — the pipe system must deduplicate.
+      let wId = b['widgetID'];
+      manager._receiveMsg({
+        "data" : {
+          "originID" : wId,
+          "action" : "pipe",
+          "job" : "add",
+          "service" : "https://mapper.example"
+        }
+      });
+
+      // Pipe must still contain only one entry
+      expect(KorAP.Pipe.toString()).toEqual("https://mapper.example");
+      expect(KorAP.Pipe.size()).toEqual(1);
+
+      manager.destroy();
+      KorAP.Pipe = tempPipe;
+      KorAP.Panel["result"] = undefined;
+    });
+
+    it('should toggle checkbox visual when widget is open', function () {
+      let p = KorAP.Panel["result"] = panelClass.create();
+      let manager = pluginServerClass.create();
+
+      document.body.appendChild(manager.element());
+
+      manager.register({
+        name : 'VisualCheck',
+        embed : [{
+          panel : 'result',
+          title : 'Map',
+          onClick : {
+            template : 'about:blank',
+            action : 'setWidget',
+            active : false
+          }
+        }]
+      });
+
+      let b = p.actions().element().firstChild;
+      let check = b.firstChild;
+
+      // Open widget via title
+      b.click();
+      expect(p.element().querySelectorAll("div.view.show.widget").length).toEqual(1);
+      expect(check.classList.contains('checked')).toBeFalsy();
+
+      // Click checkbox - should visually check
+      check.click();
+      expect(b.active.get()).toBe(true);
+      expect(check.classList.contains('checked')).toBeTruthy();
+
+      // Click checkbox again - should visually uncheck
+      check.click();
+      expect(b.active.get()).toBe(false);
+      expect(check.classList.contains('checked')).toBeFalsy();
+
+      // Widget should still be visible throughout
+      expect(p.element().querySelectorAll("div.view.show.widget").length).toEqual(1);
+
+      manager.destroy();
+      KorAP.Panel["result"] = undefined;
+    });
+
+    it('should accept valid registrations for addWidget with active', function () {
+      let p = KorAP.Panel["result"] = panelClass.create();
+      
+      let manager = pluginServerClass.create();
+
+      manager.register({
+        name : 'Check',
+        embed : [{
+          panel : 'result',
+          title : 'Export',
+          onClick : {
+            template : 'about:blank',
+            action : 'addWidget',
+            active : true
+          }
+        }]
+      });
+
+      let b = p.actions().element().firstChild;
+
+      // active state should be set to true initially
+      expect(b.active).toBeDefined();
+      expect(b.active.get()).toBe(true);
+      expect(b.firstChild.classList.contains('checked')).toBeTruthy();
+
+      expect(p.element().querySelectorAll("iframe").length).toEqual(0);
+
+      // Click on check - only toggles active, no widget created
+      b.firstChild.click();
+      expect(p.element().querySelectorAll("iframe").length).toEqual(0);
+      expect(b.active.get()).toBe(false);
+      expect(b.firstChild.classList.contains('checked')).toBeFalsy();
+
+      // Click on title - should load a widget visibly
+      b.click();
+      expect(p.element().querySelectorAll("iframe").length).toEqual(1);
+
+      manager.destroy();
+      KorAP.Panel["result"] = undefined;
+    });
+
+    it('should support changeTitle on buttons', function () {
+      let p = KorAP.Panel["result"] = panelClass.create();
+      
+      let manager = pluginServerClass.create();
+
+      manager.register({
+        name : 'Check',
+        embed : [{
+          panel : 'result',
+          title : 'Map',
+          onClick : {
+            template : 'about:blank',
+            action : 'setWidget'
+          }
+        }]
+      });
+
+      let b = p.actions().element().firstChild;
+      expect(b.lastChild.textContent).toEqual("Map");
+
+      b.changeTitle("New Title");
+      expect(b.lastChild.textContent).toEqual("New Title");
+
+      manager.destroy();
+      KorAP.Panel["result"] = undefined;
+    });
+
+    it('should handle Title set message', function () {
+      let p = KorAP.Panel["result"] = panelClass.create();
+      
+      let manager = pluginServerClass.create();
+
+      manager.register({
+        name : 'TitlePlugin',
+        embed : [{
+          panel : 'result',
+          title : 'Original',
+          onClick : {
+            template : 'about:blank',
+            action : 'setWidget'
+          }
+        }]
+      });
+
+      let b = p.actions().element().firstChild;
+      expect(b.lastChild.textContent).toEqual("Original");
+
+      // Click to open widget
+      b.click();
+
+      let id = b['widgetID'];
+      expect(id).toBeDefined();
+
+      // Send Title set message
+      manager._receiveMsg({
+        "data" : {
+          "originID" : id,
+          "action" : "set",
+          "key" : "Title",
+          "value" : "Changed"
+        }
+      });
+
+      expect(b.lastChild.textContent).toEqual("Changed");
+
+      manager.destroy();
+      KorAP.Panel["result"] = undefined;
+    });
+
+    it('should handle Active get/set messages', function () {
+      let p = KorAP.Panel["result"] = panelClass.create();
+      
+      let manager = pluginServerClass.create();
+
+      manager.register({
+        name : 'ActivePlugin',
+        embed : [{
+          panel : 'result',
+          title : 'Map',
+          onClick : {
+            template : 'about:blank',
+            action : 'setWidget',
+            active : false
+          }
+        }]
+      });
+
+      let b = p.actions().element().firstChild;
+      expect(b.active.get()).toBe(false);
+
+      // Click title to open widget
+      b.click();
+
+      let id = b['widgetID'];
+      expect(id).toBeDefined();
+
+      // Get active state
+      let data = {
+        "originID" : id,
+        "action" : "get",
+        "key" : "Active"
+      };
+      manager._receiveMsg({ "data" : data });
+      expect(data.value).toBe(false);
+
+      // Set active state via message
+      manager._receiveMsg({
+        "data" : {
+          "originID" : id,
+          "action" : "set",
+          "key" : "Active",
+          "value" : true
+        }
+      });
+      expect(b.active.get()).toBe(true);
+      expect(b.firstChild.classList.contains('checked')).toBeTruthy();
+
+      // Roll active state via message (no value)
+      manager._receiveMsg({
+        "data" : {
+          "originID" : id,
+          "action" : "set",
+          "key" : "Active"
+        }
+      });
+      expect(b.active.get()).toBe(false);
+      expect(b.firstChild.classList.contains('checked')).toBeFalsy();
+
+      manager.destroy();
+      KorAP.Panel["result"] = undefined;
+    });
+
     it('should accept valid registrations for toggle', function () {
       let p = KorAP.Panel["result"] = panelClass.create();
       
diff --git a/dev/js/src/buttongroup.js b/dev/js/src/buttongroup.js
index 40eb61f..922ca1c 100644
--- a/dev/js/src/buttongroup.js
+++ b/dev/js/src/buttongroup.js
@@ -83,6 +83,7 @@
       const b = this._insert('span');
       let desc = title;
 
+      let that = this;
       if (data !== undefined) {
         if (data['cls'] !== undefined) {
           b.classList.add.apply(b.classList, data['cls']);
@@ -98,14 +99,21 @@
 
         if (data['active'] !== undefined) {
           let active = data['active'];
+          b['active'] = active;
 
-	  let check = _addCheck(b,active);
-	  check.addEventListener('click', function (e) {
-	    // Do not bubble
-	    e.halt();
+      	  let check = _addCheck(b,active);
+	        check.addEventListener('click', function (e) {
+	          // Do not bubble
+	          e.halt();
             // Toggle state
-	    active.roll();
-	  });
+	          active.roll();
+
+            let obj = that._bind || this;
+            obj.button = b;
+            b._activeClick = true;
+            cb.apply(obj, e);
+            b._activeClick = false;
+	        });
         };
 
 	
@@ -119,10 +127,9 @@
       innerSpan.addT(title);
 
       b["changeTitle"] = function (title) {
-	innerSpan.textContent = title;
+       	innerSpan.textContent = title;
       };
       
-      let that = this;
       b.addEventListener('click', function (e) {
 
         // Do not bubble
diff --git a/dev/js/src/pipe.js b/dev/js/src/pipe.js
index f10853e..241b126 100644
--- a/dev/js/src/pipe.js
+++ b/dev/js/src/pipe.js
@@ -37,7 +37,7 @@
      */
     append : function (service) {
       service = _notNull(service);
-      if (service) {
+      if (service && this._pipe.indexOf(service) === -1) {
         this._pipe.push(service);
         this._update();
       };
@@ -49,7 +49,7 @@
      */
     prepend : function (service) {
       service = _notNull(service);
-      if (service) {
+      if (service && this._pipe.indexOf(service) === -1) {
         this._pipe.unshift(service);
         this._update();
       };
diff --git a/dev/js/src/plugin/server.js b/dev/js/src/plugin/server.js
index f8c9855..ab84ed3 100644
--- a/dev/js/src/plugin/server.js
+++ b/dev/js/src/plugin/server.js
@@ -161,6 +161,55 @@
             // "this".button is the button
             // "that" is the server object
 
+            // Active-check click: load iframe as a background service,
+            // not as a visible widget.
+            if (this.button._activeClick && 'active' in this.button) {
+
+              // Service already running - just return
+              // (active.roll() was already called in buttongroup)
+              if (this.button['widgetID'] &&
+                  services[this.button['widgetID']]) {
+                return;
+              };
+
+              // For addWidget mode (no state), just toggle - no service
+              if (!('state' in this.button)) {
+                return;
+              };
+
+              // Create a background service (iframe in services container)
+              let id = that.addService({
+                "name": name,
+                "src": onClick["template"],
+                "permissions": onClick["permissions"]
+              });
+              plugin["widgets"].push(id);
+
+              this.button['widgetID'] = id;
+
+              // Store panel reference so 'get Active' works
+              services[id].panel = this;
+
+              let activeState = this.button.active;
+              let iframe = services[id].load();
+
+              iframe.onload = function () {
+                activeState.associate({
+                  setState : function (value) {
+                    if (services[id]) {
+                      services[id].sendMsg({
+                        action: 'state',
+                        key : 'active',
+                        value : value
+                      });
+                    };
+                  }
+                });
+              };
+
+              return;
+            };
+
             // The button has a state and the state is associated to the
             // a intermediate object to toggle the view
             if ('state' in this.button && this.button.state.associates() > 0) {
@@ -182,15 +231,26 @@
               }
             };
 
+            // If a background service exists (from active-check),
+            // close it before creating the widget
+            if ('active' in this.button &&
+                this.button['widgetID'] &&
+                services[this.button['widgetID']] &&
+                !services[this.button['widgetID']].isWidget) {
+              that._closeService(this.button['widgetID']);
+              this.button['widgetID'] = undefined;
+            };
+
             // Add the widget to the panel
             let id = that.addWidget(this, {
               "name": name,
               "src": onClick["template"], // that._interpolateURI(onClick["template"], this.match);
               "permissions": onClick["permissions"],
-              "desc":desc
+              "desc":desc,
+              "panel":panel
             });
             plugin["widgets"].push(id);
-            
+
             // If a state exists, associate with a mediator object
             if ('state' in this.button) {
               this.button['widgetID'] = id;
@@ -205,7 +265,29 @@
                   };
                 }
               });
-            }
+            };
+
+            // If an active state exists, associate it with the widget
+            // so active-state changes can be reflected in the iframe.
+            if ('active' in this.button) {
+              this.button['widgetID'] = id;
+              let first = true;
+              this.button.active.associate({
+                setState : function (value) {
+                  if (first) {
+                    first = false;
+                    return;
+                  };
+                  if (services[id]) {
+                    services[id].sendMsg({
+                      action: 'state',
+                      key : 'active',
+                      value : value
+                    });
+                  };
+                }
+              });
+            };
           };
 
 
@@ -215,6 +297,11 @@
           if (embed['desc'] != undefined)
             obj['desc'] = embed['desc'];
 
+          if (onClick['active'] !== undefined) {
+            obj['active'] = stateClass.create([true, false]);
+            obj['active'].setIfNotYet(onClick['active'] ? true : false);
+          };
+
           if (onClick["action"] && onClick["action"] == "setWidget") {
 
             // Create a boolean state value,
@@ -559,6 +646,14 @@
           v["page"] = pi.page();
           v["total"] = pi.total();
           v["count"] = pi.count();
+        }
+
+        // Get active toggle state of the widget button
+        else if (d.key == 'Active') {
+          let button = services[d["originID"]].panel.button;
+          if (button && button.active) {
+            d["value"] = button.active.get();
+          };
         };
 
         // data needs to be mirrored
@@ -596,6 +691,24 @@
           // if (v["cq"] != undefined) {};
         }
 
+	      else if (d.key == "Title") {
+          // TODO: Only support "Add title"!
+	        let v = d["value"];
+          services[d["originID"]].panel.button.changeTitle(v);
+	      }
+
+        else if (d.key == "Active") {
+          let v = d["value"];
+          let button = services[d["originID"]].panel.button;
+          if (button && button.active) {
+            if (v !== undefined) {
+              button.active.set(v);
+            } else {
+              button.active.roll();
+            };
+          };
+        };
+
         break;
         
         // Redirect to a different page relative to the current
diff --git a/dev/js/src/plugin/service.js b/dev/js/src/plugin/service.js
index ef6836c..7b4e998 100644
--- a/dev/js/src/plugin/service.js
+++ b/dev/js/src/plugin/service.js
@@ -107,10 +107,12 @@
      */
     sendMsg : function (d) {
       let iframe = this.load();
-      iframe.contentWindow.postMessage(
-        d,
-        '*'
-      ); // TODO: Fix origin
+      if (iframe && iframe.contentWindow) {
+        iframe.contentWindow.postMessage(
+          d,
+          '*'
+        ); // TODO: Fix origin
+      };
     },
 
     // onClose : function () {},