crmConfirm - Allow confirmation dialogs using Angular partials
[civicrm-core.git] / js / angular-crm-ui.js
index 3e1242ac63869d96b48585c13e70a7cf2efaaa2f..7dd588aeda58ea5947460a544c91971faeb12bf0 100644 (file)
     .directive('crmUiAccordion', function() {
       return {
         scope: {
-          crmTitle: '@',
-          crmCollapsed: '@'
+          crmUiAccordion: '='
         },
-        template: '<div class="crm-accordion-wrapper" ng-class="cssClasses"><div class="crm-accordion-header">{{$parent.$eval(crmTitle)}}</div><div class="crm-accordion-body" ng-transclude></div></div>',
+        template: '<div ng-class="cssClasses"><div class="crm-accordion-header">{{crmUiAccordion.title}} <a crm-ui-help="help" ng-if="help"></a></div><div class="crm-accordion-body" ng-transclude></div></div>',
         transclude: true,
         link: function (scope, element, attrs) {
           scope.cssClasses = {
-            collapsed: scope.$parent.$eval(attrs.crmCollapsed)
+            'crm-accordion-wrapper': true,
+            collapsed: scope.crmUiAccordion.collapsed
           };
+          scope.help = null;
+          scope.$watch('crmUiAccordion', function(crmUiAccordion) {
+            if (crmUiAccordion && crmUiAccordion.help) {
+              scope.help = crmUiAccordion.help.clone({}, {
+                title: crmUiAccordion.title
+              });
+            }
+          });
         }
       };
     })
       };
     })
 
-    // Display a date widget.
-    // example: <input crm-ui-date ng-model="myobj.datefield" />
-    // example: <input crm-ui-date ng-model="myobj.datefield" crm-ui-date-format="yy-mm-dd" />
-    .directive('crmUiDate', function ($parse, $timeout) {
+    // Simple wrapper around $.crmDatepicker.
+    // example with no time input: <input crm-ui-datepicker="{time: false}" ng-model="myobj.datefield"/>
+    // example with custom date format: <input crm-ui-datepicker="{dateFormat: 'm/d/y'}" ng-model="myobj.datefield"/>
+    .directive('crmUiDatepicker', function () {
       return {
         restrict: 'AE',
         require: 'ngModel',
         scope: {
-          crmUiDateFormat: '@' // expression, date format (default: "yy-mm-dd")
+          crmUiDatepicker: '='
         },
         link: function (scope, element, attrs, ngModel) {
-          var fmt = attrs.crmUiDateFormat ? $parse(attrs.crmUiDateFormat)() : "yy-mm-dd";
-
-          element.addClass('dateplugin');
-          $(element).datepicker({
-            dateFormat: fmt
-          });
-
-          ngModel.$render = function $render() {
-            $(element).datepicker('setDate', ngModel.$viewValue);
+          ngModel.$render = function () {
+            element.val(ngModel.$viewValue).change();
           };
-          var updateParent = (function() {
-            $timeout(function () {
-              ngModel.$setViewValue(element.val());
-            });
-          });
 
-          element.on('change', updateParent);
+          element
+            .crmDatepicker(scope.crmUiDatepicker)
+            .on('change', function() {
+              var requiredLength = 19;
+              if (scope.crmUiDatepicker && scope.crmUiDatepicker.time === false) {
+                requiredLength = 10;
+              }
+              if (scope.crmUiDatepicker && scope.crmUiDatepicker.date === false) {
+                requiredLength = 8;
+              }
+              ngModel.$setValidity('incompleteDateTime', !($(this).val().length && $(this).val().length !== requiredLength));
+            });
         }
       };
     })
 
-    // Display a date-time widget.
-    // example: <div crm-ui-date-time ng-model="myobj.mydatetimefield"></div>
-    .directive('crmUiDateTime', function ($parse) {
+    // Display debug information (if available)
+    // For richer DX, checkout Batarang/ng-inspector (Chrome/Safari), or AngScope/ng-inspect (Firefox).
+    // example: <div crm-ui-debug="myobject" />
+    .directive('crmUiDebug', function ($location) {
       return {
         restrict: 'AE',
-        require: 'ngModel',
         scope: {
-          ngRequired: '@'
+          crmUiDebug: '@'
         },
-        templateUrl: '~/crmUi/datetime.html',
-        link: function (scope, element, attrs, ngModel) {
-          var ts = scope.ts = CRM.ts(null);
-          scope.dateLabel = ts('Date');
-          scope.timeLabel = ts('Time');
-          element.addClass('crm-ui-datetime');
-
-          ngModel.$render = function $render() {
-            if (!_.isEmpty(ngModel.$viewValue)) {
-              var dtparts = ngModel.$viewValue.split(/ /);
-              scope.dtparts = {date: dtparts[0], time: dtparts[1]};
-            }
-            else {
-              scope.dtparts = {date: '', time: ''};
-            }
-            validate();
-          };
-
-          function updateParent() {
-            validate();
-
-            if (_.isEmpty(scope.dtparts.date) && _.isEmpty(scope.dtparts.time)) {
-              ngModel.$setViewValue(' ');
-            }
-            else {
-              //ngModel.$setViewValue(scope.dtparts.date + ' ' + scope.dtparts.time);
-              ngModel.$setViewValue((scope.dtparts.date ? scope.dtparts.date : '') + ' ' + (scope.dtparts.time ? scope.dtparts.time : ''));
-            }
-          }
-
-          scope.$watch('dtparts.date', updateParent);
-          scope.$watch('dtparts.time', updateParent);
-
-          function validate() {
-            var incompleteDateTime = _.isEmpty(scope.dtparts.date) ^ _.isEmpty(scope.dtparts.time);
-            ngModel.$setValidity('incompleteDateTime', !incompleteDateTime);
-          }
-
-          function updateRequired() {
-            scope.required = scope.$parent.$eval(attrs.ngRequired);
-          }
-
-          if (attrs.ngRequired) {
-            updateRequired();
-            scope.$parent.$watch(attrs.ngRequired, updateRequired);
+        template: function() {
+          var args = $location.search();
+          return (args && args.angularDebug) ? '<div crm-ui-accordion crm-title=\'ts("Debug (%1)", {1: crmUiDebug})\' crm-collapsed="true"><pre>{{data|json}}</pre></div>' : '';
+        },
+        link: function(scope, element, attrs) {
+          var args = $location.search();
+          if (args && args.angularDebug) {
+            scope.ts = CRM.ts(null);
+            scope.$parent.$watch(attrs.crmUiDebug, function(data) {
+              scope.data = data;
+            });
           }
-
-          scope.reset = function reset() {
-            scope.dtparts = {date: '', time: ''};
-            ngModel.$setViewValue('');
-          };
         }
       };
     })
 
     // Display a field/row in a field list
-    // example: <div crm-ui-field crm-title="My Field"> {{mydata}} </div>
-    // example: <div crm-ui-field="subform.myfield" crm-title="'My Field'"> <input crm-ui-id="subform.myfield" name="myfield" /> </div>
-    // example: <div crm-ui-field="subform.myfield" crm-title="'My Field'"> <input crm-ui-id="subform.myfield" name="myfield" required /> </div>
+    // example: <div crm-ui-field="{title: ts('My Field')}"> {{mydata}} </div>
+    // example: <div crm-ui-field="{name: 'subform.myfield', title: ts('My Field')}"> <input crm-ui-id="subform.myfield" name="myfield" /> </div>
+    // example: <div crm-ui-field="{name: 'subform.myfield', title: ts('My Field')}"> <input crm-ui-id="subform.myfield" name="myfield" required /> </div>
+    // example: <div crm-ui-field="{name: 'subform.myfield', title: ts('My Field'), help: hs('help_field_name')}"> {{mydata}} </div>
     .directive('crmUiField', function() {
       // Note: When writing new templates, the "label" position is particular. See/patch "var label" below.
       var templateUrls = {
         require: '^crmUiIdScope',
         restrict: 'EA',
         scope: {
-          crmUiField: '@',
-          crmTitle: '@'
+          // {title, name, help, helpFile}
+          crmUiField: '='
         },
         templateUrl: function(tElement, tAttrs){
           var layout = tAttrs.crmLayout ? tAttrs.crmLayout : 'default';
         transclude: true,
         link: function (scope, element, attrs, crmUiIdCtrl) {
           $(element).addClass('crm-section');
-          scope.crmUiField = attrs.crmUiField;
-          scope.crmTitle = attrs.crmTitle;
+          scope.help = null;
+          scope.$watch('crmUiField', function(crmUiField) {
+            if (crmUiField && crmUiField.help) {
+              scope.help = crmUiField.help.clone({}, {
+                title: crmUiField.title
+              });
+            }
+          });
         }
       };
     })
       };
     })
 
+    // for example, see crmUiHelp
+    .service('crmUiHelp', function(){
+      // example: var h = new FieldHelp({id: 'foo'}); h.open();
+      function FieldHelp(options) {
+        this.options = options;
+      }
+      angular.extend(FieldHelp.prototype, {
+        get: function(n) {
+          return this.options[n];
+        },
+        open: function open() {
+          CRM.help(this.options.title, {id: this.options.id, file: this.options.file});
+        },
+        clone: function clone(options, defaults) {
+          return new FieldHelp(angular.extend({}, defaults, this.options, options));
+        }
+      });
+
+      // example: var hs = crmUiHelp({file: 'CRM/Foo/Bar'});
+      return function(defaults){
+        // example: hs('myfield')
+        // example: hs({id: 'myfield', title: 'Foo Bar', file: 'Whiz/Bang'})
+        return function(options) {
+          if (_.isString(options)) {
+            options = {id: options};
+          }
+          return new FieldHelp(angular.extend({}, defaults, options));
+        };
+      };
+    })
+
+    // Display a help icon
+    // Example: Use a default *.hlp file
+    //   scope.hs = crmUiHelp({file: 'Path/To/Help/File'});
+    //   HTML: <a crm-ui-help="hs({title:ts('My Field'), id:'my_field'})">
+    // Example: Use an explicit *.hlp file
+    //   HTML: <a crm-ui-help="hs({title:ts('My Field'), id:'my_field', file:'CRM/Foo/Bar'})">
+    .directive('crmUiHelp', function() {
+      return {
+        restrict: 'EA',
+        link: function(scope, element, attrs) {
+          setTimeout(function() {
+            var crmUiHelp = scope.$eval(attrs.crmUiHelp);
+            var title = crmUiHelp && crmUiHelp.get('title') ? ts('%1 Help', {1: crmUiHelp.get('title')}) : ts('Help');
+            element.attr('title', title);
+          }, 50);
+
+          element
+            .addClass('helpicon')
+            .attr('href', '#')
+            .on('click', function(e) {
+              e.preventDefault();
+              scope.$eval(attrs.crmUiHelp).open();
+            });
+        }
+      };
+    })
+
     // example: <div ng-form="subform" crm-ui-id-scope><label crm-ui-for="subform.foo">Foo:</label><input crm-ui-id="subform.foo" name="foo"/></div>
     .directive('crmUiFor', function ($parse, $timeout) {
       return {
             doc.close();
           };
 
+          // If the iframe is in a dialog, respond to resize events
+          $(elm).parent().on('dialogresize dialogopen', function(e, ui) {
+            $(this).css({padding: '0', margin: '0', overflow: 'hidden'});
+            iframe.setAttribute('height', '' + $(this).innerHeight() + 'px');
+          });
+
           scope.$parent.$watch(attrs.crmUiIframe, refresh);
         }
       };
       };
     })
 
+    // CrmUiOrderCtrl is a controller class which manages sort orderings.
+    // Ex:
+    //   JS:   $scope.myOrder = new CrmUiOrderCtrl(['+field1', '-field2]);
+    //         $scope.myOrder.toggle('field1');
+    //         $scope.myOrder.setDir('field2', '');
+    //   HTML: <tr ng-repeat="... | order:myOrder.get()">...</tr>
+    .service('CrmUiOrderCtrl', function(){
+      //
+      function CrmUiOrderCtrl(defaults){
+        this.values = defaults;
+      }
+      angular.extend(CrmUiOrderCtrl.prototype, {
+        get: function get() {
+          return this.values;
+        },
+        getDir: function getDir(name) {
+          if (this.values.indexOf(name) >= 0 || this.values.indexOf('+' + name) >= 0) {
+            return '+';
+          }
+          if (this.values.indexOf('-' + name) >= 0) {
+            return '-';
+          }
+          return '';
+        },
+        // @return bool TRUE if something is removed
+        remove: function remove(name) {
+          var idx = this.values.indexOf(name);
+          if (idx >= 0) {
+            this.values.splice(idx, 1);
+            return true;
+          }
+          else {
+            return false;
+          }
+        },
+        setDir: function setDir(name, dir) {
+          return this.toggle(name, dir);
+        },
+        // Toggle sort order on a field.
+        // To set a specific order, pass optional parameter 'next' ('+', '-', or '').
+        toggle: function toggle(name, next) {
+          if (!next && next !== '') {
+            next = '+';
+            if (this.remove(name) || this.remove('+' + name)) {
+              next = '-';
+            }
+            if (this.remove('-' + name)) {
+              next = '';
+            }
+          }
+
+          if (next == '+') {
+            this.values.unshift('+' + name);
+          }
+          else if (next == '-') {
+            this.values.unshift('-' + name);
+          }
+        }
+      });
+      return CrmUiOrderCtrl;
+    })
+
+    // Define a controller which manages sort order. You may interact with the controller
+    // directly ("myOrder.toggle('fieldname')") order using the helper, crm-ui-order-by.
+    // example:
+    //   <span crm-ui-order="{var: 'myOrder', defaults: {'-myField'}}"></span>
+    //   <th><a crm-ui-order-by="[myOrder,'myField']">My Field</a></th>
+    //   <tr ng-repeat="... | order:myOrder.get()">...</tr>
+    //   <button ng-click="myOrder.toggle('myField')">
+    .directive('crmUiOrder', function(CrmUiOrderCtrl) {
+      return {
+        link: function(scope, element, attrs){
+          var options = angular.extend({var: 'crmUiOrderBy'}, scope.$eval(attrs.crmUiOrder));
+          scope[options.var] = new CrmUiOrderCtrl(options.defaults);
+        }
+      };
+    })
+
+    // For usage, see crmUiOrder (above)
+    .directive('crmUiOrderBy', function() {
+      return {
+        link: function(scope, element, attrs) {
+          function updateClass(crmUiOrderCtrl, name) {
+            var dir = crmUiOrderCtrl.getDir(name);
+            element
+              .toggleClass('sorting_asc', dir === '+')
+              .toggleClass('sorting_desc', dir === '-')
+              .toggleClass('sorting', dir === '');
+          }
+
+          element.on('click', function(e){
+            var tgt = scope.$eval(attrs.crmUiOrderBy);
+            tgt[0].toggle(tgt[1]);
+            updateClass(tgt[0], tgt[1]);
+            e.preventDefault();
+            scope.$digest();
+          });
+
+          var tgt = scope.$eval(attrs.crmUiOrderBy);
+          updateClass(tgt[0], tgt[1]);
+        }
+      };
+    })
+
     // Display a fancy SELECT (based on select2).
     // usage: <select crm-ui-select="{placeholder:'Something',allowClear:true,...}" ng-model="myobj.field"><option...></select>
     .directive('crmUiSelect', function ($parse, $timeout) {
       return {
         require: '?ngModel',
         scope: {
-          crmUiSelect: '@'
+          crmUiSelect: '='
         },
         link: function (scope, element, attrs, ngModel) {
           // In cases where UI initiates update, there may be an extra
 
           function init() {
             // TODO watch select2-options
-            var options = attrs.crmUiSelect ? scope.$parent.$eval(attrs.crmUiSelect) : {};
-            element.select2(options);
+            element.select2(scope.crmUiSelect || {});
+            element.on('change', refreshModel);
+            $timeout(ngModel.$render);
+          }
+
+          init();
+        }
+      };
+    })
+
+    // Render a crmEntityRef widget
+    // usage: <input crm-entityref="{entity: 'Contact', select: {allowClear:true}}" ng-model="myobj.field" />
+    .directive('crmEntityref', function ($parse, $timeout) {
+      return {
+        require: '?ngModel',
+        scope: {
+          crmEntityref: '='
+        },
+        link: function (scope, element, attrs, ngModel) {
+          // In cases where UI initiates update, there may be an extra
+          // call to refreshUI, but it doesn't create a cycle.
+
+          ngModel.$render = function () {
+            $timeout(function () {
+              // ex: msg_template_id adds new item then selects it; use $timeout to ensure that
+              // new item is added before selection is made
+              element.select2('val', ngModel.$viewValue);
+            });
+          };
+          function refreshModel() {
+            var oldValue = ngModel.$viewValue, newValue = element.select2('val');
+            if (oldValue != newValue) {
+              scope.$parent.$apply(function () {
+                ngModel.$setViewValue(newValue);
+              });
+            }
+          }
+
+          function init() {
+            // TODO watch options
+            // TODO can we infer "entity" from model?
+            element.crmEntityRef(scope.crmEntityref || {});
             element.on('change', refreshModel);
             $timeout(ngModel.$render);
           }
       };
     })
 
-    // Display a time-entry field.
-    // example: <input crm-ui-time ng-model="myobj.mytimefield" />
-    .directive('crmUiTime', function ($parse, $timeout) {
-      return {
-        restrict: 'AE',
-        require: 'ngModel',
-        scope: {
-        },
-        link: function (scope, element, attrs, ngModel) {
-          element.addClass('crm-form-text six');
-          element.timeEntry({show24Hours: true});
-
-          ngModel.$render = function $render() {
-            element.timeEntry('setTime', ngModel.$viewValue);
-          };
-
-          var updateParent = (function () {
-            $timeout(function () {
-              ngModel.$setViewValue(element.val());
-            });
-          });
-          element.on('change', updateParent);
-        }
-      };
-    })
-
     // Generic, field-independent form validator.
     // example: <span ng-model="placeholder" crm-ui-validate="foo && bar || whiz" />
     // example: <span ng-model="placeholder" crm-ui-validate="foo && bar || whiz" crm-ui-validate-name="myError" />
           this.$last = function() { return this.$index() === steps.length -1; };
           this.$maxVisit = function() { return maxVisited; };
           this.$validStep = function() {
-            return steps[selectedIndex].isStepValid();
+            return steps[selectedIndex] && steps[selectedIndex].isStepValid();
           };
           this.iconFor = function(index) {
             if (index < this.$index()) return '√';
             $parse($scope.crmUiWizard).assign($scope.$parent, this);
           }
         },
-        link: function (scope, element, attrs) {}
+        link: function (scope, element, attrs) {
+          scope.ts = CRM.ts(null);
+        }
       };
     })
 
       };
     })
 
+    // Example: <button crm-icon="check">Save</button>
+    .directive('crmIcon', function() {
+      return {
+        restrict: 'EA',
+        scope: {},
+        link: function (scope, element, attrs) {
+          $(element).prepend('<span class="icon ui-icon-' + attrs.crmIcon + '"></span> ');
+          if ($(element).is('button')) {
+            $(element).addClass('crm-button');
+          }
+        }
+      };
+    })
+
     // example: <div crm-ui-wizard-step crm-title="ts('My Title')" ng-form="mySubForm">...content...</div>
     // If there are any conditional steps, then be sure to set a weight explicitly on *all* steps to maintain ordering.
     // example: <div crm-ui-wizard-step="100" crm-title="..." ng-if="...">...content...</div>
 
     // Example: <button crm-confirm="{message: ts('Are you sure you want to continue?')}" on-yes="frobnicate(123)">Frobincate</button>
     // Example: <button crm-confirm="{type: 'disable', obj: myObject}" on-yes="myObject.is_active=0; myObject.save()">Disable</button>
-    .directive('crmConfirm', function () {
+    // Example: <button crm-confirm="{templateUrl: '~/path/to/view.html', export: {foo: bar}}" on-yes="frobnicate(123)">Frobincate</button>
+    .directive('crmConfirm', function ($compile, $rootScope, $templateRequest, $q) {
       // Helpers to calculate default options for CRM.confirm()
       var defaultFuncs = {
         'disable': function (options) {
           };
         }
       };
+      var confirmCount = 0;
       return {
-        template: '',
         link: function (scope, element, attrs) {
           $(element).click(function () {
             var options = scope.$eval(attrs.crmConfirm);
+            if (attrs.title && !options.title) {
+              options.title = attrs.title;
+            }
             var defaults = (options.type) ? defaultFuncs[options.type](options) : {};
+
+            var tpl = null, stubId = null;
+            if (!options.message) {
+              if (options.templateUrl) {
+                tpl = $templateRequest(options.templateUrl);
+              }
+              else if (options.template) {
+                tpl = options.template;
+              }
+              if (tpl) {
+                stubId = 'crmUiConfirm_' + (++confirmCount);
+                options.message = '<div id="' + stubId + '"></div>';
+              }
+            }
+
             CRM.confirm(_.extend(defaults, options))
-              .on('crmConfirm:yes', function () { scope.$apply(attrs.onYes); })
-              .on('crmConfirm:no', function () { scope.$apply(attrs.onNo); });
+              .on('crmConfirm:yes', function() { scope.$apply(attrs.onYes); })
+              .on('crmConfirm:no', function() { scope.$apply(attrs.onNo); });
+
+            if (tpl && stubId) {
+              $q.when(tpl, function(html) {
+                var scope = options.scope || $rootScope.$new();
+                if (options.export) {
+                  angular.extend(scope, options.export);
+                }
+                var linker = $compile(html);
+                $('#' + stubId).append($(linker(scope)));
+              });
+            }
           });
         }
       };