Improves support for calculated fields in the Afform Admin UI, allowing them to be
configured more like real fields. This allows e.g. a SUM() aggregate to be made a
"search by range" filter.
if ($inputType == 'Date' && !empty($inputAttrs['formatType'])) {
self::setLegacyDateFormat($inputAttrs);
}
+ // Number input for integer fields
+ if ($inputType === 'Text' && $dataTypeName === 'Int') {
+ $inputType = 'Number';
+ }
// Date/time settings from custom fields
if ($inputType == 'Date' && !empty($data['custom_group_id'])) {
$inputAttrs['time'] = empty($data['time_format']) ? FALSE : ($data['time_format'] == 1 ? 12 : 24);
use Civi\AfformAdmin\AfformAdminMeta;
use Civi\Api4\Afform;
use Civi\Api4\Utils\CoreUtil;
-use Civi\Api4\Query\SqlExpression;
/**
* This action is used by the Afform Admin extension to load metadata for the Admin GUI.
$display = $displayGet
->addSelect('*', 'type:name', 'type:icon', 'saved_search_id.name', 'saved_search_id.label', 'saved_search_id.api_entity', 'saved_search_id.api_params')
->execute()->first();
- $display['calc_fields'] = $this->getCalcFields($display['saved_search_id.api_entity'], $display['saved_search_id.api_params']);
+ if (!$display) {
+ continue;
+ }
+ $display['calc_fields'] = \Civi\Search\Meta::getCalcFields($display['saved_search_id.api_entity'], $display['saved_search_id.api_params']);
$display['filters'] = empty($displayTag['filters']) ? NULL : (\CRM_Utils_JS::getRawProps($displayTag['filters']) ?: NULL);
$info['search_displays'][] = $display;
if ($newForm) {
}
}
- /**
- * @param string $apiEntity
- * @param array $apiParams
- * @return array
- */
- private function getCalcFields($apiEntity, $apiParams) {
- $calcFields = [];
- $api = \Civi\API\Request::create($apiEntity, 'get', $apiParams);
- $selectQuery = new \Civi\Api4\Query\Api4SelectQuery($api);
- $joinMap = $joinCount = [];
- foreach ($apiParams['join'] ?? [] as $join) {
- [$entityName, $alias] = explode(' AS ', $join[0]);
- $num = '';
- if (!empty($joinCount[$entityName])) {
- $num = ' ' . (++$joinCount[$entityName]);
- }
- else {
- $joinCount[$entityName] = 1;
- }
- $label = CoreUtil::getInfoItem($entityName, 'title');
- $joinMap[$alias] = $label . $num;
- }
-
- foreach ($apiParams['select'] ?? [] as $select) {
- if (strstr($select, ' AS ')) {
- $expr = SqlExpression::convert($select, TRUE);
- $label = $expr::getTitle();
- foreach ($expr->getFields() as $num => $fieldName) {
- $field = $selectQuery->getField($fieldName);
- $joinName = explode('.', $fieldName)[0];
- $label .= ($num ? ', ' : ': ') . (isset($joinMap[$joinName]) ? $joinMap[$joinName] . ' ' : '') . $field['title'];
- }
- $calcFields[] = [
- '#tag' => 'af-field',
- 'name' => $expr->getAlias(),
- 'defn' => [
- 'label' => $label,
- 'input_type' => 'Text',
- ],
- ];
- }
- }
- return $calcFields;
- }
-
/**
* @return array[]
*/
$scope.controls = {};
$scope.fieldList = [];
$scope.calcFieldList = [];
+ $scope.calcFieldTitles = [];
$scope.blockList = [];
$scope.blockTitles = [];
$scope.elementList = [];
fieldGroups.push({
text: ts('Calculated Fields'),
children: _.transform(ctrl.display.settings.calc_fields, function(fields, el) {
- fields.push({id: el.name, text: el.defn.label, disabled: ctrl.fieldInUse(el.name)});
+ fields.push({id: el.name, text: el.label, disabled: ctrl.fieldInUse(el.name)});
}, [])
});
}
return entity || ctrl.display.settings['saved_search_id.api_entity'];
};
+ function fieldDefaults(field, prefix) {
+ var tag = {
+ "#tag": "af-field",
+ name: prefix + field.name
+ };
+ if (field.input_type === 'Select' || field.input_type === 'ChainSelect') {
+ tag.defn = {input_attrs: {multiple: true}};
+ } else if (field.input_type === 'Date') {
+ tag.defn = {input_type: 'Select', search_range: true};
+ } else if (field.options) {
+ tag.defn = {input_type: 'Select', input_attrs: {multiple: true}};
+ }
+ return tag;
+ }
+
function buildCalcFieldList(search) {
$scope.calcFieldList.length = 0;
+ $scope.calcFieldTitles.length = 0;
_.each(_.cloneDeep(ctrl.display.settings.calc_fields), function(field) {
- if (!search || _.contains(field.defn.label.toLowerCase(), search)) {
- $scope.calcFieldList.push(field);
+ if (!search || _.contains(field.label.toLowerCase(), search)) {
+ $scope.calcFieldList.push(fieldDefaults(field, ''));
+ $scope.calcFieldTitles.push(field.label);
}
});
}
}
}, []);
}
-
- function fieldDefaults(field, prefix) {
- var tag = {
- "#tag": "af-field",
- name: prefix + field.name
- };
- if (field.input_type === 'Select' || field.input_type === 'ChainSelect') {
- tag.defn = {input_attrs: {multiple: true}};
- } else if (field.input_type === 'Date') {
- tag.defn = {input_type: 'Select', search_range: true};
- } else if (field.options) {
- tag.defn = {input_type: 'Select', input_attrs: {multiple: true}};
- }
- return tag;
- }
}
function buildElementList(search) {
<label>{{:: ts('Calculated Fields') }}</label>
<div ui-sortable="$ctrl.editor.getSortableOptions($ctrl.editor.getSelectedEntityName())" ui-sortable-update="buildPaletteLists" ng-model="calcFieldList">
<div ng-repeat="field in calcFieldList" ng-class="{disabled: $ctrl.fieldInUse(field.name)}">
- <div class="af-gui-palette-item">{{:: field.defn.label }}</div>
+ <div class="af-gui-palette-item">{{:: calcFieldTitles[$index] }}</div>
</div>
</div>
</div>
}
});
}
- if (!entityType && fieldKey && afGui.getField(searchDisplay['saved_search_id.api_entity'], fieldKey)) {
+ if (!entityType) {
entityType = searchDisplay['saved_search_id.api_entity'];
}
}
// Returns the original field definition from metadata
this.getDefn = function() {
var defn = afGui.getField(ctrl.container.getFieldEntityType(ctrl.node.name), ctrl.node.name);
+ // Calc fields are specific to a search display, not part of the schema
+ if (!defn && ctrl.container.getSearchDisplay(ctrl.container.node)) {
+ var searchDisplay = ctrl.container.getSearchDisplay(ctrl.container.node);
+ defn = _.findWhere(searchDisplay.calc_fields, {name: ctrl.node.name});
+ }
defn = defn || {
label: ts('Untitled'),
required: false
}
/**
- * Merge field definition metadata into an afform field's definition
- *
- * @param string|array $entityNames
+ * @param $entityNames
* @param string $action
- * @param \DOMElement $afField
- * @throws \API_Exception
+ * @param string $fieldName
+ * @return array|null
*/
- private static function fillFieldMetadata($entityNames, $action, \DOMElement $afField) {
- $fieldName = $afField->getAttribute('name');
+ private static function getFieldMetadata($entityNames, string $action, string $fieldName):? array {
foreach ((array) $entityNames as $entityName) {
$fieldInfo = self::getField($entityName, $fieldName, $action);
if ($fieldInfo) {
- break;
+ return $fieldInfo;
}
}
- // Merge field definition data with whatever's already in the markup.
+ return NULL;
+ }
+
+ /**
+ * Merge a field's definition with whatever's already in the markup
+ *
+ * @param \DOMElement $afField
+ * @param array $fieldInfo
+ * @throws \API_Exception
+ * @throws \CRM_Core_Exception
+ * @throws \Civi\API\Exception\NotImplementedException
+ */
+ public static function setFieldMetadata(\DOMElement $afField, array $fieldInfo):void {
$deep = ['input_attrs'];
- if ($fieldInfo) {
- // Defaults for attributes not in spec
- $fieldInfo['search_range'] = FALSE;
+ // Defaults for attributes not in spec
+ $fieldInfo['search_range'] = FALSE;
- $existingFieldDefn = trim(pq($afField)->attr('defn') ?: '');
- if ($existingFieldDefn && $existingFieldDefn[0] != '{') {
- // If it's not an object, don't mess with it.
- return;
- }
+ $existingFieldDefn = trim(pq($afField)->attr('defn') ?: '');
+ if ($existingFieldDefn && $existingFieldDefn[0] != '{') {
+ // If it's not an object, don't mess with it.
+ return;
+ }
- // Get field defn from afform markup
- $fieldDefn = $existingFieldDefn ? \CRM_Utils_JS::getRawProps($existingFieldDefn) : [];
- // This is the input type set on the form (may be different from the default input type in the field spec)
- $inputType = !empty($fieldDefn['input_type']) ? \CRM_Utils_JS::decode($fieldDefn['input_type']) : $fieldInfo['input_type'];
- // On a search form, search_range will present a pair of fields (or possibly 3 fields for date select + range)
- $isSearchRange = !empty($fieldDefn['search_range']) && \CRM_Utils_JS::decode($fieldDefn['search_range']);
+ // Get field defn from afform markup
+ $fieldDefn = $existingFieldDefn ? \CRM_Utils_JS::getRawProps($existingFieldDefn) : [];
+ // This is the input type set on the form (may be different from the default input type in the field spec)
+ $inputType = !empty($fieldDefn['input_type']) ? \CRM_Utils_JS::decode($fieldDefn['input_type']) : $fieldInfo['input_type'];
+ // On a search form, search_range will present a pair of fields (or possibly 3 fields for date select + range)
+ $isSearchRange = !empty($fieldDefn['search_range']) && \CRM_Utils_JS::decode($fieldDefn['search_range']);
- // Default placeholder for select inputs
- if ($inputType === 'Select' || $inputType === 'ChainSelect') {
- $fieldInfo['input_attrs']['placeholder'] = E::ts('Select');
- }
- elseif ($inputType === 'EntityRef') {
- $info = civicrm_api4('Entity', 'get', [
- 'where' => [['name', '=', $fieldInfo['fk_entity']]],
- 'checkPermissions' => FALSE,
- 'select' => ['title', 'title_plural'],
- ], 0);
- $label = empty($fieldInfo['input_attrs']['multiple']) ? $info['title'] : $info['title_plural'];
- $fieldInfo['input_attrs']['placeholder'] = E::ts('Select %1', [1 => $label]);
- }
+ // Default placeholder for select inputs
+ if ($inputType === 'Select' || $inputType === 'ChainSelect') {
+ $fieldInfo['input_attrs']['placeholder'] = E::ts('Select');
+ }
+ elseif ($inputType === 'EntityRef') {
+ $info = civicrm_api4('Entity', 'get', [
+ 'where' => [['name', '=', $fieldInfo['fk_entity']]],
+ 'checkPermissions' => FALSE,
+ 'select' => ['title', 'title_plural'],
+ ], 0);
+ $label = empty($fieldInfo['input_attrs']['multiple']) ? $info['title'] : $info['title_plural'];
+ $fieldInfo['input_attrs']['placeholder'] = E::ts('Select %1', [1 => $label]);
+ }
- if ($fieldInfo['input_type'] === 'Date') {
- // This flag gets used by the afField controller
- $fieldDefn['is_date'] = TRUE;
- // For date fields that have been converted to Select
- if ($inputType === 'Select') {
- $dateOptions = \CRM_Utils_Array::makeNonAssociative(\CRM_Core_OptionGroup::values('relative_date_filters'), 'id', 'label');
- if ($isSearchRange) {
- $dateOptions = array_merge([['id' => '{}', 'label' => E::ts('Choose Date Range')]], $dateOptions);
- }
- $fieldInfo['options'] = $dateOptions;
+ if ($fieldInfo['input_type'] === 'Date') {
+ // This flag gets used by the afField controller
+ $fieldDefn['is_date'] = TRUE;
+ // For date fields that have been converted to Select
+ if ($inputType === 'Select') {
+ $dateOptions = \CRM_Utils_Array::makeNonAssociative(\CRM_Core_OptionGroup::values('relative_date_filters'), 'id', 'label');
+ if ($isSearchRange) {
+ $dateOptions = array_merge([['id' => '{}', 'label' => E::ts('Choose Date Range')]], $dateOptions);
}
+ $fieldInfo['options'] = $dateOptions;
}
+ }
- 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);
- }
+ 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));
}
- pq($afField)->attr('defn', htmlspecialchars(\CRM_Utils_JS::writeObject($fieldDefn)));
+ elseif (!isset($fieldDefn[$name])) {
+ $fieldDefn[$name] = \CRM_Utils_JS::encode($prop);
+ }
+ }
+ pq($afField)->attr('defn', htmlspecialchars(\CRM_Utils_JS::writeObject($fieldDefn)));
+ }
+
+ /**
+ * Merge field definition metadata into an afform field's definition
+ *
+ * @param string|array $entityNames
+ * @param string $action
+ * @param \DOMElement $afField
+ * @throws \API_Exception
+ */
+ private static function fillFieldMetadata($entityNames, string $action, \DOMElement $afField):void {
+ $fieldName = $afField->getAttribute('name');
+ $fieldInfo = self::getFieldMetadata($entityNames, $action, $fieldName);
+ // Merge field definition data with whatever's already in the markup.
+ if ($fieldInfo) {
+ self::setFieldMetadata($afField, $fieldInfo);
}
}
<af-field name="source" />
<div class="af-container af-layout-inline">
<af-field name="Contact_Email_contact_id_01.email" />
+ <af-field name="YEAR_birth_date" defn="{search_range: true}" />
<af-field name="Contact_Email_contact_id_01.location_type_id" defn="{input_attrs: {multiple: true}}" />
</div>
<crm-search-display-table filters="{last_name: 'AfformTest', contact_type: dummy_var}" search-name="TestContactEmailSearch" display-name="TestContactEmailDisplay"></crm-search-display-table>
}
}
$fieldset->attr('api-entities', htmlspecialchars(\CRM_Utils_JS::encode($entityList)));
+ // Add field metadata for aggregate fields because they are not in the schema.
+ // Normal entity fields will be handled by AfformMetadataInjector
+ foreach (Meta::getCalcFields($display['saved_search_id.api_entity'], $display['saved_search_id.api_params']) as $fieldInfo) {
+ foreach (pq("af-field[name='{$fieldInfo['name']}']", $doc) as $afField) {
+ \Civi\Afform\AfformMetadataInjector::setFieldMetadata($afField, $fieldInfo);
+ }
+ }
}
}
}
}
});
$e->angular->add($changeSet);
-
}
}
--- /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;
+
+use CRM_Search_ExtensionUtil as E;
+use Civi\Api4\Query\SqlExpression;
+use Civi\Api4\Utils\CoreUtil;
+
+/**
+ * Search Metadata utilities
+ * @package Civi\Search
+ */
+class Meta {
+
+ /**
+ * Get calculated fields used by a saved search
+ *
+ * @param string $apiEntity
+ * @param array $apiParams
+ * @return array
+ */
+ public static function getCalcFields($apiEntity, $apiParams): array {
+ $calcFields = [];
+ $api = \Civi\API\Request::create($apiEntity, 'get', $apiParams);
+ $selectQuery = new \Civi\Api4\Query\Api4SelectQuery($api);
+ $joinMap = $joinCount = [];
+ foreach ($apiParams['join'] ?? [] as $join) {
+ [$entityName, $alias] = explode(' AS ', $join[0]);
+ $num = '';
+ if (!empty($joinCount[$entityName])) {
+ $num = ' ' . (++$joinCount[$entityName]);
+ }
+ else {
+ $joinCount[$entityName] = 1;
+ }
+ $label = CoreUtil::getInfoItem($entityName, 'title');
+ $joinMap[$alias] = $label . $num;
+ }
+
+ $dataTypeToInputType = [
+ 'Integer' => 'Number',
+ 'Date' => 'Date',
+ 'Timestamp' => 'Date',
+ 'Boolean' => 'CheckBox',
+ ];
+
+ foreach ($apiParams['select'] ?? [] as $select) {
+ if (strstr($select, ' AS ')) {
+ $expr = SqlExpression::convert($select, TRUE);
+ $label = $expr::getTitle();
+ foreach ($expr->getFields() as $num => $fieldName) {
+ $field = $selectQuery->getField($fieldName);
+ $joinName = explode('.', $fieldName)[0];
+ $label .= ($num ? ', ' : ': ') . (isset($joinMap[$joinName]) ? $joinMap[$joinName] . ' ' : '') . $field['title'];
+ }
+ if ($expr::getDataType()) {
+ $dataType = $expr::getDataType();
+ $inputType = $dataTypeToInputType[$dataType] ?? 'Text';
+ }
+ else {
+ $dataType = $field['data_type'] ?? 'String';
+ $inputType = $field['input_type'] ?? $dataTypeToInputType[$dataType] ?? 'Text';
+ }
+
+ $calcFields[] = [
+ 'name' => $expr->getAlias(),
+ 'label' => $label,
+ 'input_type' => $inputType,
+ 'data_type' => $dataType,
+ ];
+ }
+ }
+ return $calcFields;
+ }
+
+}
'id',
'display_name',
'GROUP_CONCAT(DISTINCT Contact_Email_contact_id_01.email) AS GROUP_CONCAT_Contact_Email_contact_id_01_email',
+ 'YEAR(birth_date) AS YEAR_birth_date',
],
'orderBy' => [],
'where' => [],
'dataType' => 'String',
'type' => 'field',
],
+ [
+ 'key' => 'YEAR_birth_date',
+ 'label' => 'Contact ID',
+ 'dataType' => 'Integer',
+ 'type' => 'field',
+ ],
],
],
'acl_bypass' => FALSE,
->addValue('first_name', 'tester')
->addValue('last_name', 'AfformTest')
->addValue('source', 'afform_test')
+ ->addValue('birth_date', '2020-01-01')
->addChain('emails', Email::save()
->addDefault('contact_id', '$id')
->addRecord(['email' => $email, 'location_type_id:name' => 'Home'])
->addValue('first_name', 'tester2')
->addValue('last_name', 'AfformTest')
->addValue('source', 'afform_test2')
+ ->addValue('birth_date', '2010-01-01')
->addChain('emails', Email::save()
->addDefault('contact_id', '$id')
->addRecord(['email' => 'other@test.com', 'location_type_id:name' => 'Other'])
$params['filters'] = ['Contact_Email_contact_id_01.email' => $email];
$result = civicrm_api4('SearchDisplay', 'run', $params);
$this->assertCount(1, $result);
+
+ // Filter by YEAR(birth_date)
+ $params['filters'] = [
+ 'YEAR_birth_date' => ['>=' => 2019],
+ ];
+ $result = civicrm_api4('SearchDisplay', 'run', $params);
+ $this->assertCount(1, $result);
}
public function testRunMultipleSearchForm() {