SearchKit - Add links/menus/buttons to search displays
authorColeman Watts <coleman@civicrm.org>
Thu, 25 Feb 2021 22:07:37 +0000 (17:07 -0500)
committerColeman Watts <coleman@civicrm.org>
Fri, 26 Feb 2021 00:17:12 +0000 (19:17 -0500)
28 files changed:
ext/search/CRM/Search/Upgrader.php
ext/search/Civi/Search/Admin.php
ext/search/ang/crmSearchAdmin.module.js
ext/search/ang/crmSearchAdmin/crmSearchAdminDisplay.component.js
ext/search/ang/crmSearchAdmin/crmSearchAdminLinkGroup.component.js [new file with mode: 0644]
ext/search/ang/crmSearchAdmin/crmSearchAdminLinkGroup.html [new file with mode: 0644]
ext/search/ang/crmSearchAdmin/crmSearchAdminLinkSelect.component.js
ext/search/ang/crmSearchAdmin/crmSearchAdminLinkSelect.html
ext/search/ang/crmSearchAdmin/displays/colType/buttons.html [new file with mode: 0644]
ext/search/ang/crmSearchAdmin/displays/colType/field.html [moved from ext/search/ang/crmSearchAdmin/displays/common/fieldOptions.html with 93% similarity]
ext/search/ang/crmSearchAdmin/displays/colType/links.html [new file with mode: 0644]
ext/search/ang/crmSearchAdmin/displays/colType/menu.html [new file with mode: 0644]
ext/search/ang/crmSearchAdmin/displays/common/addColMenu.html [new file with mode: 0644]
ext/search/ang/crmSearchAdmin/displays/common/unusedColumns.html
ext/search/ang/crmSearchAdmin/displays/searchAdminDisplayList.component.js
ext/search/ang/crmSearchAdmin/displays/searchAdminDisplayList.html
ext/search/ang/crmSearchAdmin/displays/searchAdminDisplayTable.component.js
ext/search/ang/crmSearchAdmin/displays/searchAdminDisplayTable.html
ext/search/ang/crmSearchDisplay.module.js
ext/search/ang/crmSearchDisplay/colType/buttons.html [new file with mode: 0644]
ext/search/ang/crmSearchDisplay/colType/field.html [new file with mode: 0644]
ext/search/ang/crmSearchDisplay/colType/links.html [new file with mode: 0644]
ext/search/ang/crmSearchDisplay/colType/menu.html [new file with mode: 0644]
ext/search/ang/crmSearchDisplayList/crmSearchDisplayList.component.js
ext/search/ang/crmSearchDisplayList/crmSearchDisplayListItems.html
ext/search/ang/crmSearchDisplayTable/crmSearchDisplayTable.component.js
ext/search/ang/crmSearchDisplayTable/crmSearchDisplayTable.html
ext/search/css/crmSearchAdmin.css

index 697e5bafbeab2fc95ee981ecb3f69b6701e0acc4..670d3ca54594c147efd8b4e3a1109e4451326d27 100644 (file)
@@ -74,6 +74,7 @@ class CRM_Search_Upgrader extends CRM_Search_Upgrader_Base {
           $key = $newAliases[$column['expr']] ?? $column['expr'];
           unset($display['settings']['columns'][$c]['expr']);
           $display['settings']['columns'][$c]['key'] = explode(' AS ', $key)[1] ?? $key;
+          $display['settings']['columns'][$c]['type'] = 'field';
         }
         \Civi\Api4\SearchDisplay::update(FALSE)
           ->setValues($display)
index 4a58faf12b85dbafdf4eeb0c63638744820650b8..711a8378ca16fbd44f4b1defc10706eb5280fc28 100644 (file)
@@ -11,6 +11,8 @@
 
 namespace Civi\Search;
 
+use CRM_Search_ExtensionUtil as E;
+
 /**
  * Class Admin
  * @package Civi\Search
@@ -28,6 +30,7 @@ class Admin {
       'operators' => \CRM_Utils_Array::makeNonAssociative(self::getOperators()),
       'functions' => \CRM_Api4_Page_Api4Explorer::getSqlFunctions(),
       'displayTypes' => Display::getDisplayTypes(['id', 'name', 'label', 'description', 'icon']),
+      'styles' => \CRM_Utils_Array::makeNonAssociative(self::getStyles()),
       'afformEnabled' => (bool) \CRM_Utils_Array::findAll(
         \CRM_Extension_System::singleton()->getMapper()->getActiveModuleFiles(),
         ['fullName' => 'org.civicrm.afform']
@@ -50,15 +53,29 @@ class Admin {
       '<' => '<',
       '>=' => '≥',
       '<=' => '≤',
-      'CONTAINS' => ts('Contains'),
-      'IN' => ts('Is One Of'),
-      'NOT IN' => ts('Not One Of'),
-      'LIKE' => ts('Is Like'),
-      'NOT LIKE' => ts('Not Like'),
-      'BETWEEN' => ts('Is Between'),
-      'NOT BETWEEN' => ts('Not Between'),
-      'IS NULL' => ts('Is Null'),
-      'IS NOT NULL' => ts('Not Null'),
+      'CONTAINS' => E::ts('Contains'),
+      'IN' => E::ts('Is One Of'),
+      'NOT IN' => E::ts('Not One Of'),
+      'LIKE' => E::ts('Is Like'),
+      'NOT LIKE' => E::ts('Not Like'),
+      'BETWEEN' => E::ts('Is Between'),
+      'NOT BETWEEN' => E::ts('Not Between'),
+      'IS NULL' => E::ts('Is Null'),
+      'IS NOT NULL' => E::ts('Not Null'),
+    ];
+  }
+
+  /**
+   * @return string[]
+   */
+  public static function getStyles():array {
+    return [
+      'default' => E::ts('Default'),
+      'primary' => E::ts('Primary'),
+      'success' => E::ts('Success'),
+      'info' => E::ts('Info'),
+      'warning' => E::ts('Warning'),
+      'danger' => E::ts('Danger'),
     ];
   }
 
@@ -84,15 +101,15 @@ class Admin {
           unset($entity['paths'][$action]);
           switch ($action) {
             case 'view':
-              $title = ts('View %1', [1 => $entity['title']]);
+              $title = E::ts('View %1', [1 => $entity['title']]);
               break;
 
             case 'update':
-              $title = ts('Edit %1', [1 => $entity['title']]);
+              $title = E::ts('Edit %1', [1 => $entity['title']]);
               break;
 
             case 'delete':
-              $title = ts('Delete %1', [1 => $entity['title']]);
+              $title = E::ts('Delete %1', [1 => $entity['title']]);
               break;
 
             default:
@@ -239,7 +256,7 @@ class Admin {
               $alias = $baseEntity['name'] . "_{$bridge}_" . $targetEntityName;
               $joins[$baseEntity['name']][] = [
                 'label' => $baseEntity['title'] . ' ' . $targetsTitle,
-                'description' => ts('Multiple %1 per %2', [1 => $targetsTitle, 2 => $baseEntity['title']]),
+                'description' => E::ts('Multiple %1 per %2', [1 => $targetsTitle, 2 => $baseEntity['title']]),
                 'entity' => $targetEntityName,
                 'conditions' => array_merge(
                   [$bridge],
@@ -254,7 +271,7 @@ class Admin {
                 $alias = $targetEntityName . "_{$bridge}_" . $baseEntity['name'];
                 $joins[$targetEntityName][] = [
                   'label' => $targetEntity['title'] . ' ' . $baseEntity['title_plural'],
-                  'description' => ts('Multiple %1 per %2', [1 => $baseEntity['title_plural'], 2 => $targetEntity['title']]),
+                  'description' => E::ts('Multiple %1 per %2', [1 => $baseEntity['title_plural'], 2 => $targetEntity['title']]),
                   'entity' => $baseEntity['name'],
                   'conditions' => array_merge(
                     [$bridge],
index 6db8c6ba143f115cc525537f6dbe070fe01bae96..6ade8b2cd0b88a7433cf7a12349fa5f1442f614d 100644 (file)
@@ -81,7 +81,7 @@
       $scope.$ctrl = this;
     })
 
-    .factory('searchMeta', function() {
+    .factory('searchMeta', function($q) {
       function getEntity(entityName) {
         if (entityName) {
           return _.find(CRM.crmSearchAdmin.schema, {name: entityName});
               }
             });
           });
+        },
+        pickIcon: function() {
+          var deferred = $q.defer();
+          $('#crm-search-admin-icon-picker').off('change').siblings('.crm-icon-picker-button').click();
+          $('#crm-search-admin-icon-picker').on('change', function() {
+            deferred.resolve($(this).val());
+          });
+          return deferred.promise;
+        }
+      };
+    })
+    .directive('contenteditable', function() {
+      return {
+        require: 'ngModel',
+        link: function(scope, elm, attrs, ctrl) {
+          // view -> model
+          elm.on('blur', function() {
+            ctrl.$setViewValue(elm.html());
+          });
+
+          // model -> view
+          ctrl.$render = function() {
+            elm.html(ctrl.$viewValue);
+          };
         }
       };
     });
 
+  // Shoehorn in a non-angular widget for picking icons
+  $(function() {
+    $('#crm-container').append('<div style="display:none"><input id="crm-search-admin-icon-picker"></div>');
+    CRM.loadScript(CRM.config.resourceBase + 'js/jquery/jquery.crmIconPicker.js').done(function() {
+      $('#crm-search-admin-icon-picker').crmIconPicker();
+    });
+  });
+
 })(angular, CRM.$, CRM._);
index afea8e8b60a901f29ed94e702f3719fdf5f4f039..2e3fd7aebec2bd95600287e3d5dd047e16809f70 100644 (file)
 
       this.preview = this.stale = false;
 
+      this.colTypes = {
+        links: {
+          label: ts('Links'),
+          icon: 'fa-link',
+          defaults: {
+            links: []
+          }
+        },
+        buttons: {
+          label: ts('Buttons'),
+          icon: 'fa-square-o',
+          defaults: {
+            size: 'btn-sm',
+            links: []
+          }
+        },
+        menu: {
+          label: ts('Menu'),
+          icon: 'fa-bars',
+          defaults: {
+            text: ts('Actions'),
+            style: 'default',
+            size: 'btn-sm',
+            icon: 'fa-bars',
+            links: []
+          }
+        },
+      };
+
       this.sortableOptions = {
         connectWith: '.crm-search-admin-edit-columns',
         containment: '.crm-search-admin-edit-columns-wrapper'
       };
 
+      this.styles = CRM.crmSearchAdmin.styles;
+
+      this.addCol = function(type) {
+        var col = _.cloneDeep(this.colTypes[type].defaults);
+        col.type = type;
+        if (this.display.type === 'table') {
+          col.alignment = 'text-right';
+        }
+        ctrl.display.settings.columns.push(col);
+      };
+
       this.removeCol = function(index) {
-        ctrl.hiddenColumns.push(ctrl.display.settings.columns[index]);
+        if (ctrl.display.settings.columns[index].type === 'field') {
+          ctrl.hiddenColumns.push(ctrl.display.settings.columns[index]);
+        }
         ctrl.display.settings.columns.splice(index, 1);
       };
 
         return searchMeta.getDefaultLabel(expr);
       };
 
+      this.getColLabel = function(col) {
+        if (col.type === 'field') {
+          return ctrl.getFieldLabel(col.key);
+        }
+        return ctrl.colTypes[col.type].label;
+      };
+
       function fieldToColumn(fieldExpr, defaults) {
         var info = searchMeta.parseExpr(fieldExpr),
           values = _.cloneDeep(defaults);
         return values;
       }
 
+      this.getLinks = function() {
+        if (!ctrl.links) {
+          ctrl.links = buildLinks();
+        }
+        return ctrl.links;
+      };
+
+      // Build a list of all possible links to main entity or join entities
+      function buildLinks() {
+        // Links to main entity
+        var links = _.cloneDeep(searchMeta.getEntity(ctrl.savedSearch.api_entity).paths || []);
+        // Links to explicitly joined entities
+        _.each(ctrl.savedSearch.api_params.join, function(join) {
+          var joinName = join[0].split(' AS '),
+            joinEntity = searchMeta.getEntity(joinName[0]);
+          _.each(joinEntity.paths, function(path) {
+            var link = _.cloneDeep(path);
+            link.path = link.path.replace(/\[/g, '[' + joinName[1] + '.');
+            links.push(link);
+          });
+        });
+        // Links to implicit joins
+        _.each(ctrl.savedSearch.api_params.select, function(fieldName) {
+          if (!_.includes(fieldName, ' AS ')) {
+            var info = searchMeta.parseExpr(fieldName);
+            if (info.field && !info.suffix && !info.fn && (info.field.fk_entity || info.field.entity !== info.field.baseEntity)) {
+              var idField = info.field.fk_entity ? fieldName : fieldName.substr(0, fieldName.lastIndexOf('.')) + '_id';
+              if (!ctrl.crmSearchAdmin.canAggregate(idField)) {
+                var joinEntity = searchMeta.getEntity(info.field.fk_entity || info.field.entity);
+                _.each(joinEntity.paths, function(path) {
+                  var link = _.cloneDeep(path);
+                  link.path = link.path.replace(/\[id/g, '[' + idField);
+                  links.push(link);
+                });
+              }
+            }
+          }
+        });
+        return links;
+      }
+
+      this.pickIcon = function(model, key) {
+        searchMeta.pickIcon().then(function(icon) {
+          model[key] = icon;
+        });
+      };
+
       // Helper function to sort active from hidden columns and initialize each column with defaults
       this.initColumns = function(defaults) {
         if (!ctrl.display.settings.columns) {
             }
           });
           _.eachRight(activeColumns, function(key, index) {
-            if (!_.includes(selectAliases, key)) {
+            if (key && !_.includes(selectAliases, key)) {
               ctrl.display.settings.columns.splice(index, 1);
             }
           });
diff --git a/ext/search/ang/crmSearchAdmin/crmSearchAdminLinkGroup.component.js b/ext/search/ang/crmSearchAdmin/crmSearchAdminLinkGroup.component.js
new file mode 100644 (file)
index 0000000..2708c98
--- /dev/null
@@ -0,0 +1,101 @@
+(function(angular, $, _) {
+  "use strict";
+
+  angular.module('crmSearchAdmin').component('crmSearchAdminLinkGroup', {
+    bindings: {
+      group: '<',
+      apiEntity: '<',
+      apiParams: '<',
+      links: '<'
+    },
+    templateUrl: '~/crmSearchAdmin/crmSearchAdminLinkGroup.html',
+    controller: function ($scope, $element, $timeout, searchMeta) {
+      var ts = $scope.ts = CRM.ts(),
+        ctrl = this;
+
+      this.styles = CRM.crmSearchAdmin.styles;
+
+      this.setValue = function(val, index) {
+        var link = ctrl.getLink(val),
+          item = ctrl.group[index];
+        if (item.path === val) {
+          return;
+        }
+        item.path = val;
+        item.icon = link ? defaultIcons[link.action] : 'fa-external-link';
+        if (val === 'civicrm/') {
+          $timeout(function () {
+            $('tr:eq(' + index + ') input[type=text]', $element).focus();
+          });
+        }
+      };
+
+      this.sortableOptions = {
+        containment: 'tbody',
+        direction: 'vertical',
+        helper: function(e, ui) {
+          // Prevent table row width from changing during drag
+          ui.children().each(function() {
+            $(this).width($(this).width());
+          });
+          return ui;
+        }
+      };
+
+      var defaultIcons = {
+        view: 'fa-external-link',
+        update: 'fa-pencil',
+        delete: 'fa-trash'
+      };
+
+      var defaultStyles = {
+        view: 'primary',
+        update: 'warning',
+        delete: 'danger'
+      };
+
+      $scope.pickIcon = function(index) {
+        searchMeta.pickIcon().then(function(icon) {
+          ctrl.group[index].icon = icon;
+        });
+      };
+
+      this.addItem = function(path) {
+        var link = ctrl.getLink(path);
+        ctrl.group.push({
+          path: path,
+          style: link && defaultStyles[link.action] || 'default',
+          text: link ? link.title : '',
+          icon: link && defaultIcons[link.action] || 'fa-external-link'
+        });
+      };
+
+      this.$onInit = function() {
+        if (!ctrl.group.length) {
+          if (ctrl.links.length) {
+            _.each(_.pluck(ctrl.links, 'path'), ctrl.addItem);
+          } else {
+            ctrl.addItem('civicrm/');
+          }
+        }
+        $element.on('change', 'select.crm-search-admin-select-path', function() {
+          var $select = $(this);
+          $scope.$apply(function() {
+            if ($select.closest('tfoot').length) {
+              ctrl.addItem($select.val());
+              $select.val('');
+            } else {
+              ctrl.setValue($select.val(), $select.closest('tr').index());
+            }
+          });
+        });
+      };
+
+      this.getLink = function(path) {
+        return _.findWhere(ctrl.links, {path: path});
+      };
+
+    }
+  });
+
+})(angular, CRM.$, CRM._);
diff --git a/ext/search/ang/crmSearchAdmin/crmSearchAdminLinkGroup.html b/ext/search/ang/crmSearchAdmin/crmSearchAdminLinkGroup.html
new file mode 100644 (file)
index 0000000..2846b60
--- /dev/null
@@ -0,0 +1,66 @@
+<table>
+  <thead>
+    <tr>
+      <th class="crm-search-admin-icon-col"></th>
+      <th class="crm-search-admin-icon-col">{{:: ts('Icon') }}</th>
+      <th>{{:: ts('Text') }}</th>
+      <th>{{:: ts('Link') }}</th>
+      <th>{{:: ts('Style') }}</th>
+      <th class="crm-search-admin-icon-col"></th>
+    </tr>
+  </thead>
+  <tbody ui-sortable="$ctrl.sortableOptions" ng-model="$ctrl.group">
+    <tr ng-repeat="item in $ctrl.group" class="bg-{{ item.style }}">
+      <td class="crm-search-admin-icon-col">
+        <i class="crm-i fa-arrows disabled"></i>
+      </td>
+      <td class="crm-search-admin-icon-col">
+        <span class="crm-editable-enabled" ng-click="pickIcon($index)">
+          <i class="{{ item.icon ? 'crm-i ' + item.icon : '' }}"></i>
+        </span>
+      </td>
+      <td>
+        <input type="text" class="form-control" ng-model="item.text">
+      </td>
+      <td class="form-inline">
+        <select class="form-control crm-search-admin-select-path" ng-show="$ctrl.links.length">
+          <option ng-repeat="link in $ctrl.links" value="{{ link.path }}" ng-selected="item.path === link.path">
+            {{ link.title }}
+          </option>
+          <option value="civicrm/" ng-selected="item.path && !$ctrl.getLink(item.path)">
+            {{ ts('Other...') }}
+          </option>
+        </select>
+        <input class="form-control" type="text" ng-if="item.path && !$ctrl.getLink(item.path)" ng-model="item.path" ng-model-options="{updateOn: 'blur'}" />
+        <crm-search-admin-token-select ng-if="item.path && !$ctrl.getLink(item.path)" api-entity="$ctrl.apiEntity" api-params="$ctrl.apiParams" model="item" field="path"></crm-search-admin-token-select>
+      </td>
+      <td class="form-inline">
+        <select class="form-control" ng-model="item.style">
+          <option ng-repeat="opt in $ctrl.styles" value="{{ opt.key }}">{{ opt.value }}</option>
+        </select>
+      </td>
+      <td class="crm-search-admin-icon-col">
+        <button ng-if="$ctrl.group.length > 1" type="button" class="btn btn-xs btn-danger-outline" ng-click="$ctrl.group.splice($index, 1)">
+          <i class="crm-i fa-times"></i>
+        </button>
+      </td>
+    </tr>
+  </tbody>
+  <tfoot>
+    <tr>
+      <td colspan="6" class="form-inline">
+        <select class="form-control crm-search-admin-select-path" ng-show="$ctrl.links.length">
+          <option value="">
+            + {{:: ts('Add...') }}
+          </option>
+          <option ng-repeat="link in $ctrl.links" value="{{ link.path }}">
+            {{ link.title }}
+          </option>
+          <option value="civicrm/">
+            {{:: ts('Other...') }}
+          </option>
+        </select>
+      </td>
+    </tr>
+  </tfoot>
+</table>
index dc916ad98d798d6196e1d57debc136f83abbe3ec..2bac3383c037de5ff60dd3c496ca74fb864554af 100644 (file)
@@ -5,50 +5,14 @@
     bindings: {
       column: '<',
       apiEntity: '<',
-      apiParams: '<'
-    },
-    require: {
-      crmSearchAdmin: '^^crmSearchAdmin'
+      apiParams: '<',
+      links: '<'
     },
     templateUrl: '~/crmSearchAdmin/crmSearchAdminLinkSelect.html',
-    controller: function ($scope, $element, $timeout, searchMeta) {
+    controller: function ($scope, $element, $timeout) {
       var ts = $scope.ts = CRM.ts(),
         ctrl = this;
 
-      // Return all possible links to main entity or join entities
-      function getLinks() {
-        // Links to main entity
-        var links = _.cloneDeep(searchMeta.getEntity(ctrl.apiEntity).paths || []);
-        // Links to explicitly joined entities
-        _.each(ctrl.apiParams.join, function(join) {
-          var joinName = join[0].split(' AS '),
-            joinEntity = searchMeta.getEntity(joinName[0]);
-          _.each(joinEntity.paths, function(path) {
-            var link = _.cloneDeep(path);
-            link.path = link.path.replace(/\[/g, '[' + joinName[1] + '.');
-            links.push(link);
-          });
-        });
-        // Links to implicit joins
-        _.each(ctrl.crmSearchAdmin.savedSearch.api_params.select, function(fieldName) {
-          if (!_.includes(fieldName, ' AS ')) {
-            var info = searchMeta.parseExpr(fieldName);
-            if (info.field && !info.suffix && !info.fn && (info.field.fk_entity || info.field.entity !== info.field.baseEntity)) {
-              var idField = info.field.fk_entity ? fieldName : fieldName.substr(0, fieldName.lastIndexOf('.')) + '_id';
-              if (!ctrl.crmSearchAdmin.canAggregate(idField)) {
-                var joinEntity = searchMeta.getEntity(info.field.fk_entity || info.field.entity);
-                _.each(joinEntity.paths, function(path) {
-                  var link = _.cloneDeep(path);
-                  link.path = link.path.replace(/\[id/g, '[' + idField);
-                  links.push(link);
-                });
-              }
-            }
-          }
-        });
-        return links;
-      }
-
       this.setValue = function(val) {
         var link = ctrl.getLink(val),
           oldLink = ctrl.getLink(ctrl.column.link);
@@ -78,8 +42,6 @@
       }
 
       this.$onInit = function() {
-        this.links = getLinks();
-
         $('select', $element).on('change', function() {
           $scope.$apply(onChange);
         });
index 3f0fa40aeba5ddb86f3eddd9c1fc539d45803d57..855f9fdb71e8aeab285eb1bf2029b6af906fc080 100644 (file)
@@ -3,7 +3,6 @@
   {{ $ctrl.column.link ? ts('Link:') : ts('Link') }}
 </label>
 <select class="form-control" ng-show="$ctrl.links.length && $ctrl.column.link">
-  <option value="">{{ ts('None') }}</option>
   <option ng-repeat="link in $ctrl.links" value="{{ link.path }}" ng-selected="$ctrl.column.link === link.path">
     {{ link.title }}
   </option>
diff --git a/ext/search/ang/crmSearchAdmin/displays/colType/buttons.html b/ext/search/ang/crmSearchAdmin/displays/colType/buttons.html
new file mode 100644 (file)
index 0000000..58a972e
--- /dev/null
@@ -0,0 +1,13 @@
+<div class="form-inline">
+  <label for="crm-search-admin-col-size-{{$index}}">
+    {{:: ts('Button Size:') }}
+  </label>
+  <select id="crm-search-admin-col-size-{{$index}}" class="form-control" ng-model="col.size">
+    <option value="btn-lg">{{:: ts('Large') }}</option>
+    <option value="">{{:: ts('Regular') }}</option>
+    <option value="btn-sm">{{:: ts('Small') }}</option>
+    <option value="btn-xs">{{:: ts('Tiny') }}</option>
+  </select>
+</div>
+<hr>
+<crm-search-admin-link-group links="$ctrl.parent.getLinks()" group="col.links" api-entity="$ctrl.apiEntity" api-params="$ctrl.apiParams"></crm-search-admin-link-group>
similarity index 93%
rename from ext/search/ang/crmSearchAdmin/displays/common/fieldOptions.html
rename to ext/search/ang/crmSearchAdmin/displays/colType/field.html
index d05271bd3641a0a818af8b72b439a8ad5b83195e..20e1976e79a0e1d649c45c3a22a8d86b63ee54d7 100644 (file)
@@ -1,4 +1,4 @@
-<crm-search-admin-link-select class="form-inline crm-search-admin-flex-row" column="col" api-entity="$ctrl.apiEntity" api-params="$ctrl.apiParams"></crm-search-admin-link-select>
+<crm-search-admin-link-select class="form-inline crm-search-admin-flex-row" column="col" api-entity="$ctrl.apiEntity" api-params="$ctrl.apiParams" links=":: $ctrl.parent.getLinks()"></crm-search-admin-link-select>
 <div class="form-inline crm-search-admin-flex-row">
   <label>
     <input type="checkbox" ng-checked="col.title" ng-click="col.title = col.title ? null : $ctrl.parent.getFieldLabel(col.key)" >
diff --git a/ext/search/ang/crmSearchAdmin/displays/colType/links.html b/ext/search/ang/crmSearchAdmin/displays/colType/links.html
new file mode 100644 (file)
index 0000000..b4f1409
--- /dev/null
@@ -0,0 +1,2 @@
+<hr>
+<crm-search-admin-link-group links="$ctrl.parent.getLinks()" group="col.links" api-entity="$ctrl.apiEntity" api-params="$ctrl.apiParams"></crm-search-admin-link-group>
diff --git a/ext/search/ang/crmSearchAdmin/displays/colType/menu.html b/ext/search/ang/crmSearchAdmin/displays/colType/menu.html
new file mode 100644 (file)
index 0000000..1758e2f
--- /dev/null
@@ -0,0 +1,32 @@
+<div class="form-inline">
+  <label for="crm-search-admin-col-size-{{$index}}">
+    {{:: ts('Menu Button Size:') }}
+  </label>
+  <select id="crm-search-admin-col-size-{{$index}}" class="form-control" ng-model="col.size">
+    <option value="btn-lg">{{:: ts('Large') }}</option>
+    <option value="">{{:: ts('Regular') }}</option>
+    <option value="btn-sm">{{:: ts('Small') }}</option>
+    <option value="btn-xs">{{:: ts('Tiny') }}</option>
+  </select>
+  <label for="crm-search-admin-col-style-{{$index}}">
+    {{:: ts('Style:') }}
+  </label>
+  <select id="crm-search-admin-col-style-{{$index}}" class="form-control" ng-model="col.style">
+    <option ng-repeat="opt in $ctrl.parent.styles" value="{{ opt.key }}">{{ opt.value }}</option>
+  </select>
+</div>
+<div class="form-inline">
+  <label>
+    {{:: ts('Menu Text/Icon:') }}
+  </label>
+  <div class="btn-group">
+    <button type="button" class="btn btn-{{ col.style + ' ' + col.size }}">
+      <span class="crm-editable-enabled" ng-click="$ctrl.parent.pickIcon(col, 'icon')">
+        <i class="{{ col.icon ? 'crm-i ' + col.icon : '' }}"></i>
+      </span>
+      <span crm-ui-editable ng-model="col.text">{{ col.text }}</span>
+    </button>
+  </div>
+</div>
+<hr>
+<crm-search-admin-link-group links="$ctrl.parent.getLinks()" group="col.links" api-entity="$ctrl.apiEntity" api-params="$ctrl.apiParams"></crm-search-admin-link-group>
diff --git a/ext/search/ang/crmSearchAdmin/displays/common/addColMenu.html b/ext/search/ang/crmSearchAdmin/displays/common/addColMenu.html
new file mode 100644 (file)
index 0000000..3f37d1d
--- /dev/null
@@ -0,0 +1,9 @@
+<button type="button" class="btn dropdown-toggle btn-default btn-sm" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+  <i class="crm-i fa-plus"></i>
+  {{:: ts('Add') }} <span class="caret"></span>
+</button>
+<ul class="dropdown-menu">
+  <li ng-repeat="(type, col) in $ctrl.parent.colTypes">
+    <a href ng-click="$ctrl.parent.addCol(type)"><i class="fa {{col.icon}}"></i> {{ col.label }}</a>
+  </li>
+</ul>
index ed093f54e6a81f19e1c893f4ccc5dd65c25fd4e8..dcaf056c13c0b02bc658aa7a2202b3121cfb44a4 100644 (file)
@@ -1,5 +1,5 @@
-<fieldset class="crm-search-admin-edit-columns crm-search-admin-unused-columns" ng-model="$ctrl.parent.hiddenColumns" ui-sortable="$ctrl.parent.sortableOptions">
-  <legend>{{:: ts('Unused') }}</legend>
+<legend>{{:: ts('Unused') }}</legend>
+<div class="crm-search-admin-edit-columns crm-search-admin-unused-columns" ng-model="$ctrl.parent.hiddenColumns" ui-sortable="$ctrl.parent.sortableOptions">
   <fieldset ng-repeat="col in $ctrl.parent.hiddenColumns" class="crm-draggable">
     <legend>{{ $ctrl.parent.getFieldLabel(col.key) }}</legend>
     <div class="form-inline">
@@ -8,4 +8,4 @@
       </button>
     </div>
   </fieldset>
-</fieldset>
+</div>
index 54dd7008c75de78636589bbc3e0639024592460e..546fdb84d028cc094aee89bb2039780a353d970d 100644 (file)
@@ -38,7 +38,7 @@
             pager: true
           };
         }
-        ctrl.parent.initColumns({key: true, dataType: true});
+        ctrl.parent.initColumns({key: true, dataType: true, type: 'field'});
       };
 
     }
index 10b1a60296fc51c89cdb47e21e5369cc1f02ed4d..0ec003a6c9de18f82959c5233f3bae7752b4be93 100644 (file)
   </div>
 </fieldset>
 <div class="crm-flex-box crm-search-admin-edit-columns-wrapper">
-  <fieldset class="crm-search-admin-edit-columns" ng-model="$ctrl.display.settings.columns" ui-sortable="$ctrl.parent.sortableOptions">
+  <fieldset class="crm-flex-3">
     <legend>{{:: ts('Fields') }}</legend>
-    <fieldset ng-repeat="col in $ctrl.display.settings.columns" class="crm-draggable">
-      <legend>{{ $ctrl.parent.getFieldLabel(col.key) }}</legend>
-      <div class="form-inline" title="{{ ts('Should this item display on its own line or inline with other items?') }}">
-        <label><input type="checkbox" ng-model="col.break"> {{:: ts('Display on new line') }}</label>
-        <button type="button" class="btn-xs pull-right" ng-click="$ctrl.parent.removeCol($index)" title="{{:: ts('Hide') }}">
-          <i class="crm-i fa-ban"></i>
-        </button>
-      </div>
-      <div class="form-inline crm-search-admin-flex-row">
-        <label>
-          <input type="checkbox" ng-checked="col.label" ng-click="col.label = col.label ? null : $ctrl.parent.getFieldLabel(col.key)" >
-          {{ col.label ? ts('Label:') : ts('Label') }}
-        </label>
-        <input ng-if="col.label" class="form-control" type="text" ng-model="col.label" ng-model-options="{updateOn: 'blur'}">
-        <crm-search-admin-token-select ng-if="col.label" api-entity="$ctrl.apiEntity" api-params="$ctrl.apiParams" model="col" field="label"></crm-search-admin-token-select>
-      </div>
-      <div class="form-inline" ng-if="col.label">
-        <label style="visibility: hidden"><input type="checkbox" disabled></label>
-        <div class="checkbox">
-          <label><input type="checkbox" ng-model="col.forceLabel"> {{:: ts('Show label even when field is blank') }}</label>
+    <div ng-include="'~/crmSearchAdmin/displays/common/addColMenu.html'" class="btn-group"></div>
+    <div class="crm-search-admin-edit-columns" ng-model="$ctrl.display.settings.columns" ui-sortable="$ctrl.parent.sortableOptions">
+      <fieldset ng-repeat="col in $ctrl.display.settings.columns" class="crm-draggable">
+        <legend>{{ $ctrl.parent.getColLabel(col) }}</legend>
+        <div class="form-inline" title="{{ ts('Should this item display on its own line or inline with other items?') }}">
+          <label><input type="checkbox" ng-model="col.break"> {{:: ts('Display on new line') }}</label>
+          <button type="button" class="btn-xs pull-right" ng-click="$ctrl.parent.removeCol($index)" title="{{:: ts('Remove') }}">
+            <i class="crm-i fa-ban"></i>
+          </button>
         </div>
-      </div>
-      <div ng-include="'~/crmSearchAdmin/displays/common/fieldOptions.html'"></div>
-    </fieldset>
+        <div class="form-inline crm-search-admin-flex-row">
+          <label>
+            <input type="checkbox" ng-checked="col.label" ng-click="col.label = col.label ? null : $ctrl.parent.getFieldLabel(col.key)" >
+            {{ col.label ? ts('Label:') : ts('Label') }}
+          </label>
+          <input ng-if="col.label" class="form-control" type="text" ng-model="col.label" ng-model-options="{updateOn: 'blur'}">
+          <crm-search-admin-token-select ng-if="col.label" api-entity="$ctrl.apiEntity" api-params="$ctrl.apiParams" model="col" field="label"></crm-search-admin-token-select>
+        </div>
+        <div class="form-inline" ng-if="col.label">
+          <label style="visibility: hidden"><input type="checkbox" disabled></label><!--To indent by 1 checkbox-width-->
+          <div class="checkbox">
+            <label><input type="checkbox" ng-model="col.forceLabel"> {{:: ts('Show label even when field is blank') }}</label>
+          </div>
+        </div>
+        <div ng-include="'~/crmSearchAdmin/displays/colType/' + col.type + '.html'"></div>
+      </fieldset>
+    </div>
   </fieldset>
-  <div ng-include="'~/crmSearchAdmin/displays/common/unusedColumns.html'"></div>
+  <fieldset ng-include="'~/crmSearchAdmin/displays/common/unusedColumns.html'"></fieldset>
 </div>
index bd958c37bf6facaf8c0d8046ab697a714f5a8f70..72b48eaaea8921b21c413205acfd60a180a45fa0 100644 (file)
@@ -22,7 +22,7 @@
             pager: true
           };
         }
-        ctrl.parent.initColumns({key: true, label: true, dataType: true});
+        ctrl.parent.initColumns({key: true, label: true, dataType: true, type: 'field'});
       };
 
     }
index 4836045d9bb43ef3f12431a6b68f5bdbce7920ca..0ade234aebd7acef63b087c17e7280fbfab43fbd 100644 (file)
   </div>
 </fieldset>
 <div class="crm-flex-box crm-search-admin-edit-columns-wrapper">
-  <fieldset class="crm-search-admin-edit-columns" ng-model="$ctrl.display.settings.columns" ui-sortable="$ctrl.parent.sortableOptions">
+  <fieldset class="crm-flex-3">
     <legend>{{:: ts('Columns') }}</legend>
-    <fieldset ng-repeat="col in $ctrl.display.settings.columns" class="crm-draggable">
-      <legend>{{ $ctrl.parent.getFieldLabel(col.key) }}</legend>
-      <div class="form-inline crm-search-admin-flex-row">
-        <label for="crm-search-admin-edit-col-{{ $index }}">{{:: ts('Header:') }}</label>
-        <input id="crm-search-admin-edit-col-{{ $index }}" class="form-control" type="text" ng-model="col.label" >
-        &nbsp;&nbsp;&nbsp;&nbsp;
-        <button type="button" class="btn-xs" ng-click="$ctrl.parent.removeCol($index)" title="{{:: ts('Hide') }}">
-          <i class="crm-i fa-ban"></i>
-        </button>
-      </div>
-      <div class="form-inline">
-        <label>{{:: ts('Alignment:') }}</label>
-        <select ng-model="col.alignment" class="form-control">
-          <option value="">{{:: ts('Left') }}</option>
-          <option value="text-center">{{:: ts('Center') }}</option>
-          <option value="text-right">{{:: ts('Right') }}</option>
-        </select>
-      </div>
-      <div ng-include="'~/crmSearchAdmin/displays/common/fieldOptions.html'"></div>
-    </fieldset>
+    <div ng-include="'~/crmSearchAdmin/displays/common/addColMenu.html'" class="btn-group"></div>
+    <div class="crm-search-admin-edit-columns" ng-model="$ctrl.display.settings.columns" ui-sortable="$ctrl.parent.sortableOptions">
+      <fieldset ng-repeat="col in $ctrl.display.settings.columns" class="crm-draggable">
+        <legend>{{ $ctrl.parent.getColLabel(col) }}</legend>
+        <div class="form-inline crm-search-admin-flex-row">
+          <label for="crm-search-admin-edit-col-{{ $index }}">{{:: ts('Header:') }}</label>
+          <input id="crm-search-admin-edit-col-{{ $index }}" class="form-control" type="text" ng-model="col.label" >
+          <button type="button" class="btn-xs" ng-click="$ctrl.parent.removeCol($index)" title="{{:: ts('Remove') }}">
+            <i class="crm-i fa-ban"></i>
+          </button>
+        </div>
+        <div class="form-inline">
+          <label>{{:: ts('Alignment:') }}</label>
+          <select ng-model="col.alignment" class="form-control">
+            <option value="">{{:: ts('Left') }}</option>
+            <option value="text-center">{{:: ts('Center') }}</option>
+            <option value="text-right">{{:: ts('Right') }}</option>
+          </select>
+        </div>
+        <div ng-include="'~/crmSearchAdmin/displays/colType/' + col.type + '.html'"></div>
+      </fieldset>
+    </div>
   </fieldset>
-  <div ng-include="'~/crmSearchAdmin/displays/common/unusedColumns.html'"></div>
+  <fieldset ng-include="'~/crmSearchAdmin/displays/common/unusedColumns.html'"></fieldset>
 </div>
index 55c18c7a55c36d7a331ffd609af8408b6e152d69..312782f53632c5010144864ec68d4812517def4b 100644 (file)
@@ -27,7 +27,7 @@
         if (url.slice(0, 1) !== '/' && url.slice(0, 4) !== 'http') {
           url = CRM.url(url);
         }
-        return _.escape(url);
+        return url;
       }
 
       // Returns html-escaped display value for a single column in a row
@@ -36,7 +36,7 @@
           displayValue = column.rewrite ? replaceTokens(column.rewrite, rowData, rowMeta) : formatRawValue(column, rowData[key]),
           result = _.escape(displayValue);
         if (column.link) {
-          result = '<a href="' + getUrl(column.link, rowData) + '">' + result + '</a>';
+          result = '<a href="' + _.escape(getUrl(column.link, rowData)) + '">' + result + '</a>';
         }
         return result;
       }
@@ -93,7 +93,8 @@
         formatDisplayValue: formatDisplayValue,
         getApiParams: getApiParams,
         getResults: getResults,
-        replaceTokens: replaceTokens
+        replaceTokens: replaceTokens,
+        getUrl: getUrl
       };
     });
 
diff --git a/ext/search/ang/crmSearchDisplay/colType/buttons.html b/ext/search/ang/crmSearchDisplay/colType/buttons.html
new file mode 100644 (file)
index 0000000..f18030c
--- /dev/null
@@ -0,0 +1,6 @@
+<span ng-repeat="item in col.links">
+  <a class="btn {{:: col.size }} btn-{{:: item.style }}" href="{{:: displayUtils.getUrl(item.path, row) }}">
+    <i ng-if=":: item.icon" class="crm-i {{:: item.icon }}"></i>
+    {{:: item.text }}
+  </a>
+</span>
diff --git a/ext/search/ang/crmSearchDisplay/colType/field.html b/ext/search/ang/crmSearchDisplay/colType/field.html
new file mode 100644 (file)
index 0000000..1c61060
--- /dev/null
@@ -0,0 +1 @@
+<span ng-bind-html="$ctrl.formatFieldValue(row, col)"></span>
diff --git a/ext/search/ang/crmSearchDisplay/colType/links.html b/ext/search/ang/crmSearchDisplay/colType/links.html
new file mode 100644 (file)
index 0000000..d0a2480
--- /dev/null
@@ -0,0 +1,6 @@
+<span ng-repeat="item in col.links">
+  <a class="text-{{:: item.style }}" href="{{:: displayUtils.getUrl(item.path, row) }}">
+    <i ng-if=":: item.icon" class="crm-i {{:: item.icon }}"></i>
+    {{:: item.text }}
+  </a>
+</span>
diff --git a/ext/search/ang/crmSearchDisplay/colType/menu.html b/ext/search/ang/crmSearchDisplay/colType/menu.html
new file mode 100644 (file)
index 0000000..a11b531
--- /dev/null
@@ -0,0 +1,14 @@
+<div class="btn-group">
+  <button type="button" class="dropdown-toggle {{:: col.size }} btn-{{:: col.style }}" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" ng-click="col.open = true">
+    <i ng-if=":: col.icon" class="crm-i {{:: col.icon }}"></i>
+    {{:: col.text }}
+  </button>
+  <ul class="dropdown-menu {{ col.alignment === 'text-right' ? 'dropdown-menu-right' : '' }}" ng-if=":: col.open">
+    <li ng-repeat="item in col.links" class="bg-{{:: item.style }}">
+      <a href="{{:: displayUtils.getUrl(item.path, row) }}">
+        <i ng-if=":: item.icon" class="crm-i {{:: item.icon }}"></i>
+        {{:: item.text }}
+      </a>
+    </li>
+  </ul>
+</div>
index d439fb6e8cbbc9c1c16224d5aa2f742960745d23..255814f5e1824c96c5f199b250087d6cd0325a96 100644 (file)
         ctrl.getResults();
       }
 
-      $scope.formatResult = function(rowData, col) {
-        var formatted = searchDisplayUtils.formatDisplayValue(rowData, col.key, ctrl.settings.columns);
-        if (col.label && (formatted.length || col.forceLabel)) {
-          var label = searchDisplayUtils.replaceTokens(col.label, rowData, ctrl.settings.columns);
-          formatted = '<label>' + _.escape(label) + '</label> ' + formatted;
-        }
-        return formatted;
+      this.formatFieldValue = function(rowData, col) {
+        return searchDisplayUtils.formatDisplayValue(rowData, col.key, ctrl.settings.columns);
       };
 
     }
index 5d4d0deb4872fa742bd84d5d809320954eacd7cb..e99f04b04f09cb74798984863a9c346c5671b83f 100644 (file)
@@ -1,4 +1,8 @@
 <li ng-repeat="row in $ctrl.results">
-  <div ng-repeat="col in $ctrl.settings.columns" ng-bind-html="formatResult(row, col)" title="{{:: displayUtils.replaceTokens(col.title, row, $ctrl.settings.columns) }}" class="{{:: col.break ? '' : 'crm-inline-block' }}">
+  <div ng-repeat="col in $ctrl.settings.columns" title="{{:: displayUtils.replaceTokens(col.title, row, $ctrl.settings.columns) }}" class="{{:: col.break ? '' : 'crm-inline-block' }}">
+    <label ng-if=":: col.label && (col.type !== 'field' || col.forceLabel || row[col.key])">
+      {{:: displayUtils.replaceTokens(col.label, row, $ctrl.settings.columns) }}
+    </label>
+    <span ng-include="'~/crmSearchDisplay/colType/' + col.type + '.html'"></span>
   </div>
 </li>
index fb5999f2bdbdbea2ce0dcf4ad45e36017100f7c6..8ba836c4cbfe844c3b313decdbb796f78fd43724 100644 (file)
@@ -63,6 +63,9 @@
        * @param $event
        */
       $scope.setSort = function(col, $event) {
+        if (col.type !== 'field') {
+          return;
+        }
         var dir = $scope.getSort(col) === 'fa-sort-asc' ? 'DESC' : 'ASC';
         if (!$event.shiftKey || !ctrl.sort) {
           ctrl.sort = [];
@@ -76,7 +79,7 @@
         ctrl.getResults();
       };
 
-      $scope.formatResult = function(rowData, col) {
+      this.formatFieldValue = function(rowData, col) {
         return searchDisplayUtils.formatDisplayValue(rowData, col.key, ctrl.settings.columns);
       };
 
index f1737a82e1d6a1cda091210cbf5616689f9e4c32..3c408c3b885da0ac72d5e4e8458eaea43e59e4bc 100644 (file)
@@ -8,7 +8,7 @@
         <input type="checkbox" ng-checked="$ctrl.allRowsSelected" ng-click="selectAllRows()" >
       </th>
       <th ng-repeat="col in $ctrl.settings.columns" ng-click="setSort(col, $event)" title="{{:: ts('Click to sort results (shift-click to sort by multiple).') }}">
-        <i class="crm-i {{ getSort(col) }}"></i>
+        <i ng-if="col.type === 'field'" class="crm-i {{ getSort(col) }}"></i>
         <span>{{ col.label }}</span>
       </th>
     </tr>
@@ -18,7 +18,7 @@
       <td ng-if="$ctrl.settings.actions">
         <input type="checkbox" ng-checked="isRowSelected(row)" ng-click="selectRow(row)" ng-disabled="!(!loadingAllRows && row.id)">
       </td>
-      <td ng-repeat="col in $ctrl.settings.columns" ng-bind-html="formatResult(row, col)" title="{{:: displayUtils.replaceTokens(col.title, row, $ctrl.settings.columns) }}" class="{{:: col.alignment }}">
+      <td ng-repeat="col in $ctrl.settings.columns" ng-include="'~/crmSearchDisplay/colType/' + col.type + '.html'" title="{{:: displayUtils.replaceTokens(col.title, row, $ctrl.settings.columns) }}" class="{{:: col.alignment }}">
       </td>
       <td></td>
     </tr>
index 29de32e6429a3aa037ecbf5aef7fbe26ad22c8cf..f5f877557f33d10496f21bdb24baf774fe203964 100644 (file)
   top: 0;
 }
 
+#bootstrap-theme .crm-search-admin-edit-columns {
+  height: 100%;
+}
+
 #bootstrap-theme .crm-search-admin-flex-row {
   display: flex;
   align-items: center;
   min-height: 60px;
 }
 
+#bootstrap-theme.crm-search .crm-editable-enabled {
+  display: inline-block;
+  padding: 0 4px !important;
+  border: 2px solid transparent !important;
+  min-width: 21px;
+}
+#bootstrap-theme.crm-search .crm-editable-enabled:hover:not(:focus) {
+  border: 2px dashed grey !important;
+}
+#bootstrap-theme.crm-search .crm-editable-enabled:before,
+#bootstrap-theme.crm-search .crm-editable-enabled:after {
+  content: '';
+  display: none;
+}
+
+#bootstrap-theme .crm-search-admin-icon-col {
+  width: 24px;
+}
+#bootstrap-theme tbody .crm-search-admin-icon-col {
+  text-align: center;
+}
+
 #bootstrap-theme input[type=search]::placeholder {
   font-family: FontAwesome;
   text-align: right;