From 2aaaa86bb041d6214c17a0d2a85f9f28838b2904 Mon Sep 17 00:00:00 2001 From: Coleman Watts 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 0000000000..6afc71a2ed --- /dev/null +++ b/Civi/Api4/Event/Subscriber/AutocompleteFieldSubscriber.php @@ -0,0 +1,74 @@ + ['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 97e7402705..d06de24e8a 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: ' ['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 4de910d9ff..8b5468d893 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 c7edf4850a..763ad10511 100644 --- a/ext/afform/core/ang/af/fields/Select.html +++ b/ext/afform/core/ang/af/fields/Select.html @@ -1,5 +1,6 @@
- + +
diff --git a/ext/search_kit/ang/crmSearchTasks/crmSearchInput/crmSearchInputVal.component.js b/ext/search_kit/ang/crmSearchTasks/crmSearchInput/crmSearchInputVal.component.js index 5cc68b77d2..9419d338d3 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 eabd9d72b9..0574685b8e 100644 --- a/ext/search_kit/ang/crmSearchTasks/crmSearchInput/entityRef.html +++ b/ext/search_kit/ang/crmSearchTasks/crmSearchInput/entityRef.html @@ -1,6 +1,10 @@ -
- -
-
- -
+ diff --git a/js/Common.js b/js/Common.js index 2b13cd552a..dcabd0af5c 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 = ''; + 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 = ''; - 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(); -- 2.25.1