diff --git a/ext/search/CRM/Search/Upgrader.php b/ext/search/CRM/Search/Upgrader.php index 697e5bafbeab..670d3ca54594 100644 --- a/ext/search/CRM/Search/Upgrader.php +++ b/ext/search/CRM/Search/Upgrader.php @@ -74,6 +74,7 @@ public function upgrade_1001() { $key = $newAliases[$column['expr']] ?? $column['expr']; unset($display['settings']['columns'][$c]['expr']); $display['settings']['columns'][$c]['key'] = explode(' AS ', $key)[1] ?? $key; + $display['settings']['columns'][$c]['type'] = 'field'; } \Civi\Api4\SearchDisplay::update(FALSE) ->setValues($display) diff --git a/ext/search/Civi/Search/Admin.php b/ext/search/Civi/Search/Admin.php index 4a58faf12b85..711a8378ca16 100644 --- a/ext/search/Civi/Search/Admin.php +++ b/ext/search/Civi/Search/Admin.php @@ -11,6 +11,8 @@ namespace Civi\Search; +use CRM_Search_ExtensionUtil as E; + /** * Class Admin * @package Civi\Search @@ -28,6 +30,7 @@ public static function getAdminSettings():array { 'operators' => \CRM_Utils_Array::makeNonAssociative(self::getOperators()), 'functions' => \CRM_Api4_Page_Api4Explorer::getSqlFunctions(), 'displayTypes' => Display::getDisplayTypes(['id', 'name', 'label', 'description', 'icon']), + 'styles' => \CRM_Utils_Array::makeNonAssociative(self::getStyles()), 'afformEnabled' => (bool) \CRM_Utils_Array::findAll( \CRM_Extension_System::singleton()->getMapper()->getActiveModuleFiles(), ['fullName' => 'org.civicrm.afform'] @@ -50,15 +53,29 @@ public static function getOperators():array { '<' => '<', '>=' => '≥', '<=' => '≤', - 'CONTAINS' => ts('Contains'), - 'IN' => ts('Is One Of'), - 'NOT IN' => ts('Not One Of'), - 'LIKE' => ts('Is Like'), - 'NOT LIKE' => ts('Not Like'), - 'BETWEEN' => ts('Is Between'), - 'NOT BETWEEN' => ts('Not Between'), - 'IS NULL' => ts('Is Null'), - 'IS NOT NULL' => ts('Not Null'), + 'CONTAINS' => E::ts('Contains'), + 'IN' => E::ts('Is One Of'), + 'NOT IN' => E::ts('Not One Of'), + 'LIKE' => E::ts('Is Like'), + 'NOT LIKE' => E::ts('Not Like'), + 'BETWEEN' => E::ts('Is Between'), + 'NOT BETWEEN' => E::ts('Not Between'), + 'IS NULL' => E::ts('Is Null'), + 'IS NOT NULL' => E::ts('Not Null'), + ]; + } + + /** + * @return string[] + */ + public static function getStyles():array { + return [ + 'default' => E::ts('Default'), + 'primary' => E::ts('Primary'), + 'success' => E::ts('Success'), + 'info' => E::ts('Info'), + 'warning' => E::ts('Warning'), + 'danger' => E::ts('Danger'), ]; } @@ -84,15 +101,15 @@ public static function getSchema() { unset($entity['paths'][$action]); switch ($action) { case 'view': - $title = ts('View %1', [1 => $entity['title']]); + $title = E::ts('View %1', [1 => $entity['title']]); break; case 'update': - $title = ts('Edit %1', [1 => $entity['title']]); + $title = E::ts('Edit %1', [1 => $entity['title']]); break; case 'delete': - $title = ts('Delete %1', [1 => $entity['title']]); + $title = E::ts('Delete %1', [1 => $entity['title']]); break; default: @@ -239,7 +256,7 @@ public static function getJoins(array $allowedEntities) { $alias = $baseEntity['name'] . "_{$bridge}_" . $targetEntityName; $joins[$baseEntity['name']][] = [ 'label' => $baseEntity['title'] . ' ' . $targetsTitle, - 'description' => ts('Multiple %1 per %2', [1 => $targetsTitle, 2 => $baseEntity['title']]), + 'description' => E::ts('Multiple %1 per %2', [1 => $targetsTitle, 2 => $baseEntity['title']]), 'entity' => $targetEntityName, 'conditions' => array_merge( [$bridge], @@ -254,7 +271,7 @@ public static function getJoins(array $allowedEntities) { $alias = $targetEntityName . "_{$bridge}_" . $baseEntity['name']; $joins[$targetEntityName][] = [ 'label' => $targetEntity['title'] . ' ' . $baseEntity['title_plural'], - 'description' => ts('Multiple %1 per %2', [1 => $baseEntity['title_plural'], 2 => $targetEntity['title']]), + 'description' => E::ts('Multiple %1 per %2', [1 => $baseEntity['title_plural'], 2 => $targetEntity['title']]), 'entity' => $baseEntity['name'], 'conditions' => array_merge( [$bridge], diff --git a/ext/search/ang/crmSearchAdmin.module.js b/ext/search/ang/crmSearchAdmin.module.js index 6db8c6ba143f..6ade8b2cd0b8 100644 --- a/ext/search/ang/crmSearchAdmin.module.js +++ b/ext/search/ang/crmSearchAdmin.module.js @@ -81,7 +81,7 @@ $scope.$ctrl = this; }) - .factory('searchMeta', function() { + .factory('searchMeta', function($q) { function getEntity(entityName) { if (entityName) { return _.find(CRM.crmSearchAdmin.schema, {name: entityName}); @@ -227,8 +227,40 @@ } }); }); + }, + pickIcon: function() { + var deferred = $q.defer(); + $('#crm-search-admin-icon-picker').off('change').siblings('.crm-icon-picker-button').click(); + $('#crm-search-admin-icon-picker').on('change', function() { + deferred.resolve($(this).val()); + }); + return deferred.promise; + } + }; + }) + .directive('contenteditable', function() { + return { + require: 'ngModel', + link: function(scope, elm, attrs, ctrl) { + // view -> model + elm.on('blur', function() { + ctrl.$setViewValue(elm.html()); + }); + + // model -> view + ctrl.$render = function() { + elm.html(ctrl.$viewValue); + }; } }; }); + // Shoehorn in a non-angular widget for picking icons + $(function() { + $('#crm-container').append('
'); + CRM.loadScript(CRM.config.resourceBase + 'js/jquery/jquery.crmIconPicker.js').done(function() { + $('#crm-search-admin-icon-picker').crmIconPicker(); + }); + }); + })(angular, CRM.$, CRM._); diff --git a/ext/search/ang/crmSearchAdmin/crmSearchAdminDisplay.component.js b/ext/search/ang/crmSearchAdmin/crmSearchAdminDisplay.component.js index afea8e8b60a9..2e3fd7aebec2 100644 --- a/ext/search/ang/crmSearchAdmin/crmSearchAdminDisplay.component.js +++ b/ext/search/ang/crmSearchAdmin/crmSearchAdminDisplay.component.js @@ -38,13 +38,55 @@ this.preview = this.stale = false; + this.colTypes = { + links: { + label: ts('Links'), + icon: 'fa-link', + defaults: { + links: [] + } + }, + buttons: { + label: ts('Buttons'), + icon: 'fa-square-o', + defaults: { + size: 'btn-sm', + links: [] + } + }, + menu: { + label: ts('Menu'), + icon: 'fa-bars', + defaults: { + text: ts('Actions'), + style: 'default', + size: 'btn-sm', + icon: 'fa-bars', + links: [] + } + }, + }; + this.sortableOptions = { connectWith: '.crm-search-admin-edit-columns', containment: '.crm-search-admin-edit-columns-wrapper' }; + this.styles = CRM.crmSearchAdmin.styles; + + this.addCol = function(type) { + var col = _.cloneDeep(this.colTypes[type].defaults); + col.type = type; + if (this.display.type === 'table') { + col.alignment = 'text-right'; + } + ctrl.display.settings.columns.push(col); + }; + this.removeCol = function(index) { - ctrl.hiddenColumns.push(ctrl.display.settings.columns[index]); + if (ctrl.display.settings.columns[index].type === 'field') { + ctrl.hiddenColumns.push(ctrl.display.settings.columns[index]); + } ctrl.display.settings.columns.splice(index, 1); }; @@ -70,6 +112,13 @@ return searchMeta.getDefaultLabel(expr); }; + this.getColLabel = function(col) { + if (col.type === 'field') { + return ctrl.getFieldLabel(col.key); + } + return ctrl.colTypes[col.type].label; + }; + function fieldToColumn(fieldExpr, defaults) { var info = searchMeta.parseExpr(fieldExpr), values = _.cloneDeep(defaults); @@ -85,6 +134,53 @@ return values; } + this.getLinks = function() { + if (!ctrl.links) { + ctrl.links = buildLinks(); + } + return ctrl.links; + }; + + // Build a list of all possible links to main entity or join entities + function buildLinks() { + // Links to main entity + var links = _.cloneDeep(searchMeta.getEntity(ctrl.savedSearch.api_entity).paths || []); + // Links to explicitly joined entities + _.each(ctrl.savedSearch.api_params.join, function(join) { + var joinName = join[0].split(' AS '), + joinEntity = searchMeta.getEntity(joinName[0]); + _.each(joinEntity.paths, function(path) { + var link = _.cloneDeep(path); + link.path = link.path.replace(/\[/g, '[' + joinName[1] + '.'); + links.push(link); + }); + }); + // Links to implicit joins + _.each(ctrl.savedSearch.api_params.select, function(fieldName) { + if (!_.includes(fieldName, ' AS ')) { + var info = searchMeta.parseExpr(fieldName); + if (info.field && !info.suffix && !info.fn && (info.field.fk_entity || info.field.entity !== info.field.baseEntity)) { + var idField = info.field.fk_entity ? fieldName : fieldName.substr(0, fieldName.lastIndexOf('.')) + '_id'; + if (!ctrl.crmSearchAdmin.canAggregate(idField)) { + var joinEntity = searchMeta.getEntity(info.field.fk_entity || info.field.entity); + _.each(joinEntity.paths, function(path) { + var link = _.cloneDeep(path); + link.path = link.path.replace(/\[id/g, '[' + idField); + links.push(link); + }); + } + } + } + }); + return links; + } + + this.pickIcon = function(model, key) { + searchMeta.pickIcon().then(function(icon) { + model[key] = icon; + }); + }; + // Helper function to sort active from hidden columns and initialize each column with defaults this.initColumns = function(defaults) { if (!ctrl.display.settings.columns) { @@ -104,7 +200,7 @@ } }); _.eachRight(activeColumns, function(key, index) { - if (!_.includes(selectAliases, key)) { + if (key && !_.includes(selectAliases, key)) { ctrl.display.settings.columns.splice(index, 1); } }); diff --git a/ext/search/ang/crmSearchAdmin/crmSearchAdminLinkGroup.component.js b/ext/search/ang/crmSearchAdmin/crmSearchAdminLinkGroup.component.js new file mode 100644 index 000000000000..2708c98909bb --- /dev/null +++ b/ext/search/ang/crmSearchAdmin/crmSearchAdminLinkGroup.component.js @@ -0,0 +1,101 @@ +(function(angular, $, _) { + "use strict"; + + angular.module('crmSearchAdmin').component('crmSearchAdminLinkGroup', { + bindings: { + group: '<', + apiEntity: '<', + apiParams: '<', + links: '<' + }, + templateUrl: '~/crmSearchAdmin/crmSearchAdminLinkGroup.html', + controller: function ($scope, $element, $timeout, searchMeta) { + var ts = $scope.ts = CRM.ts(), + ctrl = this; + + this.styles = CRM.crmSearchAdmin.styles; + + this.setValue = function(val, index) { + var link = ctrl.getLink(val), + item = ctrl.group[index]; + if (item.path === val) { + return; + } + item.path = val; + item.icon = link ? defaultIcons[link.action] : 'fa-external-link'; + if (val === 'civicrm/') { + $timeout(function () { + $('tr:eq(' + index + ') input[type=text]', $element).focus(); + }); + } + }; + + this.sortableOptions = { + containment: 'tbody', + direction: 'vertical', + helper: function(e, ui) { + // Prevent table row width from changing during drag + ui.children().each(function() { + $(this).width($(this).width()); + }); + return ui; + } + }; + + var defaultIcons = { + view: 'fa-external-link', + update: 'fa-pencil', + delete: 'fa-trash' + }; + + var defaultStyles = { + view: 'primary', + update: 'warning', + delete: 'danger' + }; + + $scope.pickIcon = function(index) { + searchMeta.pickIcon().then(function(icon) { + ctrl.group[index].icon = icon; + }); + }; + + this.addItem = function(path) { + var link = ctrl.getLink(path); + ctrl.group.push({ + path: path, + style: link && defaultStyles[link.action] || 'default', + text: link ? link.title : '', + icon: link && defaultIcons[link.action] || 'fa-external-link' + }); + }; + + this.$onInit = function() { + if (!ctrl.group.length) { + if (ctrl.links.length) { + _.each(_.pluck(ctrl.links, 'path'), ctrl.addItem); + } else { + ctrl.addItem('civicrm/'); + } + } + $element.on('change', 'select.crm-search-admin-select-path', function() { + var $select = $(this); + $scope.$apply(function() { + if ($select.closest('tfoot').length) { + ctrl.addItem($select.val()); + $select.val(''); + } else { + ctrl.setValue($select.val(), $select.closest('tr').index()); + } + }); + }); + }; + + this.getLink = function(path) { + return _.findWhere(ctrl.links, {path: path}); + }; + + } + }); + +})(angular, CRM.$, CRM._); diff --git a/ext/search/ang/crmSearchAdmin/crmSearchAdminLinkGroup.html b/ext/search/ang/crmSearchAdmin/crmSearchAdminLinkGroup.html new file mode 100644 index 000000000000..2846b6007396 --- /dev/null +++ b/ext/search/ang/crmSearchAdmin/crmSearchAdminLinkGroup.html @@ -0,0 +1,66 @@ ++ | {{:: ts('Icon') }} | +{{:: ts('Text') }} | +{{:: ts('Link') }} | +{{:: ts('Style') }} | ++ |
---|---|---|---|---|---|
+ + | ++ + + + | ++ + | +
+
+
+ |
+ + + | ++ + | +
+ + | +