From: Coleman Watts Date: Tue, 19 Jan 2021 16:03:48 +0000 (-0500) Subject: Bridge search displays and afforms X-Git-Url: https://vcs.fsf.org/?a=commitdiff_plain;h=a92d81a18444cc817399df7381e5488c9d0c069f;p=civicrm-core.git Bridge search displays and afforms 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. --- diff --git a/ext/afform/core/Civi/Afform/AfformMetadataInjector.php b/ext/afform/core/Civi/Afform/AfformMetadataInjector.php new file mode 100644 index 0000000000..cf9082a413 --- /dev/null +++ b/ext/afform/core/Civi/Afform/AfformMetadataInjector.php @@ -0,0 +1,155 @@ +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; + } + +} diff --git a/ext/afform/core/afform.php b/ext/afform/core/afform.php index 0cecbe4ed1..4c7ff432de 100644 --- a/ext/afform/core/afform.php +++ b/ext/afform/core/afform.php @@ -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(). * diff --git a/ext/afform/core/ang/af/afFieldset.directive.js b/ext/afform/core/ang/af/afFieldset.directive.js index 4294ee7c58..979915f21d 100644 --- a/ext/afform/core/ang/af/afFieldset.directive.js +++ b/ext/afform/core/ang/af/afFieldset.directive.js @@ -3,7 +3,7 @@ angular.module('af').directive('afFieldset', function() { return { restrict: 'A', - require: ['afFieldset', '^afForm'], + require: ['afFieldset', '?^^afForm'], bindToController: { modelName: '@afFieldset' }, @@ -11,12 +11,12 @@ 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 index 0000000000..e86b796bc8 --- /dev/null +++ b/ext/search/Civi/Search/AfformSearchMetadataInjector.php @@ -0,0 +1,62 @@ +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); + + } + +} diff --git a/ext/search/ang/crmSearchDisplay.module.js b/ext/search/ang/crmSearchDisplay.module.js index 377b3b1c5d..72bd7c6a1e 100644 --- a/ext/search/ang/crmSearchDisplay.module.js +++ b/ext/search/ang/crmSearchDisplay.module.js @@ -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 = []; } @@ -87,13 +87,20 @@ 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; diff --git a/ext/search/ang/crmSearchDisplayList/crmSearchDisplayList.component.js b/ext/search/ang/crmSearchDisplayList/crmSearchDisplayList.component.js index 50157877bc..45688ba1c9 100644 --- a/ext/search/ang/crmSearchDisplayList/crmSearchDisplayList.component.js +++ b/ext/search/ang/crmSearchDisplayList/crmSearchDisplayList.component.js @@ -8,6 +8,9 @@ settings: '<', filters: '<' }, + require: { + afFieldset: '?^^afFieldset' + }, templateUrl: '~/crmSearchDisplayList/crmSearchDisplayList.html', controller: function($scope, crmApi4, searchDisplayUtils) { var ts = $scope.ts = CRM.ts(), @@ -19,18 +22,20 @@ 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], diff --git a/ext/search/ang/crmSearchDisplayTable/crmSearchDisplayTable.component.js b/ext/search/ang/crmSearchDisplayTable/crmSearchDisplayTable.component.js index 127d5d9c13..bdcd65683a 100644 --- a/ext/search/ang/crmSearchDisplayTable/crmSearchDisplayTable.component.js +++ b/ext/search/ang/crmSearchDisplayTable/crmSearchDisplayTable.component.js @@ -8,6 +8,9 @@ settings: '<', filters: '<' }, + require: { + afFieldset: '?^^afFieldset' + }, templateUrl: '~/crmSearchDisplayTable/crmSearchDisplayTable.html', controller: function($scope, crmApi4, searchDisplayUtils) { var ts = $scope.ts = CRM.ts(), @@ -22,18 +25,21 @@ 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 diff --git a/ext/search/search.php b/ext/search/search.php index ce37a88868..4578007678 100644 --- a/ext/search/search.php +++ b/ext/search/search.php @@ -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); - -}