From 2aaaa86bb041d6214c17a0d2a85f9f28838b2904 Mon Sep 17 00:00:00 2001 From: Coleman Watts <coleman@civicrm.org> Date: Sun, 13 Nov 2022 20:32:46 -0500 Subject: [PATCH] SearchKit, Afform - Use APIv4-based Autocomplete widget throughout --- .../AutocompleteFieldSubscriber.php | 74 ++++++++++ ang/crmUi.js | 70 ++++++++-- .../afGuiEditor/afGuiFieldValue.directive.js | 6 +- .../AfformAutocompleteSubscriber.php | 14 +- ext/afform/core/ang/af/afField.component.js | 7 - ext/afform/core/ang/af/fields/Select.html | 3 +- .../crmSearchInputVal.component.js | 14 +- .../crmSearchInput/entityRef.html | 16 ++- js/Common.js | 130 +++++++++++------- 9 files changed, 242 insertions(+), 92 deletions(-) create mode 100644 Civi/Api4/Event/Subscriber/AutocompleteFieldSubscriber.php diff --git a/Civi/Api4/Event/Subscriber/AutocompleteFieldSubscriber.php b/Civi/Api4/Event/Subscriber/AutocompleteFieldSubscriber.php new file mode 100644 index 00000000000..6afc71a2eda --- /dev/null +++ b/Civi/Api4/Event/Subscriber/AutocompleteFieldSubscriber.php @@ -0,0 +1,74 @@ +<?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\Api4\Event\Subscriber; + +use Civi\Core\Service\AutoService; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Preprocess api autocomplete requests + * @service + * @internal + */ +class AutocompleteFieldSubscriber extends AutoService implements EventSubscriberInterface { + + /** + * @return array + */ + public static function getSubscribedEvents() { + return [ + 'civi.api.prepare' => ['onApiPrepare', -50], + ]; + } + + /** + * Apply any filters set in the schema for autocomplete fields + * + * In order for this to work, the `$fieldName` param needs to be in + * the format `EntityName.field_name`. Anything not in that format + * will be ignored, with the expectation that any extension making up + * its own notation for identifying fields (e.g. Afform) can implement + * its own `PrepareEvent` handler to do filtering. If their callback + * runs earlier than this one, it can optionally `setFieldName` to the + * standard recognized here to get the benefit of both custom filters + * and the ones from the schema. + * @see \Civi\Api4\Subscriber\AfformAutocompleteSubscriber::processAfformAutocomplete + * + * @param \Civi\API\Event\PrepareEvent $event + */ + public function onApiPrepare(\Civi\API\Event\PrepareEvent $event): void { + $apiRequest = $event->getApiRequest(); + if (is_object($apiRequest) && is_a($apiRequest, 'Civi\Api4\Generic\AutocompleteAction')) { + [$entityName, $fieldName] = array_pad(explode('.', (string) $apiRequest->getFieldName(), 2), 2, ''); + + if (!$fieldName) { + return; + } + try { + $fieldSpec = civicrm_api4($entityName, 'getFields', [ + 'checkPermissions' => FALSE, + 'where' => [['name', '=', $fieldName]], + ])->single(); + + // Auto-add filters defined in schema + foreach ($fieldSpec['input_attrs']['filter'] ?? [] as $key => $value) { + $apiRequest->addFilter($key, $value); + } + + } + catch (\Exception $e) { + // Ignore anything else. Extension using their own $fieldName notation can do their own handling. + } + } + } + +} diff --git a/ang/crmUi.js b/ang/crmUi.js index 97e74027053..d06de24e8a6 100644 --- a/ang/crmUi.js +++ b/ang/crmUi.js @@ -716,28 +716,72 @@ .directive('crmAutocomplete', function () { return { require: { + crmAutocomplete: 'crmAutocomplete', ngModel: '?ngModel' }, + priority: 100, bindToController: { - crmAutocomplete: '<', + entity: '<crmAutocomplete', crmAutocompleteParams: '<', multi: '<', - autoOpen: '<' + autoOpen: '<', + staticOptions: '<' + }, + link: function(scope, element, attr, ctrl) { + // Copied from ng-list but applied conditionally if field is multi-valued + var parseList = function(viewValue) { + // If the viewValue is invalid (say required but empty) it will be `undefined` + if (_.isUndefined(viewValue)) return; + + if (!ctrl.crmAutocomplete.multi) { + return viewValue; + } + + var list = []; + + if (viewValue) { + _.each(viewValue.split(','), function(value) { + if (value) { + list.push(_.trim(value)); + } + }); + } + + return list; + }; + + if (ctrl.ngModel) { + // Ensure widget is updated when model changes + ctrl.ngModel.$render = function() { + element.val(ctrl.ngModel.$viewValue || '').change(); + }; + + // Copied from ng-list + ctrl.ngModel.$parsers.push(parseList); + ctrl.ngModel.$formatters.push(function(value) { + return _.isArray(value) ? value.join(',') : value; + }); + + // Copied from ng-list + ctrl.ngModel.$isEmpty = function(value) { + return !value || !value.length; + }; + } }, controller: function($element, $timeout) { var ctrl = this; - $timeout(function() { - $element.crmAutocomplete(ctrl.crmAutocomplete, ctrl.crmAutocompleteParams, { - multiple: ctrl.multi, - minimumInputLength: ctrl.autoOpen ? 0 : 1 + + // Intitialize widget, and re-render it every time params change + this.$onChanges = function() { + // Timeout is to wait for `placeholder="{{ ts(...) }}"` to be resolved + $timeout(function() { + $element.crmAutocomplete(ctrl.entity, ctrl.crmAutocompleteParams, { + multiple: ctrl.multi, + minimumInputLength: ctrl.autoOpen && !ctrl.staticOptions ? 0 : 1, + static: ctrl.staticOptions || [], + }); }); - // Ensure widget is updated when model changes - if (ctrl.ngModel) { - ctrl.ngModel.$render = function() { - $element.val(ctrl.ngModel.$viewValue || '').change(); - }; - } - }); + }; } }; }) diff --git a/ext/afform/admin/ang/afGuiEditor/afGuiFieldValue.directive.js b/ext/afform/admin/ang/afGuiEditor/afGuiFieldValue.directive.js index ba3a108592d..fae80a96897 100644 --- a/ext/afform/admin/ang/afGuiEditor/afGuiFieldValue.directive.js +++ b/ext/afform/admin/ang/afGuiEditor/afGuiFieldValue.directive.js @@ -38,7 +38,11 @@ _.each(ctrl.editor.getEntities({type: field.fk_entity}), function(entity) { options.push({id: entity.name, label: entity.label, icon: afGui.meta.entities[entity.type].icon}); }); - $el.crmEntityRef({entity: field.fk_entity, select: {multiple: multi}, static: options}); + $el.crmAutocomplete(field.fk_entity, {fieldName: field.entity + '.' + field.name}, { + multiple: multi, + "static": options, + minimumInputLength: options.length ? 1 : 0 + }); } else if (field.options) { options = _.transform(field.options, function(options, val) { options.push({id: val.id, text: val.label}); diff --git a/ext/afform/core/Civi/Api4/Subscriber/AfformAutocompleteSubscriber.php b/ext/afform/core/Civi/Api4/Subscriber/AfformAutocompleteSubscriber.php index 222ff8ebb34..c94d392e501 100644 --- a/ext/afform/core/Civi/Api4/Subscriber/AfformAutocompleteSubscriber.php +++ b/ext/afform/core/Civi/Api4/Subscriber/AfformAutocompleteSubscriber.php @@ -13,7 +13,6 @@ namespace Civi\Api4\Subscriber; use Civi\Core\Service\AutoService; use Civi\Afform\FormDataModel; -use Civi\API\Events; use Civi\Api4\Afform; use Civi\Api4\Generic\AutocompleteAction; use Civi\Api4\Utils\CoreUtil; @@ -31,7 +30,7 @@ class AfformAutocompleteSubscriber extends AutoService implements EventSubscribe */ public static function getSubscribedEvents() { return [ - 'civi.api.prepare' => ['onApiPrepare', Events::W_MIDDLE], + 'civi.api.prepare' => ['onApiPrepare', -20], ]; } @@ -90,15 +89,10 @@ class AfformAutocompleteSubscriber extends AutoService implements EventSubscribe $isId = $fieldName === CoreUtil::getIdFieldName($apiEntity); $formField = $entity['fields'][$fieldName]['defn'] ?? []; } - $fieldSpec = civicrm_api4($apiEntity, 'getFields', [ - 'checkPermissions' => FALSE, - 'where' => [['name', '=', $fieldName]], - ])->first(); - // Auto-add filters defined in schema - foreach ($fieldSpec['input_attrs']['filter'] ?? [] as $key => $value) { - $apiRequest->addFilter($key, $value); - } + // Set standard fieldName so core AutocompleteFieldSubscriber can handle filters from the schema + // @see \Civi\Api4\Event\Subscriber\AutocompleteFieldSubscriber::onApiPrepare + $apiRequest->setFieldName("$apiEntity.$fieldName"); // For the "Existing Entity" selector, // Look up the "type" fields (e.g. contact_type, activity_type_id, case_type_id, etc) diff --git a/ext/afform/core/ang/af/afField.component.js b/ext/afform/core/ang/af/afField.component.js index 4de910d9ff2..8b5468d8934 100644 --- a/ext/afform/core/ang/af/afField.component.js +++ b/ext/afform/core/ang/af/afField.component.js @@ -236,16 +236,9 @@ else if (ctrl.defn.search_range) { return ($scope.dataProvider.getFieldData()[ctrl.fieldName]['>='] = val); } - // A multi-select needs to split string value into an array - if (ctrl.defn.input_attrs && ctrl.defn.input_attrs.multiple) { - val = val ? val.split(',') : []; - } return ($scope.dataProvider.getFieldData()[ctrl.fieldName] = val); } // Getter - if (_.isArray(currentVal)) { - return currentVal.join(','); - } if (ctrl.defn.is_date) { return _.isPlainObject(currentVal) ? '{}' : currentVal; } diff --git a/ext/afform/core/ang/af/fields/Select.html b/ext/afform/core/ang/af/fields/Select.html index c7edf4850a4..763ad105114 100644 --- a/ext/afform/core/ang/af/fields/Select.html +++ b/ext/afform/core/ang/af/fields/Select.html @@ -1,5 +1,6 @@ <div class="{{:: $ctrl.defn.search_range ? 'form-inline' : 'form-group' }}"> - <input class="form-control" id="{{:: fieldId }}" crm-ui-select="{data: select2Options, multiple: $ctrl.defn.input_attrs.multiple, placeholder: $ctrl.defn.input_attrs.placeholder}" ng-model="getSetSelect" ng-model-options="{getterSetter: true}" > + <input class="form-control" id="{{:: fieldId }}" ng-if="!$ctrl.defn.input_attrs.multiple" crm-ui-select="{data: select2Options, placeholder: $ctrl.defn.input_attrs.placeholder}" ng-model="getSetSelect" ng-model-options="{getterSetter: true}" > + <input class="form-control" id="{{:: fieldId }}" ng-if="$ctrl.defn.input_attrs.multiple" ng-list crm-ui-select="{data: select2Options, multiple: true, placeholder: $ctrl.defn.input_attrs.placeholder}" ng-model="getSetSelect" ng-model-options="{getterSetter: true}" > <input class="form-control" ng-if=":: $ctrl.defn.search_range && !$ctrl.defn.is_date" id="{{:: fieldId }}2" crm-ui-select="{data: select2Options, placeholder: $ctrl.defn.input_attrs.placeholder2 || $ctrl.defn.input_attrs.placeholder}" ng-model="dataProvider.getFieldData()[$ctrl.fieldName]['<=']" > <div ng-if="$ctrl.defn.search_range && $ctrl.defn.is_date && getSetSelect() === '{}'" class="form-group" ng-include="'~/af/fields/Date.html'"></div> </div> diff --git a/ext/search_kit/ang/crmSearchTasks/crmSearchInput/crmSearchInputVal.component.js b/ext/search_kit/ang/crmSearchTasks/crmSearchInput/crmSearchInputVal.component.js index 5cc68b77d20..9419d338d3d 100644 --- a/ext/search_kit/ang/crmSearchTasks/crmSearchInput/crmSearchInputVal.component.js +++ b/ext/search_kit/ang/crmSearchTasks/crmSearchInput/crmSearchInputVal.component.js @@ -17,7 +17,6 @@ var rendered = false, field = this.field || {}; ctrl.dateRanges = CRM.crmSearchTasks.dateRanges; - ctrl.entity = field.fk_entity || field.entity; this.ngModel.$render = function() { ctrl.value = ctrl.ngModel.$viewValue; @@ -46,6 +45,19 @@ } }; + this.getFkEntity = function() { + return ctrl.field ? ctrl.field.fk_entity || ctrl.field.entity : null; + }; + + var autocompleteStaticOptions = { + Contact: ['user_contact_id'], + '': [] + }; + + this.getAutocompleteStaticOptions = function() { + return autocompleteStaticOptions[ctrl.getFkEntity() || ''] || autocompleteStaticOptions['']; + }; + this.isMulti = function() { // If there's a search operator, return `true` if the operator takes multiple values, else `false` if (ctrl.op) { diff --git a/ext/search_kit/ang/crmSearchTasks/crmSearchInput/entityRef.html b/ext/search_kit/ang/crmSearchTasks/crmSearchInput/entityRef.html index eabd9d72b97..0574685b8ec 100644 --- a/ext/search_kit/ang/crmSearchTasks/crmSearchInput/entityRef.html +++ b/ext/search_kit/ang/crmSearchTasks/crmSearchInput/entityRef.html @@ -1,6 +1,10 @@ -<div class="form-group" ng-if="!$ctrl.isMulti()"> - <input class="form-control" ng-model="$ctrl.value" crm-entityref="{entity: $ctrl.entity, select:{allowClear: true, placeholder: ts('Select')}, static: $ctrl.entity === 'Contact' ? ['user_contact_id'] : []}"> -</div> -<div class="form-group" ng-if="$ctrl.isMulti()"> - <input class="form-control" ng-model="$ctrl.value" crm-entityref="{entity: $ctrl.entity, select: {multiple: true}, static: $ctrl.entity === 'Contact' ? ['user_contact_id'] : []}" ng-list > -</div> +<input + class="form-control" + ng-model="$ctrl.value" + crm-autocomplete="$ctrl.getFkEntity()" + crm-autocomplete-params="{fieldName: $ctrl.field.entity + '.' + $ctrl.field.name}" + auto-open="true" + multi="$ctrl.isMulti()" + static-options="$ctrl.getAutocompleteStaticOptions()" + placeholder="{{:: ts('Select') }}" +> diff --git a/js/Common.js b/js/Common.js index 2b13cd552a0..dcabd0af5cb 100644 --- a/js/Common.js +++ b/js/Common.js @@ -478,6 +478,7 @@ if (!CRM.vars) CRM.vars = {}; } $el + .off('.crmSelect2') .on('select2-loaded.crmSelect2', function() { // Use description as title for each option $('.crm-select2-row-description', '#select2-drop').each(function() { @@ -523,11 +524,43 @@ if (!CRM.vars) CRM.vars = {}; }); }; + function getStaticOptions(staticItems) { + var staticPresets = { + user_contact_id: { + id: 'user_contact_id', + label: ts('Select Current User'), + icon: 'fa-user-circle-o' + } + }; + + return _.transform(staticItems || [], function(staticItems, option) { + staticItems.push(_.isString(option) ? staticPresets[option] : option); + }); + } + + function getStaticOptionMarkup(staticItems) { + if (!staticItems.length) { + return ''; + } + var markup = '<div class="crm-entityref-links crm-entityref-links-static">'; + _.each(staticItems, function(link) { + markup += ' <a class="crm-hover-button" href="#' + link.id + '">' + + '<i class="crm-i ' + link.icon + '" aria-hidden="true"></i> ' + + _.escape(link.label) + '</a>'; + }); + markup += '</div>'; + return markup; + } + // Autocomplete based on APIv4 and Select2. $.fn.crmAutocomplete = function(entityName, apiParams, select2Options) { select2Options = select2Options || {}; return $(this).each(function() { - $(this).crmSelect2(_.extend({ + var $el = $(this).off('.crmEntity'), + staticItems = getStaticOptions(select2Options.static), + multiple = !!select2Options.multiple; + + $el.crmSelect2(_.extend({ ajax: { quietMillis: 250, url: CRM.url('civicrm/ajax/api4/' + entityName + '/autocomplete'), @@ -549,18 +582,51 @@ if (!CRM.vars) CRM.vars = {}; formatSelection: formatEntityRefSelection, escapeMarkup: _.identity, initSelection: function($el, callback) { - var - multiple = !!select2Options.multiple, - val = $el.val(); + var val = $el.val(); if (val === '') { return; } - var params = $.extend({}, apiParams || {}, {ids: val.split(',')}); - CRM.api4(entityName, 'autocomplete', params).then(function(result) { - callback(multiple ? result : result[0]); - }); + var idsNeeded = _.difference(val.split(','), _.pluck(staticItems, 'id')), + existing = _.filter(staticItems, function(item) { + return _.includes(val.split(','), item.id); + }); + // If we already have the data, just return it + if (!idsNeeded.length) { + callback(multiple ? existing : existing[0]); + } else { + var params = $.extend({}, apiParams || {}, {ids: idsNeeded}); + CRM.api4(entityName, 'autocomplete', params).then(function (result) { + callback(multiple ? result.concat(existing) : result[0]); + }); + } + }, + formatInputTooShort: function() { + var txt = $.fn.select2.defaults.formatInputTooShort.call(this); + txt += getStaticOptionMarkup(staticItems); + return txt; } }, select2Options)); + + $el.on('select2-open.crmEntity', function() { + var $el = $(this); + $('#select2-drop') + .off('.crmEntity') + .on('click.crmEntity', '.crm-entityref-links-static a', function(e) { + var id = $(this).attr('href').substr(1), + item = _.findWhere(staticItems, {id: id}); + $el.select2('close'); + if (multiple) { + var selection = $el.select2('data'); + if (!_.findWhere(selection, {id: id})) { + selection.push(item); + $el.select2('data', selection, true); + } + } else { + $el.select2('data', item, true); + } + return false; + }); + }); }); }; @@ -584,14 +650,7 @@ if (!CRM.vars) CRM.vars = {}; var $el = $(this).off('.crmEntity'), entity = options.entity || $el.data('api-entity') || 'Contact', - selectParams = {}, - staticPresets = { - user_contact_id: { - id: 'user_contact_id', - label: ts('Select Current User'), - icon: 'fa-user-circle-o' - } - }; + selectParams = {}; // Legacy: fix entity name if passed in as snake case if (entity.charAt(0).toUpperCase() !== entity.charAt(0)) { entity = _.capitalize(_.camelCase(entity)); @@ -600,26 +659,6 @@ if (!CRM.vars) CRM.vars = {}; $el.data('select-params', $.extend({}, $el.data('select-params') || {}, options.select)); $el.data('api-params', $.extend(true, {}, $el.data('api-params') || {}, options.api)); $el.data('create-links', options.create || $el.data('create-links')); - var staticItems = options.static || $el.data('static') || []; - _.each(staticItems, function(option, i) { - if (_.isString(option)) { - staticItems[i] = staticPresets[option]; - } - }); - - function staticItemMarkup() { - if (!staticItems.length) { - return ''; - } - var markup = '<div class="crm-entityref-links crm-entityref-links-static">'; - _.each(staticItems, function(link) { - markup += ' <a class="crm-hover-button" href="#' + link.id + '">' + - '<i class="crm-i ' + link.icon + '" aria-hidden="true"></i> ' + - _.escape(link.label) + '</a>'; - }); - markup += '</div>'; - return markup; - } $el.addClass('crm-form-entityref crm-' + _.kebabCase(entity) + '-ref'); var settings = { @@ -649,7 +688,7 @@ if (!CRM.vars) CRM.vars = {}; var multiple = !!$el.data('select-params').multiple, val = $el.val(), - stored = ($el.data('entity-value') || []).concat(staticItems); + stored = $el.data('entity-value') || []; if (val === '') { return; } @@ -704,7 +743,7 @@ if (!CRM.vars) CRM.vars = {}; else { selectParams.formatInputTooShort = function() { var txt = $el.data('select-params').formatInputTooShort || $.fn.select2.defaults.formatInputTooShort.call(this); - txt += entityRefFiltersMarkup($el) + staticItemMarkup() + renderEntityRefCreateLinks($el); + txt += entityRefFiltersMarkup($el) + renderEntityRefCreateLinks($el); return txt; }; selectParams.formatNoMatches = function() { @@ -739,21 +778,6 @@ if (!CRM.vars) CRM.vars = {}; }); return false; }) - .on('click.crmEntity', '.crm-entityref-links-static a', function(e) { - var id = $(this).attr('href').substr(1), - item = _.findWhere(staticItems, {id: id}); - $el.select2('close'); - if ($el.select2('container').hasClass('select2-container-multi')) { - var selection = $el.select2('data'); - if (!_.findWhere(selection, {id: id})) { - selection.push(item); - $el.select2('data', selection, true); - } - } else { - $el.select2('data', item, true); - } - return false; - }) .on('change.crmEntity', '.crm-entityref-filter-value', function() { var filter = $el.data('user-filter') || {}; filter.value = $(this).val(); -- GitLab