Search ext: Add links to search admin and improve links in displays
authorColeman Watts <coleman@civicrm.org>
Tue, 3 Nov 2020 02:16:17 +0000 (21:16 -0500)
committerColeman Watts <coleman@civicrm.org>
Tue, 3 Nov 2020 02:16:17 +0000 (21:16 -0500)
ext/search/Civi/Search/Admin.php
ext/search/ang/crmSearchAdmin.module.js
ext/search/ang/crmSearchAdmin/compose/results.html
ext/search/ang/crmSearchAdmin/crmSearchAdmin.component.js
ext/search/ang/crmSearchAdmin/crmSearchAdminLinkSelect.directive.js [new file with mode: 0644]
ext/search/ang/crmSearchAdmin/crmSearchAdminLinkSelect.html [new file with mode: 0644]
ext/search/ang/crmSearchAdmin/displays/searchAdminDisplayTable.component.js
ext/search/ang/crmSearchAdmin/displays/searchAdminDisplayTable.html
ext/search/ang/crmSearchDisplay/crmSearchDisplayTable.component.js
ext/search/ang/crmSearchDisplay/crmSearchDisplayTable.html

index 58f8d5e1927265ff4b555d77d7142e02bcb52672..fab5cd7870d9457f832793b98f6ce164c868cdff 100644 (file)
@@ -58,7 +58,7 @@ class Admin {
   public static function getSchema() {
     $schema = [];
     $entities = \Civi\Api4\Entity::get()
-      ->addSelect('name', 'title', 'title_plural', 'description', 'icon')
+      ->addSelect('name', 'title', 'title_plural', 'description', 'icon', 'paths')
       ->addWhere('name', '!=', 'Entity')
       ->addOrderBy('title_plural')
       ->setChain([
@@ -68,6 +68,31 @@ class Admin {
     foreach ($entities as $entity) {
       // Skip if entity doesn't have a 'get' action or the user doesn't have permission to use get
       if ($entity['get']) {
+        // Add paths (but only RUD actions) with translated titles
+        foreach ($entity['paths'] as $action => $path) {
+          unset($entity['paths'][$action]);
+          switch ($action) {
+            case 'view':
+              $title = ts('View %1', [1 => $entity['title']]);
+              break;
+
+            case 'edit':
+              $title = ts('Edit %1', [1 => $entity['title']]);
+              break;
+
+            case 'delete':
+              $title = ts('Delete %1', [1 => $entity['title']]);
+              break;
+
+            default:
+              continue 2;
+          }
+          $entity['paths'][] = [
+            'path' => $path,
+            'title' => $title,
+            'action' => $action,
+          ];
+        }
         $entity['fields'] = civicrm_api4($entity['name'], 'getFields', [
           'select' => $getFields,
           'where' => [['name', 'NOT IN', ['api_key', 'hash']]],
index af31489c9aab42bd214682ea7f8c2f6cfe6041ca..baa2cbecced8a922de6e0ca917750aa5d8098254 100644 (file)
@@ -88,7 +88,8 @@
       function getField(fieldName, entityName) {
         var dotSplit = fieldName.split('.'),
           joinEntity = dotSplit.length > 1 ? dotSplit[0] : null,
-          name = _.last(dotSplit).split(':')[0];
+          name = _.last(dotSplit).split(':')[0],
+          field;
         // Custom fields contain a dot in their fieldname
         // If 3 segments, the first is the joinEntity and the last 2 are the custom field
         if (dotSplit.length === 3) {
         }
         // If 2 segments, it's ambiguous whether this is a custom field or joined field. Search the main entity first.
         if (dotSplit.length === 2) {
-          var field = _.find(getEntity(entityName).fields, {name: dotSplit[0] + '.' + name});
+          field = _.find(getEntity(entityName).fields, {name: dotSplit[0] + '.' + name});
           if (field) {
+            field.entity = entityName;
             return field;
           }
         }
         if (joinEntity) {
           entityName = _.find(CRM.vars.search.links[entityName], {alias: joinEntity}).entity;
         }
-        return _.find(getEntity(entityName).fields, {name: name});
+        field = _.find(getEntity(entityName).fields, {name: name});
+        if (field) {
+          field.entity = entityName;
+          return field;
+        }
       }
       return {
         getEntity: getEntity,
index 049d037bb4da6d32b2363d84119819342514227d..3cda603bc89f4cae520144a663412d3f7bcfa187 100644 (file)
@@ -22,9 +22,7 @@
       <td>
         <input type="checkbox" ng-checked="isRowSelected(row)" ng-click="selectRow(row)" ng-disabled="!(loading === false && !loadingAllRows && row.id)">
       </td>
-      <td ng-repeat="col in $ctrl.savedSearch.api_params.select">
-        {{ formatResult(row, col) }}
-      </td>
+      <td ng-repeat="col in $ctrl.savedSearch.api_params.select" ng-bind-html="formatResult(row, col)"></td>
       <td></td>
     </tr>
   </tbody>
index b5a34a34e3ece3cac6a86b82cd70198f44bdaf4a..8e070741e9e709490e4f998e58dede9bdd4cfb89 100644 (file)
       this.addDisplay = function(type) {
         ctrl.savedSearch.displays.push({
           type: type,
-          label: '',
-          settings: {}
+          label: ''
         });
         $scope.selectTab('display_' + (ctrl.savedSearch.displays.length - 1));
       };
       // Debounced callback for loadResults
       function _loadResultsCallback() {
         // Multiply limit to read 2 pages at once & save ajax requests
-        var params = angular.merge({debug: true, limit: ctrl.limit * 2}, ctrl.savedSearch.api_params);
+        var params = _.merge(_.cloneDeep(ctrl.savedSearch.api_params), {debug: true, limit: ctrl.limit * 2});
+        // Select the ids of joined entities (helps with displaying links)
+        _.each(params.join, function(join) {
+          var idField = join[0].split(' AS ')[1] + '.id';
+          if (!_.includes(params.select, idField) && !ctrl.canAggregate(idField)) {
+            params.select.push(idField);
+          }
+        });
         lockTableHeight();
         $scope.error = false;
         if (ctrl.stale) {
         })
           .finally(function() {
             if (ctrl.debug) {
-              ctrl.debug.params = JSON.stringify(_.extend({version: 4}, ctrl.savedSearch.api_params), null, 2);
+              ctrl.debug.params = JSON.stringify(params, null, 2);
               if (ctrl.debug.timeIndex) {
                 ctrl.debug.timeIndex = Number.parseFloat(ctrl.debug.timeIndex).toPrecision(2);
               }
 
       // Is a column eligible to use an aggregate function?
       this.canAggregate = function(col) {
+        // If the query does not use grouping, never
+        if (!ctrl.savedSearch.api_params.groupBy.length) {
+          return false;
+        }
         var info = searchMeta.parseExpr(col);
         // If the column is used for a groupBy, no
         if (ctrl.savedSearch.api_params.groupBy.indexOf(info.path) > -1) {
         return ctrl.savedSearch.api_params.groupBy.indexOf(info.prefix + 'id') < 0;
       };
 
-      $scope.formatResult = function formatResult(row, col) {
+      $scope.formatResult = function(row, col) {
         var info = searchMeta.parseExpr(col),
           key = info.fn ? (info.fn.name + ':' + info.path + info.suffix) : col,
           value = row[key];
         if (info.fn && info.fn.name === 'COUNT') {
           return value;
         }
+        // Output user-facing name/label fields as a link, if possible
+        if (info.field && _.includes(['display_name', 'title', 'label', 'subject'], info.field.name) && !info.fn && typeof value === 'string') {
+          var link = getEntityUrl(row, info);
+          if (link) {
+            return '<a href="' + _.escape(link.url) + '" title="' + _.escape(link.title) + '">' + formatFieldValue(info.field, value) + '</a>';
+          }
+        }
         return formatFieldValue(info.field, value);
       };
 
+      // Attempts to construct a view url for a given entity
+      function getEntityUrl(row, info) {
+        var entity = searchMeta.getEntity(info.field.entity),
+          path = _.result(_.findWhere(entity.paths, {action: 'view'}), 'path');
+        // Only proceed if the path metadata exists for this entity
+        if (path) {
+          // Replace tokens in the path (e.g. [id])
+          var tokens = path.match(/\[\w*]/g) || [],
+            replacements = _.transform(tokens, function(replacements, token) {
+              var fieldName = info.prefix + token.slice(1, token.length - 1);
+              if (row[fieldName]) {
+                replacements.push(row[fieldName]);
+              }
+            });
+          // Only proceed if the row contains all the necessary data to resolve tokens
+          if (tokens.length === replacements.length) {
+            _.each(tokens, function(token, index) {
+              path = path.replace(token, replacements[index]);
+            });
+            return {url: CRM.url(path), title: path.title};
+          }
+        }
+      }
+
       function formatFieldValue(field, value) {
-        var type = field.data_type;
+        var type = field.data_type,
+          result = value;
         if (_.isArray(value)) {
           return _.map(value, function(val) {
             return formatFieldValue(field, val);
           }).join(', ');
         }
         if (value && (type === 'Date' || type === 'Timestamp') && /^\d{4}-\d{2}-\d{2}/.test(value)) {
-          return CRM.utils.formatDate(value, null, type === 'Timestamp');
+          result = CRM.utils.formatDate(value, null, type === 'Timestamp');
         }
         else if (type === 'Boolean' && typeof value === 'boolean') {
-          return value ? ts('Yes') : ts('No');
+          result = value ? ts('Yes') : ts('No');
         }
         else if (type === 'Money' && typeof value === 'number') {
-          return CRM.formatMoney(value);
+          result = CRM.formatMoney(value);
         }
-        return value;
+        return _.escape(result);
       }
 
       $scope.fieldsForGroupBy = function() {
diff --git a/ext/search/ang/crmSearchAdmin/crmSearchAdminLinkSelect.directive.js b/ext/search/ang/crmSearchAdmin/crmSearchAdminLinkSelect.directive.js
new file mode 100644 (file)
index 0000000..6676055
--- /dev/null
@@ -0,0 +1,46 @@
+(function(angular, $, _) {
+  "use strict";
+
+  angular.module('crmSearchAdmin').component('crmSearchAdminLinkSelect', {
+    bindings: {
+      column: '<',
+      links: '<'
+    },
+    templateUrl: '~/crmSearchAdmin/crmSearchAdminLinkSelect.html',
+    controller: function ($scope, $element, $timeout) {
+      var ts = $scope.ts = CRM.ts(),
+        ctrl = this;
+
+      function onChange() {
+        var val = $('select', $element).val();
+        if (val !== ctrl.column.link) {
+          var link = ctrl.getLink(val);
+          if (link) {
+            ctrl.column.link = link.path;
+            ctrl.column.title = link.title;
+          } else if (val === 'civicrm/') {
+            ctrl.column.link = val;
+            $timeout(function() {
+              $('input', $element).focus();
+            });
+          } else {
+            ctrl.column.link = '';
+            ctrl.column.title = '';
+          }
+        }
+      }
+
+      this.$onInit = function() {
+        $('select', $element).on('change', function() {
+          $scope.$apply(onChange);
+        });
+      };
+
+      this.getLink = function(path) {
+        return _.findWhere(ctrl.links, {path: path});
+      };
+
+    }
+  });
+
+})(angular, CRM.$, CRM._);
diff --git a/ext/search/ang/crmSearchAdmin/crmSearchAdminLinkSelect.html b/ext/search/ang/crmSearchAdmin/crmSearchAdminLinkSelect.html
new file mode 100644 (file)
index 0000000..47648e7
--- /dev/null
@@ -0,0 +1,10 @@
+<select class="form-control">
+  <option value="" ng-selected="!$ctrl.column.link" >{{ ts('None') }}</option>
+  <option ng-repeat="link in $ctrl.links" value="{{ link.path }}" ng-selected="$ctrl.column.link === link.path">
+    {{ link.title }}
+  </option>
+  <option value="civicrm/" ng-selected="$ctrl.column.link && !$ctrl.getLink($ctrl.column.link)">
+    {{ ts('Other...') }}
+  </option>
+</select>
+<input class="form-control" type="text" ng-model="$ctrl.column.link" ng-model-options="{updateOn: 'blur'}" ng-show="$ctrl.column.link && !$ctrl.getLink($ctrl.column.link)" />
index 706366cc1127ded16134dbc8dcd7df03f7c5f809..78fb6450da2c74747f69a20f47a2f7ce6eaea8e9 100644 (file)
         ctrl.hiddenColumns.splice(index, 1);
       };
 
-      this.toggleLink = function(col) {
-        col.link = col.link ? '' : (window.location.pathname + window.location.search).replace('civicrm/admin/search', 'civicrm/');
-      };
-
       this.$onInit = function () {
         ctrl.getFieldLabel = ctrl.crmSearchAdmin.getFieldLabel;
+        if (!ctrl.display.settings) {
+          ctrl.display.settings = {
+            limit: 20,
+            pager: true
+          };
+        }
         if (!ctrl.display.settings.columns) {
           ctrl.display.settings.columns = _.transform(ctrl.apiParams.select, function(columns, fieldExpr) {
             columns.push(fieldToColumn(fieldExpr));
             }
           });
         }
+        ctrl.links = _.cloneDeep(searchMeta.getEntity(ctrl.apiEntity).paths || []);
+        _.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] + '.');
+            ctrl.links.push(link);
+          });
+        });
       };
 
     }
index 3777818fce3ae1ca21aeb9c6fced52da622a4d1e..66a15a4286f569171ce9f80d393f1541847bfcbb 100644 (file)
         </button>
       </div>
       <div class="form-inline">
-        <label>{{ ts('Link:') }} <input type="checkbox" ng-checked="!!col.link" ng-click="$ctrl.toggleLink(col)" /></label>
-        <input class="form-control" type="text" ng-model="col.link" ng-model-options="{updateOn: 'blur'}" ng-show="!!col.link" />
+        <label>{{ ts('Link:') }}</label>
+        <crm-search-admin-link-select column="col" links="$ctrl.links"></crm-search-admin-link-select>
+      </div>
+      <div class="form-inline">
+        <label>{{ ts('Tooltip:') }}</label>
+        <input class="form-control" type="text" ng-model="col.title" />
       </div>
     </fieldset>
   </fieldset>
index 6e190bb75576e581440a9e73c74a809a60caae20..c3d4e5e884bfc2c0ab2c106a274dd83ae1b261bb 100644 (file)
         if (_.isEmpty(params.where)) {
           params.where = [];
         }
+        // Select the ids of joined entities (helps with displaying links)
+        _.each(params.join, function(join) {
+          var joinEntity = join[0].split(' AS ')[1],
+            idField = joinEntity + '.id';
+          if (!_.includes(params.select, idField) && !canAggregate('id', joinEntity + '.')) {
+            params.select.push(idField);
+          }
+        });
         _.each(ctrl.filters, function(value, key) {
           if (value) {
             params.where.push([key, 'CONTAINS', value]);
         }
         result = _.escape(result);
         if (col.link) {
-          result = '<a href="' + replaceTokens(col.link, row) + '">' + result + '</a>';
+          result = '<a href="' + getUrl(col.link, row) + '">' + result + '</a>';
         }
         return result;
       }
 
+      function getUrl(link, row) {
+        var url = replaceTokens(link, row);
+        if (url.slice(0, 1) !== '/' && url.slice(0, 4) !== 'http') {
+          url = CRM.url(url);
+        }
+        return _.escape(url);
+      }
+
       function replaceTokens(str, data) {
         _.each(data, function(value, key) {
           str = str.replace('[' + key + ']', value);
         return str;
       }
 
+      function canAggregate(fieldName, prefix) {
+        // If the query does not use grouping, never
+        if (!ctrl.apiParams.groupBy.length) {
+          return false;
+        }
+        // If the column is used for a groupBy, no
+        if (ctrl.apiParams.groupBy.indexOf(prefix + fieldName) > -1) {
+          return false;
+        }
+        // If the entity this column belongs to is being grouped by id, then also no
+        return ctrl.apiParams.groupBy.indexOf(prefix + 'id') < 0;
+      }
+
       $scope.selectAllRows = function() {
         // Deselect all
         if (ctrl.allRowsSelected) {
index e49fd6742fcb77ac7e4404dcb97401c6b69399f1..6b086334fe9a37d5bf2b826bf7a7a77c9a22e6a0 100644 (file)
       <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.columns" ng-bind-html="formatResult(row, col)">
+      <td ng-repeat="col in $ctrl.columns" ng-bind-html="formatResult(row, col)" title="{{:: col.title }}">
       </td>
       <td></td>
     </tr>
   </tbody>
 </table>
-<div class="text-center" ng-if="$ctrl.rowCount">
+<div class="text-center" ng-if="$ctrl.rowCount && $ctrl.settings.pager">
   <ul uib-pagination
       class="pagination"
       boundary-links="true"