SearchKit - Set links in admin UI using entity+action+join instead of path
authorColeman Watts <coleman@civicrm.org>
Wed, 27 Oct 2021 18:38:18 +0000 (14:38 -0400)
committerColeman Watts <coleman@civicrm.org>
Sun, 31 Oct 2021 15:10:26 +0000 (11:10 -0400)
Now that SearchDisplay.Run understands this new link syntax, the admin UI
can use it too. The old links set in existing search displays will still work,
but in order for ACLs to be checked they need to use the new syntax.

ext/search_kit/Civi/Search/Admin.php
ext/search_kit/ang/crmSearchAdmin/crmSearchAdmin.component.js
ext/search_kit/ang/crmSearchAdmin/crmSearchAdminDisplay.component.js
ext/search_kit/ang/crmSearchAdmin/crmSearchAdminLinkGroup.component.js
ext/search_kit/ang/crmSearchAdmin/crmSearchAdminLinkGroup.html
ext/search_kit/ang/crmSearchAdmin/crmSearchAdminLinkSelect.component.js
ext/search_kit/ang/crmSearchAdmin/crmSearchAdminLinkSelect.html
ext/search_kit/ang/crmSearchAdmin/displays/colType/field.html

index 6e94549377c3138321658d192a896587cb475c30..96ecf41db4ec536d985ce7461e2a65e30b4c7be1 100644 (file)
@@ -96,7 +96,7 @@ class Admin {
   public static function getSchema() {
     $schema = [];
     $entities = \Civi\Api4\Entity::get()
-      ->addSelect('name', 'title', 'title_plural', 'bridge_title', 'type', 'primary_key', 'description', 'label_field', 'icon', 'paths', 'dao', 'bridge', 'ui_join_filters', 'searchable')
+      ->addSelect('name', 'title', 'title_plural', 'bridge_title', 'type', 'primary_key', 'description', 'label_field', 'icon', 'dao', 'bridge', 'ui_join_filters', 'searchable')
       ->addWhere('searchable', '!=', 'none')
       ->addOrderBy('title_plural')
       ->setChain([
@@ -105,15 +105,10 @@ 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]);
-          if (in_array($action, ['view', 'update', 'delete'], TRUE)) {
-            $entity['paths'][] = [
-              'path' => $path,
-              'action' => $action,
-            ];
-          }
+        // Add links with translatable titles
+        $links = Display::getEntityLinks($entity['name']);
+        if ($links) {
+          $entity['links'] = array_values($links);
         }
         $getFields = civicrm_api4($entity['name'], 'getFields', [
           'select' => ['name', 'title', 'label', 'description', 'type', 'options', 'input_type', 'input_attrs', 'data_type', 'serialize', 'entity', 'fk_entity', 'readonly', 'operators'],
index 1100fe36f60108290defd58f21a754a6b83ddb39..d072d936113ba6031d5af12868e06ea979cfdd3b 100644 (file)
       }
 
       // Build a list of all possible links to main entity & join entities
+      // @return {Array}
       this.buildLinks = function() {
         function addTitle(link, entityName) {
-          switch (link.action) {
-            case 'view':
-              link.title = ts('View %1', {1: entityName});
-              link.icon = 'fa-external-link';
-              link.style = 'default';
-              break;
-
-            case 'update':
-              link.title = ts('Edit %1', {1: entityName});
-              link.icon = 'fa-pencil';
-              link.style = 'default';
-              break;
-
-            case 'delete':
-              link.title = ts('Delete %1', {1: entityName});
-              link.icon = 'fa-trash';
-              link.style = 'danger';
-              break;
-          }
+          link.text = link.text.replace('%1', entityName);
         }
 
         // Links to main entity
-        // @return {Array}
         var mainEntity = searchMeta.getEntity(ctrl.savedSearch.api_entity),
-          links = _.cloneDeep(mainEntity.paths || []);
+          links = _.cloneDeep(mainEntity.links || []);
         _.each(links, function(link) {
           link.join = '';
           addTitle(link, mainEntity.title);
         _.each(ctrl.savedSearch.api_params.join, function(joinClause) {
           var join = searchMeta.getJoin(joinClause[0]),
             joinEntity = searchMeta.getEntity(join.entity),
-            primaryKey = joinEntity.primary_key[0],
-            // Links for aggregate columns get aggregated using GROUP_CONCAT
-            isAggregate = ctrl.canAggregate(join.alias + '.' + primaryKey),
-            joinPrefix = (isAggregate ? ctrl.DEFAULT_AGGREGATE_FN + '_' : '') + join.alias + '.',
             bridgeEntity = _.isString(joinClause[2]) ? searchMeta.getEntity(joinClause[2]) : null;
-          _.each(joinEntity.paths, function(path) {
-            var link = _.cloneDeep(path);
-            link.path = link.path.replace(/\[/g, '[' + joinPrefix);
-            if (isAggregate) {
-              link.path = link.path.replace(/[.:]/g, '_');
-            }
+          _.each(_.cloneDeep(joinEntity.links), function(link) {
             link.join = join.alias;
             addTitle(link, join.label);
             links.push(link);
           });
-          _.each(bridgeEntity && bridgeEntity.paths, function(path) {
-            var link = _.cloneDeep(path);
-            link.path = link.path.replace(/\[/g, '[' + join.alias + '.');
+          _.each(_.cloneDeep(bridgeEntity && bridgeEntity.links), function(link) {
             link.join = join.alias;
             addTitle(link, join.label + (bridgeEntity.bridge_title ? ' ' + bridgeEntity.bridge_title : ''));
             links.push(link);
               if (!ctrl.canAggregate(idFieldName)) {
                 var joinEntity = searchMeta.getEntity(idField.fk_entity),
                   label = (idField.join ? idField.join.label + ': ' : '') + (idField.input_attrs && idField.input_attrs.label || idField.label);
-                _.each((joinEntity || {}).paths, function(path) {
-                  var link = _.cloneDeep(path);
-                  link.path = link.path.replace(/\[id/g, '[' + idFieldName);
+                _.each(_.cloneDeep(joinEntity && joinEntity.links), function(link) {
                   link.join = idFieldName;
                   addTitle(link, label);
                   links.push(link);
             }
           }
         });
-        return _.uniq(links, 'path');
+        return links;
       };
 
       function loadAfforms() {
index 9aea30e3d93a8a0f82f7e8e7571c6303898dddd6..c7b2548e539353e2d9cebb2ba33a33e429a5639a 100644 (file)
         return !info.fn || info.fn.category !== 'aggregate' || info.fn.name === 'GROUP_CONCAT';
       };
 
+      var linkProps = ['path', 'entity', 'action', 'join', 'target'];
+
       this.toggleLink = function(column) {
         if (column.link) {
-          ctrl.onChangeLink(column, column.link.path, '');
+          ctrl.onChangeLink(column, {});
         } else {
           delete column.editable;
           var defaultLink = ctrl.getLinks(column.key)[0];
-          column.link = {path: defaultLink ? defaultLink.path : 'civicrm/'};
-          ctrl.onChangeLink(column, null, column.link.path);
+          ctrl.onChangeLink(column, defaultLink || {path: 'civicrm/'});
         }
       };
 
-      this.onChangeLink = function(column, before, after) {
-        var beforeLink = before && _.findWhere(ctrl.getLinks(), {path: before}),
-          afterLink = after && _.findWhere(ctrl.getLinks(), {path: after});
-        if (!after) {
-          if (beforeLink && column.title === beforeLink.title) {
+      this.onChangeLink = function(column, afterLink) {
+        column.link = column.link || {};
+        var beforeLink = column.link.action && _.findWhere(ctrl.getLinks(column.key), {action: column.link.action});
+        if (!afterLink.action && !afterLink.path) {
+          if (beforeLink && beforeLink.text === column.title) {
             delete column.title;
           }
           delete column.link;
-        } else if (afterLink && ((!column.title && !before) || (beforeLink && beforeLink.title === column.title))) {
-          column.title = afterLink.title;
-        } else if (!afterLink && (beforeLink && beforeLink.title === column.title)) {
+          return;
+        }
+        if (afterLink.text && ((!column.title && !beforeLink) || (beforeLink && beforeLink.text === column.title))) {
+          column.title = afterLink.text;
+        } else if (!afterLink.text && (beforeLink && beforeLink.text === column.title)) {
           delete column.title;
         }
+        _.each(linkProps, function(prop) {
+          column.link[prop] = afterLink[prop] || '';
+        });
       };
 
       this.getLinks = function(columnKey) {
index 5053c6ba17e2a48519833ddcc4cdd8f52aaad50c..6623f2933dcf0ebbaa009289eaccf21608496617 100644 (file)
@@ -11,7 +11,8 @@
     templateUrl: '~/crmSearchAdmin/crmSearchAdminLinkGroup.html',
     controller: function ($scope, $element, $timeout, searchMeta) {
       var ts = $scope.ts = CRM.ts('org.civicrm.search_kit'),
-        ctrl = this;
+        ctrl = this,
+        linkProps = ['path', 'entity', 'action', 'join', 'target', 'icon', 'text', 'style'];
 
       this.styles = CRM.crmSearchAdmin.styles;
 
         });
       };
 
-      this.addItem = function(path) {
-        var link = ctrl.getLink(path);
-        ctrl.group.push({
-          path: path,
-          style: link && link.style || 'default',
-          text: link ? link.title : ts('Link'),
-          icon: link && link.icon || 'fa-external-link'
-        });
+      this.addItem = function(item) {
+        ctrl.group.push(_.pick(item, linkProps));
       };
 
-      this.onChangeLink = function(item, before, after) {
-        var beforeLink = before && ctrl.getLink(before),
-          beforeTitle = beforeLink ? beforeLink.title : ts('Link'),
-          afterLink = after && ctrl.getLink(after);
-        if (afterLink && (!item.text || beforeTitle === item.text)) {
-          item.text = afterLink.title;
+      this.onChangeLink = function(item, newValue) {
+        if (newValue.path === 'civicrm/') {
+          newValue = JSON.parse(this.default);
         }
+        _.each(linkProps, function(prop) {
+          item[prop] = newValue[prop] || '';
+        });
       };
 
+      this.serialize = JSON.stringify;
+
       this.$onInit = function() {
+        this.default = this.serialize({
+          style: 'default',
+          text: ts('Link'),
+          icon: 'fa-external-link',
+          path: 'civicrm/'
+        });
         var defaultLinks = _.filter(ctrl.links, function(link) {
           return !link.join;
         });
         if (!ctrl.group.length) {
           if (defaultLinks.length) {
-            _.each(_.pluck(defaultLinks, 'path'), ctrl.addItem);
+            _.each(defaultLinks, ctrl.addItem);
           } else {
-            ctrl.addItem('civicrm/');
+            ctrl.addItem(JSON.parse(this.default));
           }
         }
         $element.on('change', 'select.crm-search-admin-add-link', function() {
           var $select = $(this);
           $scope.$apply(function() {
-            ctrl.addItem($select.val());
+            ctrl.addItem(JSON.parse($select.val()));
             $select.val('');
           });
         });
       };
 
-      this.getLink = function(path) {
-        return _.findWhere(ctrl.links, {path: path});
-      };
-
     }
   });
 
index c4459e5c9f7f4b19f26b56d08fc6f123a4f7e6db..fc67712617b117b73cd60d7ced13ad77c7ce980d 100644 (file)
@@ -32,7 +32,7 @@
         <crm-search-admin-token-select model="item" field="text" suffix=":label"></crm-search-admin-token-select>
       </td>
       <td class="form-inline">
-        <crm-search-admin-link-select api-entity="$ctrl.apiEntity" api-params="$ctrl.apiParams" link="item" links="$ctrl.links" on-change="$ctrl.onChangeLink(item, before, after)"></crm-search-admin-link-select>
+        <crm-search-admin-link-select api-entity="$ctrl.apiEntity" api-params="$ctrl.apiParams" link="item" links="$ctrl.links" on-change="$ctrl.onChangeLink(item, newLink)"></crm-search-admin-link-select>
       </td>
       <td>
         <div class="btn-group">
           <option value="">
             + {{:: ts('Add...') }}
           </option>
-          <option ng-repeat="link in $ctrl.links" value="{{ link.path }}">
-            {{ link.title }}
+          <option ng-repeat="link in $ctrl.links" value="{{:: $ctrl.serialize(link) }}">
+            {{ link.text }}
           </option>
-          <option value="civicrm/">
+          <option value="{{:: $ctrl.default }}">
             {{:: ts('Other...') }}
           </option>
         </select>
index 16f4528bb2a2e4aafde07a1b0e80aa3ae0b444e8..bf7c68d4ba98174ea60a85d442921c250d2e008a 100644 (file)
         ctrl = this;
 
       this.setValue = function(val) {
-        ctrl.link  = ctrl.link  || {};
-        var link = ctrl.getLink(val),
-          oldVal = ctrl.link.path;
-        ctrl.link.path = val;
-        if (!link) {
+        if (val.path) {
           $timeout(function () {
             $('input[type=text]', $element).focus();
           });
         }
-        ctrl.onChange({before: oldVal, after: val});
+        ctrl.onChange({newLink: val});
       };
 
-      this.getLink = function(path) {
-        return _.findWhere(ctrl.links, {path: path});
+      this.getLink = function() {
+        return _.findWhere(ctrl.links, {action: ctrl.link.action, join: ctrl.link.join, entity: ctrl.link.entity});
       };
 
     }
index cd5b1f1f91c80b08415362bfc132b0fb4a0066fc..cd4f85f2b72f5c6fa6a358a020e20880db44b32f 100644 (file)
@@ -1,20 +1,22 @@
 <div class="crm-flex-1 input-group" >
-  <input type="text" class="form-control" ng-if="!$ctrl.getLink($ctrl.link.path)" ng-model="$ctrl.link.path" ng-model-options="{updateOn: 'blur'}" ng-change="$ctrl.onChange({before: 'civicrm/', after: $ctrl.link.path})">
-  <div class="input-group-btn" style="{{ $ctrl.getLink($ctrl.link.path) ? '' : 'width:27px' }}">
-    <button type="button" class="btn btn-sm btn-secondary-outline dropdown-toggle" style="min-width: 200px; text-align: left;" ng-if="$ctrl.getLink($ctrl.link.path)" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
-      {{ $ctrl.getLink($ctrl.link.path).title }}
+  <input type="text" class="form-control" ng-if="!$ctrl.link.action" ng-model="$ctrl.link.path" ng-model-options="{updateOn: 'blur'}" ng-change="$ctrl.onChange({newLink: $ctrl.link})">
+  <div class="input-group-btn" style="{{ $ctrl.link.action ? '' : 'width:27px' }}">
+    <button type="button" class="btn btn-sm btn-secondary-outline dropdown-toggle" style="min-width: 200px; text-align: left;" ng-if="$ctrl.link.action" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+      {{ $ctrl.getLink().text }}
     </button>
     <button type="button" class="btn btn-sm btn-secondary dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
       <span class="caret"></span>
     </button>
-    <ul class="dropdown-menu {{ $ctrl.getLink($ctrl.link.path) ? '' : 'dropdown-menu-right' }}" style="min-width: 223px;">
-      <li ng-repeat="link in $ctrl.links" ng-class="{disabled: $ctrl.link.path === link.path}">
-        <a href ng-click="$ctrl.setValue(link.path)">{{:: link.title }}</a>
+    <ul class="dropdown-menu {{ $ctrl.link.action ? '' : 'dropdown-menu-right' }}" style="min-width: 223px;">
+      <li ng-repeat="link in $ctrl.links" ng-class="{disabled: link === $ctrl.getLink()}">
+        <a href ng-click="$ctrl.setValue(link)">{{:: link.text }}</a>
       </li>
-      <li ng-class="{disabled: !$ctrl.getLink($ctrl.link.path)}">
-        <a href ng-click="$ctrl.setValue('civicrm/')">{{:: ts('Other...') }}</a>
+      <li ng-class="{disabled: !$ctrl.link.action}">
+        <a href ng-click="$ctrl.setValue({path: 'civicrm/'})">
+          {{:: ts('Other...') }}
+        </a>
       </li>
     </ul>
   </div>
 </div>
-<crm-search-admin-token-select ng-if="!$ctrl.getLink($ctrl.link.path)" model="$ctrl.link" field="path"></crm-search-admin-token-select>
+<crm-search-admin-token-select ng-if="!$ctrl.link.action" model="$ctrl.link" field="path"></crm-search-admin-token-select>
index 414eba6b30fc0cd2fede34c3bdcfafaae5992bae..6d3b75b03a8ab99e20bed4d6d05954088878d0ac 100644 (file)
@@ -8,7 +8,7 @@
     <option value="_blank">{{:: ts('New tab') }}</option>
     <option value="crm-popup">{{:: ts('Popup dialog') }}</option>
   </select>
-  <crm-search-admin-link-select ng-if="col.link" link="col.link" on-change="$ctrl.parent.onChangeLink(col, before, after)" api-entity="$ctrl.apiEntity" api-params="$ctrl.apiParams" links=":: $ctrl.parent.getLinks(col.key)">
+  <crm-search-admin-link-select ng-if="col.link" link="col.link" on-change="$ctrl.parent.onChangeLink(col, newLink)" api-entity="$ctrl.apiEntity" api-params="$ctrl.apiParams" links=":: $ctrl.parent.getLinks(col.key)">
   </crm-search-admin-link-select>
 </div>
 <div class="form-inline crm-search-admin-flex-row">