From 725c21311c5537de0114036b32c92850576a7e3e Mon Sep 17 00:00:00 2001 From: Coleman Watts Date: Fri, 2 Sep 2022 10:37:39 -0400 Subject: [PATCH] SearchKit - Better support for calculated fields as Afform filters 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. --- Civi/Api4/Service/Spec/SpecFormatter.php | 4 + .../Civi/Api4/Action/Afform/LoadAdminData.php | 51 +------ .../ang/afGuiEditor/afGuiSearch.component.js | 39 ++--- .../admin/ang/afGuiEditor/afGuiSearch.html | 2 +- .../elements/afGuiContainer.component.js | 2 +- .../elements/afGuiField.component.js | 5 + .../Civi/Afform/AfformMetadataInjector.php | 134 +++++++++++------- .../ang/testContactEmailSearchForm.aff.html | 1 + .../Search/AfformSearchMetadataInjector.php | 8 +- ext/search_kit/Civi/Search/Meta.php | 85 +++++++++++ .../api/v4/SearchDisplay/SearchAfformTest.php | 16 +++ 11 files changed, 224 insertions(+), 123 deletions(-) create mode 100644 ext/search_kit/Civi/Search/Meta.php diff --git a/Civi/Api4/Service/Spec/SpecFormatter.php b/Civi/Api4/Service/Spec/SpecFormatter.php index 81ac207389..0697a64b3e 100644 --- a/Civi/Api4/Service/Spec/SpecFormatter.php +++ b/Civi/Api4/Service/Spec/SpecFormatter.php @@ -287,6 +287,10 @@ class SpecFormatter { 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); diff --git a/ext/afform/admin/Civi/Api4/Action/Afform/LoadAdminData.php b/ext/afform/admin/Civi/Api4/Action/Afform/LoadAdminData.php index 5c61ace1a5..9ee74c6585 100644 --- a/ext/afform/admin/Civi/Api4/Action/Afform/LoadAdminData.php +++ b/ext/afform/admin/Civi/Api4/Action/Afform/LoadAdminData.php @@ -5,7 +5,6 @@ namespace Civi\Api4\Action\Afform; 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. @@ -186,7 +185,10 @@ class LoadAdminData extends \Civi\Api4\Generic\AbstractAction { $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) { @@ -264,51 +266,6 @@ class LoadAdminData extends \Civi\Api4\Generic\AbstractAction { } } - /** - * @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[] */ diff --git a/ext/afform/admin/ang/afGuiEditor/afGuiSearch.component.js b/ext/afform/admin/ang/afGuiEditor/afGuiSearch.component.js index 04c006921a..d33e3441e2 100644 --- a/ext/afform/admin/ang/afGuiEditor/afGuiSearch.component.js +++ b/ext/afform/admin/ang/afGuiEditor/afGuiSearch.component.js @@ -14,6 +14,7 @@ $scope.controls = {}; $scope.fieldList = []; $scope.calcFieldList = []; + $scope.calcFieldTitles = []; $scope.blockList = []; $scope.blockTitles = []; $scope.elementList = []; @@ -29,7 +30,7 @@ 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)}); }, []) }); } @@ -69,11 +70,28 @@ 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); } }); } @@ -150,21 +168,6 @@ } }, []); } - - 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) { diff --git a/ext/afform/admin/ang/afGuiEditor/afGuiSearch.html b/ext/afform/admin/ang/afGuiEditor/afGuiSearch.html index 76e42ba6b8..1d1001f888 100644 --- a/ext/afform/admin/ang/afGuiEditor/afGuiSearch.html +++ b/ext/afform/admin/ang/afGuiEditor/afGuiSearch.html @@ -61,7 +61,7 @@
-
{{:: field.defn.label }}
+
{{:: calcFieldTitles[$index] }}
diff --git a/ext/afform/admin/ang/afGuiEditor/elements/afGuiContainer.component.js b/ext/afform/admin/ang/afGuiEditor/elements/afGuiContainer.component.js index ba9d313f95..a61c47e144 100644 --- a/ext/afform/admin/ang/afGuiEditor/elements/afGuiContainer.component.js +++ b/ext/afform/admin/ang/afGuiEditor/elements/afGuiContainer.component.js @@ -424,7 +424,7 @@ } }); } - if (!entityType && fieldKey && afGui.getField(searchDisplay['saved_search_id.api_entity'], fieldKey)) { + if (!entityType) { entityType = searchDisplay['saved_search_id.api_entity']; } } diff --git a/ext/afform/admin/ang/afGuiEditor/elements/afGuiField.component.js b/ext/afform/admin/ang/afGuiEditor/elements/afGuiField.component.js index 8de37bbf5f..882ea3fd1a 100644 --- a/ext/afform/admin/ang/afGuiEditor/elements/afGuiField.component.js +++ b/ext/afform/admin/ang/afGuiEditor/elements/afGuiField.component.js @@ -89,6 +89,11 @@ // 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 diff --git a/ext/afform/core/Civi/Afform/AfformMetadataInjector.php b/ext/afform/core/Civi/Afform/AfformMetadataInjector.php index 88c602bdc1..c2b128c62f 100644 --- a/ext/afform/core/Civi/Afform/AfformMetadataInjector.php +++ b/ext/afform/core/Civi/Afform/AfformMetadataInjector.php @@ -80,77 +80,101 @@ class AfformMetadataInjector { } /** - * 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); } } diff --git a/ext/afform/mock/ang/testContactEmailSearchForm.aff.html b/ext/afform/mock/ang/testContactEmailSearchForm.aff.html index 90de6fee16..f0629148e3 100644 --- a/ext/afform/mock/ang/testContactEmailSearchForm.aff.html +++ b/ext/afform/mock/ang/testContactEmailSearchForm.aff.html @@ -2,6 +2,7 @@
+
diff --git a/ext/search_kit/Civi/Search/AfformSearchMetadataInjector.php b/ext/search_kit/Civi/Search/AfformSearchMetadataInjector.php index 478c491d2e..76c48b77e1 100644 --- a/ext/search_kit/Civi/Search/AfformSearchMetadataInjector.php +++ b/ext/search_kit/Civi/Search/AfformSearchMetadataInjector.php @@ -65,6 +65,13 @@ class AfformSearchMetadataInjector { } } $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); + } + } } } } @@ -72,7 +79,6 @@ class AfformSearchMetadataInjector { } }); $e->angular->add($changeSet); - } } diff --git a/ext/search_kit/Civi/Search/Meta.php b/ext/search_kit/Civi/Search/Meta.php new file mode 100644 index 0000000000..826fb23489 --- /dev/null +++ b/ext/search_kit/Civi/Search/Meta.php @@ -0,0 +1,85 @@ + '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; + } + +} diff --git a/ext/search_kit/tests/phpunit/api/v4/SearchDisplay/SearchAfformTest.php b/ext/search_kit/tests/phpunit/api/v4/SearchDisplay/SearchAfformTest.php index 15d5425f39..377cb8d7b9 100644 --- a/ext/search_kit/tests/phpunit/api/v4/SearchDisplay/SearchAfformTest.php +++ b/ext/search_kit/tests/phpunit/api/v4/SearchDisplay/SearchAfformTest.php @@ -45,6 +45,7 @@ class SearchAfformTest extends \PHPUnit\Framework\TestCase implements HeadlessIn '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' => [], @@ -89,6 +90,12 @@ class SearchAfformTest extends \PHPUnit\Framework\TestCase implements HeadlessIn 'dataType' => 'String', 'type' => 'field', ], + [ + 'key' => 'YEAR_birth_date', + 'label' => 'Contact ID', + 'dataType' => 'Integer', + 'type' => 'field', + ], ], ], 'acl_bypass' => FALSE, @@ -101,6 +108,7 @@ class SearchAfformTest extends \PHPUnit\Framework\TestCase implements HeadlessIn ->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']) @@ -112,6 +120,7 @@ class SearchAfformTest extends \PHPUnit\Framework\TestCase implements HeadlessIn ->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']) @@ -162,6 +171,13 @@ class SearchAfformTest extends \PHPUnit\Framework\TestCase implements HeadlessIn $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() { -- 2.25.1