From 7e3179ab9fdbba6c00ff1c30f97b432b58f9eafb Mon Sep 17 00:00:00 2001
From: Chris Chua <chris@thousandeyes.com>
Date: Mon, 24 Feb 2014 00:08:20 -0800
Subject: [PATCH] feat(popover): add popover-template directive

---
 src/popover/docs/demo.html                | 21 +++++--
 src/popover/docs/demo.js                  |  7 ++-
 src/popover/docs/readme.md                |  7 +++
 src/popover/popover.js                    | 14 +++++
 src/popover/test/popover-template.spec.js | 66 ++++++++++++++++++++
 src/tooltip/tooltip.js                    | 76 ++++++++++++++++++++++-
 template/popover/popover-template.html    | 10 +++
 7 files changed, 193 insertions(+), 8 deletions(-)
 create mode 100644 src/popover/test/popover-template.spec.js
 create mode 100644 template/popover/popover-template.html

diff --git a/src/popover/docs/demo.html b/src/popover/docs/demo.html
index 8955b1918e..d89b5c5ef1 100644
--- a/src/popover/docs/demo.html
+++ b/src/popover/docs/demo.html
@@ -2,14 +2,27 @@
     <h4>Dynamic</h4>
     <div class="form-group">
       <label>Popup Text:</label>
-      <input type="text" ng-model="dynamicPopover" class="form-control">
+      <input type="text" ng-model="dynamicPopover.content" class="form-control">
     </div>
     <div class="form-group">
       <label>Popup Title:</label>
-      <input type="text" ng-model="dynamicPopoverTitle" class="form-control">
+      <input type="text" ng-model="dynamicPopover.title" class="form-control">
     </div>
-    <button popover="{{dynamicPopover}}" popover-title="{{dynamicPopoverTitle}}" class="btn btn-default">Dynamic Popover</button>
-    
+    <div class="form-group">
+      <label>Popup Template:</label>
+      <input type="text" ng-model="dynamicPopover.templateUrl" class="form-control">
+    </div>
+    <button popover="{{dynamicPopover.content}}" popover-title="{{dynamicPopover.title}}" class="btn btn-default">Dynamic Popover</button>
+
+    <button popover-template="{{dynamicPopover.templateUrl}}" popover-template-title="{{dynamicPopover.title}}" class="btn btn-default">Popover With Template</button>
+
+    <script type="text/ng-template" id="myPopoverTemplate.html">
+        <div>{{dynamicPopover.content}}</div>
+        <div class="form-group">
+          <label>Popup Title:</label>
+          <input type="text" ng-model="dynamicPopover.title" class="form-control">
+        </div>
+    </script>
     <hr />
     <h4>Positional</h4>
     <button popover-placement="top" popover="On the Top!" class="btn btn-default">Top</button>
diff --git a/src/popover/docs/demo.js b/src/popover/docs/demo.js
index 6cce89812b..e4e0e2ff29 100644
--- a/src/popover/docs/demo.js
+++ b/src/popover/docs/demo.js
@@ -1,4 +1,7 @@
 angular.module('ui.bootstrap.demo').controller('PopoverDemoCtrl', function ($scope) {
-  $scope.dynamicPopover = 'Hello, World!';
-  $scope.dynamicPopoverTitle = 'Title';
+  $scope.dynamicPopover = {
+    content: 'Hello, World!',
+    templateUrl: 'myTemplatePopover.html',
+    title: 'Title'
+  };
 });
diff --git a/src/popover/docs/readme.md b/src/popover/docs/readme.md
index cbac5f3d26..dcfd6dff6f 100644
--- a/src/popover/docs/readme.md
+++ b/src/popover/docs/readme.md
@@ -4,6 +4,13 @@ directive supports multiple placements, optional transition animation, and more.
 Like the Bootstrap jQuery plugin, the popover **requires** the tooltip
 module.
 
+There are two versions of the popover: `popover` and `popover-template`:
+
+- `popover` takes text only and will escape any HTML provided for the popover
+  body.
+- `popover-template` takes text that specifies the location of a template to
+  use for the popover body.
+
 The popover directives provides several optional attributes to control how it
 will display:
 
diff --git a/src/popover/popover.js b/src/popover/popover.js
index 2bea0a3e10..dee4c94381 100644
--- a/src/popover/popover.js
+++ b/src/popover/popover.js
@@ -5,6 +5,20 @@
  */
 angular.module( 'ui.bootstrap.popover', [ 'ui.bootstrap.tooltip' ] )
 
+.directive( 'popoverTemplatePopup', function () {
+  return {
+    restrict: 'EA',
+    replace: true,
+    scope: { title: '@', content: '@', placement: '@', animation: '&', isOpen: '&',
+      originScope: '&' },
+    templateUrl: 'template/popover/popover-template.html'
+  };
+})
+
+.directive( 'popoverTemplate', [ '$tooltip', function ( $tooltip ) {
+  return $tooltip( 'popoverTemplate', 'popoverTemplate', 'click' );
+}])
+
 .directive( 'popoverPopup', function () {
   return {
     restrict: 'EA',
diff --git a/src/popover/test/popover-template.spec.js b/src/popover/test/popover-template.spec.js
new file mode 100644
index 0000000000..cb504c28d9
--- /dev/null
+++ b/src/popover/test/popover-template.spec.js
@@ -0,0 +1,66 @@
+describe('popover template', function() {
+  var elm,
+      elmBody,
+      scope,
+      elmScope,
+      tooltipScope;
+
+  // load the popover code
+  beforeEach(module('ui.bootstrap.popover'));
+
+  // load the template
+  beforeEach(module('template/popover/popover.html'));
+  beforeEach(module('template/popover/popover-template.html'));
+
+  beforeEach(inject(function ($templateCache) {
+    $templateCache.put('myUrl', [200, '<span>{{ myTemplateText }}</span>', {}]);
+  }));
+
+  beforeEach(inject(function($rootScope, $compile) {
+    elmBody = angular.element(
+      '<div><span popover-template="{{ templateUrl }}">Selector Text</span></div>'
+    );
+
+    scope = $rootScope;
+    $compile(elmBody)(scope);
+    scope.templateUrl = 'myUrl';
+
+    scope.$digest();
+    elm = elmBody.find('span');
+    elmScope = elm.scope();
+    tooltipScope = elmScope.$$childTail;
+  }));
+
+  it('should open on click', inject(function() {
+    elm.trigger( 'click' );
+    expect( tooltipScope.isOpen ).toBe( true );
+
+    expect( elmBody.children().length ).toBe( 2 );
+  }));
+
+  it('should not open on click if templateUrl is empty', inject(function() {
+    scope.templateUrl = null;
+    scope.$digest();
+
+    elm.trigger( 'click' );
+    expect( tooltipScope.isOpen ).toBe( false );
+
+    expect( elmBody.children().length ).toBe( 1 );
+  }));
+
+  it('should show updated text', inject(function() {
+    scope.myTemplateText = 'some text';
+    scope.$digest();
+
+    elm.trigger( 'click' );
+    expect( tooltipScope.isOpen ).toBe( true );
+
+    expect( elmBody.children().eq(1).text().trim() ).toBe( 'some text' );
+
+    scope.myTemplateText = 'new text';
+    scope.$digest();
+
+    expect( elmBody.children().eq(1).text().trim() ).toBe( 'new text' );
+  }));
+});
+
diff --git a/src/tooltip/tooltip.js b/src/tooltip/tooltip.js
index 4c5dc825de..646eb58b72 100644
--- a/src/tooltip/tooltip.js
+++ b/src/tooltip/tooltip.js
@@ -103,6 +103,7 @@ angular.module( 'ui.bootstrap.tooltip', [ 'ui.bootstrap.position', 'ui.bootstrap
           'class="'+startSym+'class'+endSym+'" '+
           'animation="animation" '+
           'is-open="isOpen"'+
+          'origin-scope="origScope" '+
           '>'+
         '</div>';
 
@@ -111,7 +112,7 @@ angular.module( 'ui.bootstrap.tooltip', [ 'ui.bootstrap.position', 'ui.bootstrap
         compile: function (tElem, tAttrs) {
           var tooltipLinker = $compile( template );
 
-          return function link ( scope, element, attrs ) {
+          return function link ( scope, element, attrs, tooltipCtrl ) {
             var tooltip;
             var tooltipLinkedScope;
             var transitionTimeout;
@@ -132,6 +133,9 @@ angular.module( 'ui.bootstrap.tooltip', [ 'ui.bootstrap.position', 'ui.bootstrap
               tooltip.css( ttPosition );
             };
 
+            // Set up the correct scope to allow transclusion later
+            ttScope.origScope = scope;
+
             // By default, the tooltip is not open.
             // TODO add ability to start tooltip opened
             ttScope.isOpen = false;
@@ -197,7 +201,7 @@ angular.module( 'ui.bootstrap.tooltip', [ 'ui.bootstrap.position', 'ui.bootstrap
 
               // And show the tooltip.
               ttScope.isOpen = true;
-              ttScope.$digest(); // digest required as $apply is not called
+              ttScope.$apply(); // digest required as $apply is not called
 
               // Return positioning function as promise callback for correct
               // positioning after draw.
@@ -349,6 +353,74 @@ angular.module( 'ui.bootstrap.tooltip', [ 'ui.bootstrap.position', 'ui.bootstrap
   }];
 })
 
+// This is mostly ngInclude code but with a custom scope
+.directive( 'tooltipTemplateTransclude', [
+         '$animate', '$sce', '$compile', '$templateRequest',
+function ($animate ,  $sce ,  $compile ,  $templateRequest) {
+  return {
+    link: function ( scope, elem, attrs ) {
+      var origScope = scope.$eval(attrs.tooltipTemplateTranscludeScope);
+
+      var changeCounter = 0,
+        currentScope,
+        previousElement,
+        currentElement;
+
+      var cleanupLastIncludeContent = function() {
+        if (previousElement) {
+          previousElement.remove();
+          previousElement = null;
+        }
+        if (currentScope) {
+          currentScope.$destroy();
+          currentScope = null;
+        }
+        if (currentElement) {
+          $animate.leave(currentElement).then(function() {
+            previousElement = null;
+          });
+          previousElement = currentElement;
+          currentElement = null;
+        }
+      };
+
+      scope.$watch($sce.parseAsResourceUrl(attrs.tooltipTemplateTransclude), function (src) {
+        var thisChangeId = ++changeCounter;
+
+        if (src) {
+          //set the 2nd param to true to ignore the template request error so that the inner
+          //contents and scope can be cleaned up.
+          $templateRequest(src, true).then(function(response) {
+            if (thisChangeId !== changeCounter) { return; }
+            var newScope = origScope.$new();
+            var template = response;
+
+            var clone = $compile(template)(newScope, function(clone) {
+              cleanupLastIncludeContent();
+              $animate.enter(clone, elem);
+            });
+
+            currentScope = newScope;
+            currentElement = clone;
+
+            currentScope.$emit('$includeContentLoaded', src);
+          }, function() {
+            if (thisChangeId === changeCounter) {
+              cleanupLastIncludeContent();
+              scope.$emit('$includeContentError', src);
+            }
+          });
+          scope.$emit('$includeContentRequested', src);
+        } else {
+          cleanupLastIncludeContent();
+        }
+      });
+
+      scope.$on('$destroy', cleanupLastIncludeContent);
+    }
+  };
+}])
+
 .directive( 'tooltipPopup', function () {
   return {
     restrict: 'EA',
diff --git a/template/popover/popover-template.html b/template/popover/popover-template.html
new file mode 100644
index 0000000000..43731c8284
--- /dev/null
+++ b/template/popover/popover-template.html
@@ -0,0 +1,10 @@
+<div class="popover {{placement}}" ng-class="{ in: isOpen(), fade: animation() }">
+  <div class="arrow"></div>
+
+  <div class="popover-inner">
+      <h3 class="popover-title" ng-bind="title" ng-show="title"></h3>
+      <div class="popover-content"
+        tooltip-template-transclude="content"
+        tooltip-template-transclude-scope="originScope()"></div>
+  </div>
+</div>