Bridge search displays and afforms
authorColeman Watts <coleman@civicrm.org>
Tue, 19 Jan 2021 16:03:48 +0000 (11:03 -0500)
committerColeman Watts <coleman@civicrm.org>
Tue, 19 Jan 2021 17:23:28 +0000 (12:23 -0500)
This allows an afform af-fieldset to function without a containing af-form or reference to af-entity
Instead it can directly contain an api-entities tag to assist filling field metadata.
The search extension then adds that tag based on the search parameters.
This also eliminates the need to pass in filters to the search display directly (though it still supports that),
as it will infer them from the presence of an af-fieldset.

ext/afform/core/Civi/Afform/AfformMetadataInjector.php [new file with mode: 0644]
ext/afform/core/afform.php
ext/afform/core/ang/af/afFieldset.directive.js
ext/search/Civi/Search/AfformSearchMetadataInjector.php [new file with mode: 0644]
ext/search/ang/crmSearchDisplay.module.js
ext/search/ang/crmSearchDisplayList/crmSearchDisplayList.component.js
ext/search/ang/crmSearchDisplayTable/crmSearchDisplayTable.component.js
ext/search/search.php

diff --git a/ext/afform/core/Civi/Afform/AfformMetadataInjector.php b/ext/afform/core/Civi/Afform/AfformMetadataInjector.php
new file mode 100644 (file)
index 0000000..cf9082a
--- /dev/null
@@ -0,0 +1,155 @@
+<?php
+/*
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC. All rights reserved.                        |
+ |                                                                    |
+ | This work is published under the GNU AGPLv3 license with some      |
+ | permitted exceptions and without any warranty. For full license    |
+ | and copyright information, see https://civicrm.org/licensing       |
+ +--------------------------------------------------------------------+
+ */
+
+namespace Civi\Afform;
+
+/**
+ * Class AfformMetadataInjector
+ * @package Civi\Afform
+ */
+class AfformMetadataInjector {
+
+  /**
+   * @param \Civi\Core\Event\GenericHookEvent $e
+   * @see CRM_Utils_Hook::alterAngular()
+   */
+  public static function preprocess($e) {
+    $changeSet = \Civi\Angular\ChangeSet::create('fieldMetadata')
+      ->alterHtml(';\\.aff\\.html$;', function($doc, $path) {
+        try {
+          $module = \Civi::service('angular')->getModule(basename($path, '.aff.html'));
+          $meta = \Civi\Api4\Afform::get()->addWhere('name', '=', $module['_afform'])->setSelect(['join', 'block'])->setCheckPermissions(FALSE)->execute()->first();
+        }
+        catch (\Exception $e) {
+        }
+
+        $blockEntity = $meta['join'] ?? $meta['block'] ?? NULL;
+        if (!$blockEntity) {
+          $entities = self::getFormEntities($doc);
+        }
+
+        // Each field can be nested within a fieldset, a join or a block
+        foreach (pq('af-field', $doc) as $afField) {
+          /** @var \DOMElement $afField */
+          $action = 'create';
+          $joinName = pq($afField)->parents('[af-join]')->attr('af-join');
+          if ($joinName) {
+            self::fillFieldMetadata($joinName, $action, $afField);
+            continue;
+          }
+          if ($blockEntity) {
+            self::fillFieldMetadata($blockEntity, $action, $afField);
+            continue;
+          }
+          // Not a block or a join, get metadata from fieldset
+          $fieldset = pq($afField)->parents('[af-fieldset]');
+          $apiEntities = pq($fieldset)->attr('api-entities');
+          // If this fieldset is standalone (not linked to an af-entity) it is for get rather than create
+          if ($apiEntities) {
+            $action = 'get';
+            $entityType = self::getFieldEntityType($afField->getAttribute('name'), \CRM_Utils_JS::decode($apiEntities));
+          }
+          else {
+            $entityName = pq($fieldset)->attr('af-fieldset');
+            if (!preg_match(';^[a-zA-Z0-9\_\-\. ]+$;', $entityName)) {
+              throw new \CRM_Core_Exception("Cannot process $path: malformed entity name ($entityName)");
+            }
+            $entityType = $entities[$entityName]['type'];
+          }
+          self::fillFieldMetadata($entityType, $action, $afField);
+        }
+      });
+    $e->angular->add($changeSet);
+  }
+
+  /**
+   * Merge field definition metadata into an afform field's definition
+   *
+   * @param string $entityType
+   * @param string $action
+   * @param \DOMElement $afField
+   * @throws \API_Exception
+   */
+  private static function fillFieldMetadata($entityType, $action, \DOMElement $afField) {
+    $fieldName = $afField->getAttribute('name');
+    if (strpos($entityType, ' AS ')) {
+      [$entityType, $alias] = explode(' AS ', $entityType);
+      $fieldName = preg_replace('/^' . preg_quote($alias . '.', '/') . '/', '', $fieldName);
+    }
+    $params = [
+      'action' => $action,
+      'where' => [['name', '=', $fieldName]],
+      'select' => ['label', 'input_type', 'input_attrs', 'options'],
+      'loadOptions' => ['id', 'label'],
+    ];
+    if (in_array($entityType, \CRM_Contact_BAO_ContactType::basicTypes(TRUE))) {
+      $params['values'] = ['contact_type' => $entityType];
+      $entityType = 'Contact';
+    }
+    // Merge field definition data with whatever's already in the markup.
+    // If the admin has chosen to include this field on the form, then it's OK for us to get metadata about the field - regardless of user's other permissions.
+    $getFields = civicrm_api4($entityType, 'getFields', $params + ['checkPermissions' => FALSE]);
+    $deep = ['input_attrs'];
+    foreach ($getFields as $fieldInfo) {
+      $existingFieldDefn = trim(pq($afField)->attr('defn') ?: '');
+      if ($existingFieldDefn && $existingFieldDefn[0] != '{') {
+        // If it's not an object, don't mess with it.
+        continue;
+      }
+      // Default placeholder for select inputs
+      if ($fieldInfo['input_type'] === 'Select') {
+        $fieldInfo['input_attrs'] = ($fieldInfo['input_attrs'] ?? []) + ['placeholder' => ts('Select')];
+      }
+
+      $fieldDefn = $existingFieldDefn ? \CRM_Utils_JS::getRawProps($existingFieldDefn) : [];
+      foreach ($fieldInfo as $name => $prop) {
+        // Merge array props 1 level deep
+        if (in_array($name, $deep) && !empty($fieldDefn[$name])) {
+          $fieldDefn[$name] = \CRM_Utils_JS::writeObject(\CRM_Utils_JS::getRawProps($fieldDefn[$name]) + array_map(['\CRM_Utils_JS', 'encode'], $prop));
+        }
+        elseif (!isset($fieldDefn[$name])) {
+          $fieldDefn[$name] = \CRM_Utils_JS::encode($prop);
+        }
+      }
+      pq($afField)->attr('defn', htmlspecialchars(\CRM_Utils_JS::writeObject($fieldDefn)));
+    }
+  }
+
+  /**
+   * @param string $fieldName
+   * @param string[] $entityList
+   * @return string
+   */
+  private static function getFieldEntityType($fieldName, $entityList) {
+    $prefix = strpos($fieldName, '.') ? explode('.', $fieldName)[0] : NULL;
+    $baseEntity = array_shift($entityList);
+    if ($prefix) {
+      foreach ($entityList as $entityAndAlias) {
+        [$entity, $alias] = explode(' AS ', $entityAndAlias);
+        if ($alias === $prefix) {
+          return $entityAndAlias;
+        }
+      }
+    }
+    return $baseEntity;
+  }
+
+  private static function getFormEntities(\phpQueryObject $doc) {
+    $entities = [];
+    foreach ($doc->find('af-entity') as $afmModelProp) {
+      $entities[$afmModelProp->getAttribute('name')] = [
+        'type' => $afmModelProp->getAttribute('type'),
+      ];
+    }
+    return $entities;
+  }
+
+}
index 0cecbe4ed11ae7b7eaf87ae08e689d65637d643f..4c7ff432de837369c567d6184a4b32f3fcadcc94 100644 (file)
@@ -52,6 +52,7 @@ function afform_civicrm_config(&$config) {
   Civi::dispatcher()->addListener(Submit::EVENT_NAME, [Submit::class, 'processContacts'], 500);
   Civi::dispatcher()->addListener(Submit::EVENT_NAME, [Submit::class, 'processGenericEntity'], -1000);
   Civi::dispatcher()->addListener('hook_civicrm_angularModules', '_afform_civicrm_angularModules_autoReq', -1000);
+  Civi::dispatcher()->addListener('hook_civicrm_alterAngular', ['\Civi\Afform\AfformMetadataInjector', 'preprocess']);
 }
 
 /**
@@ -341,96 +342,6 @@ function _afform_reverse_deps_find($formName, $html, $revMap) {
   return array_values(array_unique(array_merge($elems, $attrs)));
 }
 
-/**
- * @param \Civi\Angular\Manager $angular
- * @see CRM_Utils_Hook::alterAngular()
- */
-function afform_civicrm_alterAngular($angular) {
-  $fieldMetadata = \Civi\Angular\ChangeSet::create('fieldMetadata')
-    ->alterHtml(';\\.aff\\.html$;', function($doc, $path) {
-      try {
-        $module = \Civi::service('angular')->getModule(basename($path, '.aff.html'));
-        $meta = \Civi\Api4\Afform::get()->addWhere('name', '=', $module['_afform'])->setSelect(['join', 'block'])->setCheckPermissions(FALSE)->execute()->first();
-      }
-      catch (Exception $e) {
-      }
-
-      $blockEntity = $meta['join'] ?? $meta['block'] ?? NULL;
-      if (!$blockEntity) {
-        $entities = _afform_getMetadata($doc);
-      }
-
-      foreach (pq('af-field', $doc) as $afField) {
-        /** @var DOMElement $afField */
-        $entityName = pq($afField)->parents('[af-fieldset]')->attr('af-fieldset');
-        $joinName = pq($afField)->parents('[af-join]')->attr('af-join');
-        if (!$blockEntity && !preg_match(';^[a-zA-Z0-9\_\-\. ]+$;', $entityName)) {
-          throw new \CRM_Core_Exception("Cannot process $path: malformed entity name ($entityName)");
-        }
-        $entityType = $blockEntity ?? $entities[$entityName]['type'];
-        _af_fill_field_metadata($joinName ? $joinName : $entityType, $afField);
-      }
-    });
-  $angular->add($fieldMetadata);
-}
-
-/**
- * Merge field definition metadata into an afform field's definition
- *
- * @param $entityType
- * @param DOMElement $afField
- * @throws API_Exception
- */
-function _af_fill_field_metadata($entityType, DOMElement $afField) {
-  $params = [
-    'action' => 'create',
-    'where' => [['name', '=', $afField->getAttribute('name')]],
-    'select' => ['label', 'input_type', 'input_attrs', 'options'],
-    'loadOptions' => ['id', 'label'],
-  ];
-  if (in_array($entityType, CRM_Contact_BAO_ContactType::basicTypes(TRUE))) {
-    $params['values'] = ['contact_type' => $entityType];
-    $entityType = 'Contact';
-  }
-  // Merge field definition data with whatever's already in the markup.
-  // If the admin has chosen to include this field on the form, then it's OK for us to get metadata about the field - regardless of user's other permissions.
-  $getFields = civicrm_api4($entityType, 'getFields', $params + ['checkPermissions' => FALSE]);
-  $deep = ['input_attrs'];
-  foreach ($getFields as $fieldInfo) {
-    $existingFieldDefn = trim(pq($afField)->attr('defn') ?: '');
-    if ($existingFieldDefn && $existingFieldDefn[0] != '{') {
-      // If it's not an object, don't mess with it.
-      continue;
-    }
-    // Default placeholder for select inputs
-    if ($fieldInfo['input_type'] === 'Select') {
-      $fieldInfo['input_attrs'] = ($fieldInfo['input_attrs'] ?? []) + ['placeholder' => ts('Select')];
-    }
-
-    $fieldDefn = $existingFieldDefn ? CRM_Utils_JS::getRawProps($existingFieldDefn) : [];
-    foreach ($fieldInfo as $name => $prop) {
-      // Merge array props 1 level deep
-      if (in_array($name, $deep) && !empty($fieldDefn[$name])) {
-        $fieldDefn[$name] = CRM_Utils_JS::writeObject(CRM_Utils_JS::getRawProps($fieldDefn[$name]) + array_map(['CRM_Utils_JS', 'encode'], $prop));
-      }
-      elseif (!isset($fieldDefn[$name])) {
-        $fieldDefn[$name] = CRM_Utils_JS::encode($prop);
-      }
-    }
-    pq($afField)->attr('defn', htmlspecialchars(CRM_Utils_JS::writeObject($fieldDefn)));
-  }
-}
-
-function _afform_getMetadata(phpQueryObject $doc) {
-  $entities = [];
-  foreach ($doc->find('af-entity') as $afmModelProp) {
-    $entities[$afmModelProp->getAttribute('name')] = [
-      'type' => $afmModelProp->getAttribute('type'),
-    ];
-  }
-  return $entities;
-}
-
 /**
  * Implements hook_civicrm_alterSettingsFolders().
  *
index 4294ee7c58ec728008a09ad5bf393c240d711802..979915f21d0c5c760585fe6830b527326ccd93cf 100644 (file)
@@ -3,7 +3,7 @@
   angular.module('af').directive('afFieldset', function() {
     return {
       restrict: 'A',
-      require: ['afFieldset', '^afForm'],
+      require: ['afFieldset', '?^^afForm'],
       bindToController: {
         modelName: '@afFieldset'
       },
         var self = ctrls[0];
         self.afFormCtrl = ctrls[1];
       },
-      controller: function($scope){
-        this.getDefn = function() {
-          return this.afFormCtrl.getEntity(this.modelName);
-        };
+      controller: function({
+        var ctrl = this,
+          localData = [];
+
         this.getData = function() {
-          return this.afFormCtrl.getData(this.modelName);
+          return ctrl.afFormCtrl ? ctrl.afFormCtrl.getData(ctrl.modelName) : localData;
         };
         this.getName = function() {
           return this.modelName;
@@ -25,7 +25,7 @@
           return this.afFormCtrl.getEntity(this.modelName).type;
         };
         this.getFieldData = function() {
-          var data = this.getData();
+          var data = ctrl.getData();
           if (!data.length) {
             data.push({fields: {}});
           }
diff --git a/ext/search/Civi/Search/AfformSearchMetadataInjector.php b/ext/search/Civi/Search/AfformSearchMetadataInjector.php
new file mode 100644 (file)
index 0000000..e86b796
--- /dev/null
@@ -0,0 +1,62 @@
+<?php
+/*
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC. All rights reserved.                        |
+ |                                                                    |
+ | This work is published under the GNU AGPLv3 license with some      |
+ | permitted exceptions and without any warranty. For full license    |
+ | and copyright information, see https://civicrm.org/licensing       |
+ +--------------------------------------------------------------------+
+ */
+
+namespace Civi\Search;
+
+/**
+ * Class AfformSearchMetadataInjector
+ * @package Civi\Search
+ */
+class AfformSearchMetadataInjector {
+
+  /**
+   * Injects settings data into search displays embedded in afforms
+   *
+   * @param \Civi\Core\Event\GenericHookEvent $e
+   * @see CRM_Utils_Hook::alterAngular()
+   */
+  public static function preprocess($e) {
+    $changeSet = \Civi\Angular\ChangeSet::create('searchSettings')
+      ->alterHtml(';\\.aff\\.html$;', function($doc, $path) {
+        $displayTypes = array_column(\Civi\Search\Display::getDisplayTypes(['name']), 'name');
+
+        if ($displayTypes) {
+          $displayTypeTags = 'crm-search-display-' . implode(', crm-search-display-', $displayTypes);
+          foreach (pq($displayTypeTags, $doc) as $component) {
+            $searchName = pq($component)->attr('search-name');
+            $displayName = pq($component)->attr('display-name');
+            if ($searchName && $displayName) {
+              $display = \Civi\Api4\SearchDisplay::get(FALSE)
+                ->addWhere('name', '=', $displayName)
+                ->addWhere('saved_search.name', '=', $searchName)
+                ->addSelect('settings', 'saved_search.api_entity', 'saved_search.api_params')
+                ->execute()->first();
+              if ($display) {
+                pq($component)->attr('settings', \CRM_Utils_JS::encode($display['settings'] ?? []));
+                pq($component)->attr('api-entity', \CRM_Utils_JS::encode($display['saved_search.api_entity']));
+                pq($component)->attr('api-params', \CRM_Utils_JS::encode($display['saved_search.api_params']));
+
+                // Add entity names to the fieldset so that afform can populate field metadata
+                $fieldset = pq($component)->parents('[af-fieldset]');
+                if ($fieldset->length) {
+                  $entityList = array_merge([$display['saved_search.api_entity']], array_column($display['saved_search.api_params']['join'] ?? [], 0));
+                  $fieldset->attr('api-entities', \CRM_Utils_JS::encode($entityList));
+                }
+              }
+            }
+          }
+        }
+      });
+    $e->angular->add($changeSet);
+
+  }
+
+}
index 377b3b1c5d41882d1cf9e8ff2b00ec483d523483..72bd7c6a1e4acea2d258e470a606152f5769fa61 100644 (file)
@@ -74,8 +74,8 @@
         return columns;
       }
 
-      function prepareParams(apiParams, filters, page) {
-        var params = _.cloneDeep(apiParams);
+      function prepareParams(ctrl) {
+        var params = _.cloneDeep(ctrl.apiParams);
         if (_.isEmpty(params.where)) {
           params.where = [];
         }
             params.select.push(idField);
           }
         });
-        _.each(filters, function(value, key) {
+        function addFilter(value, key) {
           if (value) {
             params.where.push([key, 'CONTAINS', value]);
           }
-        });
-        if (page) {
-          params.offset = (page - 1) * apiParams.limit;
+        }
+        // Add filters explicitly passed into controller
+        _.each(ctrl.filters, addFilter);
+        // Add filters when nested in an afform fieldset
+        if (ctrl.afFieldset) {
+          _.each(ctrl.afFieldset.getFieldData(), addFilter);
+        }
+
+        if (ctrl.settings && ctrl.settings.pager && ctrl.page) {
+          params.offset = (ctrl.page - 1) * params.limit;
           params.select.push('row_count');
         }
         return params;
index 50157877bc728bfeb5df843ac043f4874bab1256..45688ba1c9a08f0182b4e7f37ee2f57b657c3104 100644 (file)
@@ -8,6 +8,9 @@
       settings: '<',
       filters: '<'
     },
+    require: {
+      afFieldset: '?^^afFieldset'
+    },
     templateUrl: '~/crmSearchDisplayList/crmSearchDisplayList.html',
     controller: function($scope, crmApi4, searchDisplayUtils) {
       var ts = $scope.ts = CRM.ts(),
         this.apiParams.limit = parseInt(this.settings.limit || 0, 10);
         this.columns = searchDisplayUtils.prepareColumns(this.settings.columns, this.apiParams);
         $scope.displayUtils = searchDisplayUtils;
+        if (this.afFieldset) {
+          $scope.$watch(this.afFieldset.getFieldData, this.getResults, true);
+        }
+        $scope.$watch('$ctrl.filters', ctrl.getResults, true);
       };
 
-      this.getResults = function() {
-        var params = searchDisplayUtils.prepareParams(ctrl.apiParams, ctrl.filters, ctrl.settings.pager ? ctrl.page : null);
+      this.getResults = _.debounce(function() {
+        var params = searchDisplayUtils.prepareParams(ctrl);
 
         crmApi4(ctrl.apiEntity, 'get', params).then(function(results) {
           ctrl.results = results;
           ctrl.rowCount = results.count;
         });
-      };
-
-      $scope.$watch('$ctrl.filters', ctrl.getResults, true);
+      }, 100);
 
       $scope.formatResult = function(row, col) {
         var value = row[col.key],
index 127d5d9c13932274750201e12dd04fb782ddce86..bdcd65683ae3e40d748d878fdd1f1f50ef094e78 100644 (file)
@@ -8,6 +8,9 @@
       settings: '<',
       filters: '<'
     },
+    require: {
+      afFieldset: '?^^afFieldset'
+    },
     templateUrl: '~/crmSearchDisplayTable/crmSearchDisplayTable.html',
     controller: function($scope, crmApi4, searchDisplayUtils) {
       var ts = $scope.ts = CRM.ts(),
         this.apiParams.limit = parseInt(this.settings.limit || 0, 10);
         this.columns = searchDisplayUtils.prepareColumns(this.settings.columns, this.apiParams);
         $scope.displayUtils = searchDisplayUtils;
+
+        if (this.afFieldset) {
+          $scope.$watch(this.afFieldset.getFieldData, this.getResults, true);
+        }
+        $scope.$watch('$ctrl.filters', ctrl.getResults, true);
       };
 
-      this.getResults = function() {
-        var params = searchDisplayUtils.prepareParams(ctrl.apiParams, ctrl.filters, ctrl.settings.pager ? ctrl.page : null);
+      this.getResults = _.debounce(function() {
+        var params = searchDisplayUtils.prepareParams(ctrl);
 
         crmApi4(ctrl.apiEntity, 'get', params).then(function(results) {
           ctrl.results = results;
           ctrl.rowCount = results.count;
         });
-      };
-
-      $scope.$watch('$ctrl.filters', ctrl.getResults, true);
+      }, 100);
 
       /**
        * Returns crm-i icon class for a sortable column
index ce37a888686bc3e0e4020414f31aabbd56399bd5..45780076786da8507df3f5f8d81ba54bb9fb3f3d 100644 (file)
@@ -9,6 +9,7 @@ require_once 'search.civix.php';
  */
 function search_civicrm_config(&$config) {
   _search_civix_civicrm_config($config);
+  Civi::dispatcher()->addListener('hook_civicrm_alterAngular', ['\Civi\Search\AfformSearchMetadataInjector', 'preprocess'], 1000);
 }
 
 /**
@@ -141,38 +142,3 @@ function search_civicrm_pre($op, $entity, $id, &$params) {
     }
   }
 }
-
-/**
- * Injects settings data to search displays embedded in afforms
- *
- * @param \Civi\Angular\Manager $angular
- * @see CRM_Utils_Hook::alterAngular()
- */
-function search_civicrm_alterAngular($angular) {
-  $changeSet = \Civi\Angular\ChangeSet::create('searchSettings')
-    ->alterHtml(';\\.aff\\.html$;', function($doc, $path) {
-      $displayTypes = array_column(\Civi\Search\Display::getDisplayTypes(['name']), 'name');
-
-      if ($displayTypes) {
-        $componentNames = 'crm-search-display-' . implode(', crm-search-display-', $displayTypes);
-        foreach (pq($componentNames, $doc) as $component) {
-          $searchName = pq($component)->attr('search-name');
-          $displayName = pq($component)->attr('display-name');
-          if ($searchName && $displayName) {
-            $display = \Civi\Api4\SearchDisplay::get(FALSE)
-              ->addWhere('name', '=', $displayName)
-              ->addWhere('saved_search.name', '=', $searchName)
-              ->addSelect('settings', 'saved_search.api_entity', 'saved_search.api_params')
-              ->execute()->first();
-            if ($display) {
-              pq($component)->attr('settings', CRM_Utils_JS::encode($display['settings'] ?? []));
-              pq($component)->attr('api-entity', CRM_Utils_JS::encode($display['saved_search.api_entity']));
-              pq($component)->attr('api-params', CRM_Utils_JS::encode($display['saved_search.api_params']));
-            }
-          }
-        }
-      }
-    });
-  $angular->add($changeSet);
-
-}