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.
--- /dev/null
+<?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;
+ }
+
+}
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']);
}
/**
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().
*
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;
return this.afFormCtrl.getEntity(this.modelName).type;
};
this.getFieldData = function() {
- var data = this.getData();
+ var data = ctrl.getData();
if (!data.length) {
data.push({fields: {}});
}
--- /dev/null
+<?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);
+
+ }
+
+}
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;
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],
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
*/
function search_civicrm_config(&$config) {
_search_civix_civicrm_config($config);
+ Civi::dispatcher()->addListener('hook_civicrm_alterAngular', ['\Civi\Search\AfformSearchMetadataInjector', 'preprocess'], 1000);
}
/**
}
}
}
-
-/**
- * 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);
-
-}