From b7ceb253b5500621cb015d30169dc5a96af981c3 Mon Sep 17 00:00:00 2001 From: Coleman Watts Date: Thu, 4 Dec 2014 15:15:22 -0500 Subject: [PATCH] CRM-15495 - Add filtering control to entityRef widget --- CRM/Core/Resources.php | 39 ++- CRM/Utils/Array.php | 17 ++ api/v3/Generic.php | 9 +- css/civicrm.css | 20 ++ js/Common.js | 258 +++++++++++++++----- templates/CRM/Contact/Form/Relationship.tpl | 3 +- templates/CRM/common/l10n.js.tpl | 6 +- 7 files changed, 277 insertions(+), 75 deletions(-) diff --git a/CRM/Core/Resources.php b/CRM/Core/Resources.php index d1426b1de2..54cfc95b4f 100644 --- a/CRM/Core/Resources.php +++ b/CRM/Core/Resources.php @@ -509,7 +509,7 @@ class CRM_Core_Resources { )); // Disable profile creation if user lacks permission if (!CRM_Core_Permission::check('edit all contacts') && !CRM_Core_Permission::check('add contacts')) { - $settings['profileCreate'] = FALSE; + $settings['config']['entityRef']['contactCreate'] = FALSE; } $this->addSetting($settings); @@ -593,7 +593,10 @@ class CRM_Core_Resources { 'moneyFormat' => json_encode(CRM_Utils_Money::format(1234.56)), 'contactSearch' => json_encode($config->includeEmailInName ? ts('Start typing a name or email...') : ts('Start typing a name...')), 'otherSearch' => json_encode(ts('Enter search term...')), - 'contactCreate' => CRM_Core_BAO_UFGroup::getCreateLinks(), + 'entityRef' => array( + 'contactCreate' => CRM_Core_BAO_UFGroup::getCreateLinks(), + 'filters' => self::getEntityRefFilters(), + ), ); print CRM_Core_Smarty::singleton()->fetchWith('CRM/common/l10n.js.tpl', $vars); CRM_Utils_System::civiExit(); @@ -689,4 +692,36 @@ class CRM_Core_Resources { static function isAjaxMode() { return in_array(CRM_Utils_Array::value('snippet', $_REQUEST), array(CRM_Core_Smarty::PRINT_SNIPPET, CRM_Core_Smarty::PRINT_NOFORM, CRM_Core_Smarty::PRINT_JSON)); } + + /** + * Provide a list of available entityRef filters + * FIXME: This function doesn't really belong in this class + * @TODO: Provide a sane way to extend this list for other entities - a hook or?? + * @return array + */ + static function getEntityRefFilters() { + $filters = array(); + + $filters['event'] = array( + array('key' => 'event_type_id', 'value' => ts('Event Type')), + ); + + $filters['activity'] = array( + array('key' => 'activity_type_id', 'value' => ts('Activity Type')), + array('key' => 'status_id', 'value' => ts('Activity Status')), + ); + + $contactTypes = CRM_Utils_Array::makeNonAssociative(CRM_Contact_BAO_ContactType::getSelectElements(FALSE, TRUE, '.')); + $filters['contact'] = array( + array('key' => 'contact_type', 'value' => ts('Contact Type'), 'options' => $contactTypes), + array('key' => 'group', 'value' => ts('Group'), 'entity' => 'group_contact'), + array('key' => 'tag', 'value' => ts('Tag'), 'entity' => 'entity_tag'), + array('key' => 'state_province', 'value' => ts('State/Province'), 'entity' => 'address'), + array('key' => 'country', 'value' => ts('Country'), 'entity' => 'address'), + array('key' => 'gender_id', 'value' => ts('Gender')), + array('key' => 'is_deceased', 'value' => ts('Deceased')), + ); + + return $filters; + } } diff --git a/CRM/Utils/Array.php b/CRM/Utils/Array.php index b10a5cff86..daffcf7137 100644 --- a/CRM/Utils/Array.php +++ b/CRM/Utils/Array.php @@ -808,5 +808,22 @@ class CRM_Utils_Array { } return NULL; } + + /** + * Transform an associative array of key=>value pairs into a non-associative array of arrays. + * This is necessary to preserve sort order when sending an array through json_encode. + * + * @param array $associative + * @param string $keyName + * @param string $valueName + * @return array + */ + static function makeNonAssociative($associative, $keyName = 'key', $valueName = 'value') { + $output = array(); + foreach ($associative as $key => $val) { + $output[] = array($keyName => $key, $valueName => $val); + } + return $output; + } } diff --git a/api/v3/Generic.php b/api/v3/Generic.php index 8faf54b8e0..2dda583681 100644 --- a/api/v3/Generic.php +++ b/api/v3/Generic.php @@ -274,18 +274,15 @@ function civicrm_api3_generic_getoptions($apiRequest) { unset($apiRequest['params']['context'], $apiRequest['params']['field']); $baoName = _civicrm_api3_get_BAO($apiRequest['entity']); - $options = $output = $baoName::buildOptions($fieldName, $context, $apiRequest['params']); + $options = $baoName::buildOptions($fieldName, $context, $apiRequest['params']); if ($options === FALSE) { return civicrm_api3_create_error("The field '{$fieldName}' has no associated option list."); } // Support 'sequential' output as a non-associative array if (!empty($apiRequest['params']['sequential'])) { - $output = array(); - foreach ($options as $key => $val) { - $output[] = array('key' => $key, 'value' => $val); - } + $options = CRM_Utils_Array::makeNonAssociative($options); } - return civicrm_api3_create_success($output, $apiRequest['params'], $apiRequest['entity'], 'getoptions'); + return civicrm_api3_create_success($options, $apiRequest['params'], $apiRequest['entity'], 'getoptions'); } /** diff --git a/css/civicrm.css b/css/civicrm.css index 742b76ec3c..3de4a2d0c3 100644 --- a/css/civicrm.css +++ b/css/civicrm.css @@ -3702,6 +3702,26 @@ div.m ul#civicrm-menu, .crm-container .select2-dropdown-open .select2-choice .select2-arrow b { background-position: -18px 1px; } +.select2-drop .crm-entityref-links { + border-top: 1px solid #d3d3d3; + margin-top: 9px; +} +.select2-drop .crm-entityref-filters { + margin-top: 4px; +} +.select2-drop .crm-entityref-filters select { + border-radius: 3px; + border: 1px solid #f2f2f2; + background-color: #f6f6f6; + color: #494949; + font-size: 11px; + max-width: 70%; +} +.select2-drop .crm-entityref-filters select:hover, +.select2-drop .crm-entityref-filters select:focus, +.select2-drop .crm-entityref-filters select.active { + border: 1px solid #808080; +} /* Style autocomplete results */ .crm-container .select2-results { font-size: 12px; diff --git a/js/Common.js b/js/Common.js index 26ae4cfaf1..67f14d5b41 100644 --- a/js/Common.js +++ b/js/Common.js @@ -207,32 +207,17 @@ CRM.strings = CRM.strings || {}; * Populate a select list, overwriting the existing options except for the placeholder. * @param select jquery selector - 1 or more select elements * @param options array in format returned by api.getoptions - * @param placeholder string + * @param placeholder string|bool - new placeholder or false (default) to keep the old one + * @param value string|array - will silently update the element with new value without triggering change */ - CRM.utils.setOptions = function(select, options, placeholder) { + CRM.utils.setOptions = function(select, options, placeholder, value) { $(select).each(function() { var $elect = $(this), - val = $elect.val() || [], - opts = placeholder || placeholder === '' ? '' : '[value!=""]', - newOptions = '', - theme = function(options) { - _.each(options, function(option) { - if (option.children) { - newOptions += ''; - theme(option.children); - newOptions += ''; - } else { - var selected = ($.inArray('' + option.key, val) > -1) ? 'selected="selected"' : ''; - newOptions += ''; - } - }); - }; - if (!$.isArray(val)) { - val = [val]; - } + val = value || $elect.val() || [], + opts = placeholder || placeholder === '' ? '' : '[value!=""]'; $elect.find('option' + opts).remove(); - theme(options); + var newOptions = CRM.utils.renderOptions(options, val); if (typeof placeholder === 'string') { if ($elect.is('[multiple]')) { select.attr('placeholder', placeholder); @@ -241,10 +226,37 @@ CRM.strings = CRM.strings || {}; } } $elect.append(newOptions); - $elect.trigger('crmOptionsUpdated', $.extend({}, options)).trigger('change'); + if (!value) { + $elect.trigger('crmOptionsUpdated', $.extend({}, options)).trigger('change'); + } }); }; + /** + * Render an option list + * @param options array + * @param val default value + * @internal param rendered + * @return string + */ + CRM.utils.renderOptions = function(options, val) { + var rendered = arguments[2] || ''; + if (!$.isArray(val)) { + val = [val]; + } + _.each(options, function(option) { + if (option.children) { + rendered += '' + + CRM.utils.renderOptions(option.children, val, rendered) + + ''; + } else { + var selected = ($.inArray('' + option.key, val) > -1) ? 'selected="selected"' : ''; + rendered += ''; + } + }); + return rendered; + }; + function chainSelect() { var $form = $(this).closest('form'), $target = $('select[data-name="' + $(this).data('target') + '"]', $form), @@ -327,13 +339,13 @@ CRM.strings = CRM.strings || {}; $el.data('select-params', $.extend({}, $el.data('select-params') || {}, options.select)); $el.data('api-params', $.extend({}, $el.data('api-params') || {}, options.api)); $el.data('create-links', options.create || $el.data('create-links')); - $el.addClass('crm-form-entityref crm-' + entity + '-ref'); + $el.addClass('crm-form-entityref crm-' + entity.toLowerCase() + '-ref'); var settings = { - // Use select2 ajax helper instead of CRM.api because it provides more value + // Use select2 ajax helper instead of CRM.api3 because it provides more value ajax: { url: CRM.url('civicrm/ajax/rest'), data: function (input, page_num) { - var params = $el.data('api-params') || {}; + var params = getEntityRefApiParams($el); params.input = input; params.page_num = page_num; return { @@ -373,50 +385,72 @@ CRM.strings = CRM.strings || {}; } } }; - if ($el.data('create-links') && entity.toLowerCase() === 'contact') { + // Create new items inline - works for tags + if ($el.data('create-links') && entity.toLowerCase() === 'tag') { + selectParams.createSearchChoice = function(term, data) { + if (!_.findKey(data, {label: term})) { + return {id: "0", term: term, label: term + ' (' + ts('new tag') + ')'}; + } + }; + selectParams.tokenSeparators = [',']; + selectParams.createSearchChoicePosition = 'bottom'; + } + else { selectParams.formatInputTooShort = function() { var txt = $el.data('select-params').formatInputTooShort || $.fn.select2.defaults.formatInputTooShort.call(this); - if ($el.data('create-links') && CRM.profileCreate && CRM.profileCreate.length) { - txt += ' ' + ts('or') + '
' + formatSelect2CreateLinks($el); - } + txt += renderEntityRefFilters($el); + txt += renderEntityRefCreateLinks($el); return txt; }; selectParams.formatNoMatches = function() { var txt = $el.data('select-params').formatNoMatches || $.fn.select2.defaults.formatNoMatches; - return txt + (CRM.profileCreate ? ('
' + formatSelect2CreateLinks($el)) : ''); + txt += renderEntityRefFilters($el); + txt += renderEntityRefCreateLinks($el); + return txt; }; $el.on('select2-open.crmEntity', function() { var $el = $(this); - $('#select2-drop').off('.crmEntity').on('click.crmEntity', 'a.crm-add-entity', function(e) { - $el.select2('close'); - CRM.loadForm($(this).attr('href'), { - dialog: {width: 500, height: 'auto'} - }).on('crmFormSuccess', function(e, data) { - if (data.status === 'success' && data.id) { - CRM.status(ts('%1 Created', {1: data.label})); - if ($el.select2('container').hasClass('select2-container-multi')) { - var selection = $el.select2('data'); - selection.push(data); - $el.select2('data', selection, true); - } else { - $el.select2('data', data, true); + loadEntityRefFilterOptions($el); + $('#select2-drop') + .off('.crmEntity') + .on('click.crmEntity', 'a.crm-add-entity', function(e) { + $el.select2('close'); + CRM.loadForm($(this).attr('href'), { + dialog: {width: 500, height: 'auto'} + }).on('crmFormSuccess', function(e, data) { + if (data.status === 'success' && data.id) { + CRM.status(ts('%1 Created', {1: data.label})); + if ($el.select2('container').hasClass('select2-container-multi')) { + var selection = $el.select2('data'); + selection.push(data); + $el.select2('data', selection, true); + } else { + $el.select2('data', data, true); + } } + }); + return false; + }) + .on('change.crmEntity', 'select.crm-entityref-filter-value', function() { + var filter = $el.data('user-filter') || {}; + filter.value = $(this).val(); + $(this).toggleClass('active', !!filter.value); + $el.data('user-filter', filter); + if (filter.value) { + // Once a filter has been chosen, rerender create links and refocus the search box + $el.select2('close'); + $el.select2('open'); } + }) + .on('change.crmEntity', 'select.crm-entityref-filter-key', function() { + var filter = $el.data('user-filter') || {}; + filter.key = $(this).val(); + $(this).toggleClass('active', !!filter.key); + $el.data('user-filter', filter); + loadEntityRefFilterOptions($el); }); - return false; - }); }); } - // Create new items inline - works for tags - else if ($el.data('create-links')) { - selectParams.createSearchChoice = function(term, data) { - if (!_.findKey(data, {label: term})) { - return {id: "0", term: term, label: term + ' (' + ts('new tag') + ')'}; - } - }; - selectParams.tokenSeparators = [',']; - selectParams.createSearchChoicePosition = 'bottom'; - } $el.crmSelect2($.extend(settings, $el.data('select-params'), selectParams)) .on('select2-selecting.crmEntity', function(e) { if (e.val === "0") { @@ -442,6 +476,29 @@ CRM.strings = CRM.strings || {}; }); }; + /** + * Combine api-params with user-filter + * @param $el + * @returns {*} + */ + function getEntityRefApiParams($el) { + var + params = $.extend({params: {}}, $el.data('api-params') || {}), + // Prevent original data from being modified - $.extend and _.clone don't cut it, they pass nested objects by reference! + combined = _.cloneDeep(params), + filter = $.extend({}, $el.data('user-filter') || {}); + if (filter.key && filter.value) { + // Special case for contact type/sub-type combo + if (filter.key === 'contact_type' && (filter.value.indexOf('.') > 0)) { + combined.params.contact_type = filter.value.split('.')[0]; + combined.params.contact_sub_type = filter.value.split('.')[1]; + } else { + combined.params[filter.key] = filter.value; + } + } + return combined; + } + CRM.utils.formatSelect2Result = function (row) { var markup = '
'; if (row.image !== undefined) { @@ -459,15 +516,17 @@ CRM.strings = CRM.strings || {}; return markup; }; - function formatSelect2CreateLinks($el) { + function renderEntityRefCreateLinks($el) { var createLinks = $el.data('create-links'), - api = $el.data('api-params') || {}, - type = api.params ? api.params.contact_type : null; + params = getEntityRefApiParams($el).params, + markup = ''; + return markup; + } + + function getEntityRefFilters($el) { + var + entity = $el.data('api-entity').toLowerCase(), + filters = $.extend([], CRM.config.entityRef.filters[entity] || []), + filter = $el.data('user-filter') || {}, + params = $.extend({params: {}}, $el.data('api-params') || {}).params, + result = []; + $.each(filters, function() { + if (typeof params[this.key] === 'undefined') { + result.push(this); + } + else if (this.key == 'contact_type' && typeof params.contact_sub_type === 'undefined') { + this.options = _.remove(this.options, function(option) { + return option.key.indexOf(params.contact_type + '.') === 0; + }); + result.push(this); + } + }); + return result; + } + + function renderEntityRefFilters($el) { + var + filters = getEntityRefFilters($el), + filter = $el.data('user-filter') || {}, + filterSpec = filter.key ? _.find(filters, {key: filter.key}) : null; + if (!filters.length) { + return ''; + } + var markup = '
' + + '   ' + + '
'; return markup; } + /** + * Fetch options for a filter (via ajax if necessary) and populate the appropriate select list + * @param $el + */ + function loadEntityRefFilterOptions($el) { + var + filters = getEntityRefFilters($el), + filter = $el.data('user-filter') || {}, + filterSpec = filter.key ? _.find(filters, {key: filter.key}) : null, + $valField = $('.crm-entityref-filter-value', '#select2-drop'); + if (filterSpec) { + $valField.show().val(''); + if (filterSpec.options) { + CRM.utils.setOptions($valField, filterSpec.options, false, filter.value); + } else { + $valField.prop('disabled', true); + CRM.api3(filterSpec.entity || $el.data('api-entity'), 'getoptions', {field: filter.key, sequential: 1}) + .done(function(result) { + var entity = $el.data('api-entity').toLowerCase(), + globalFilterSpec = _.find(CRM.config.entityRef.filters[entity], {key: filter.key}) || {}; + // Store options globally so we don't have to look them up again + globalFilterSpec.options = result.values; + $valField.prop('disabled', false); + CRM.utils.setOptions($valField, result.values); + $valField.val(filter.value || ''); + }); + } + } else { + $valField.hide(); + } + } + /** * Wrapper for jQuery validate initialization function; supplies defaults - * @param options object */ $.fn.crmValidate = function(params) { return $(this).each(function () { @@ -494,7 +628,7 @@ CRM.strings = CRM.strings || {}; }); } }); - } + }; // Initialize widgets $(document) diff --git a/templates/CRM/Contact/Form/Relationship.tpl b/templates/CRM/Contact/Form/Relationship.tpl index 4524998ce0..069b6ba25f 100644 --- a/templates/CRM/Contact/Form/Relationship.tpl +++ b/templates/CRM/Contact/Form/Relationship.tpl @@ -174,12 +174,11 @@ if (contact_sub_type) { api.params.contact_sub_type = contact_sub_type; } - // Todo: pass sub-type to new contact profile otherwise relationship create will fail. Disabling it completely for now. - $contactField.data('create-links', !contact_sub_type); $contactField .val('') .prop('disabled', false) .data('api-params', api) + .data('user-filter', {}) .attr('placeholder', relationshipData[rType]['placeholder_' + target]) .change(); } diff --git a/templates/CRM/common/l10n.js.tpl b/templates/CRM/common/l10n.js.tpl index 1fb21b2d0e..f5ce959f98 100644 --- a/templates/CRM/common/l10n.js.tpl +++ b/templates/CRM/common/l10n.js.tpl @@ -1,4 +1,4 @@ - {* +{* +--------------------------------------------------------------------+ | CiviCRM version 4.5 | +--------------------------------------------------------------------+ @@ -31,8 +31,8 @@ CRM.config.resourceBase = {$config->resourceBase|@json_encode}; CRM.config.lcMessages = {$config->lcMessages|@json_encode}; - // Contact create links - if (CRM.profileCreate !== false) CRM.profileCreate = {$contactCreate|@json_encode}; + // Merge entityRef settings + CRM.config.entityRef = $.extend({ldelim}{rdelim}, {$entityRef|@json_encode}, CRM.config.entityRef || {ldelim}{rdelim}); // Initialize CRM.url and CRM.formatMoney CRM.url({ldelim}back: '{crmURL p="*path*" q="*query*" h=0 fb=1}', front: '{crmURL p="*path*" q="*query*" h=0 fe=1}'{rdelim}); -- 2.25.1