From 8362288d4cac92de97cd40a6514bf218b837561e Mon Sep 17 00:00:00 2001 From: Coleman Watts Date: Tue, 19 Oct 2021 17:00:32 -0400 Subject: [PATCH] SearchKit - add/remove tags action for all taggable entities --- .../Action/SearchDisplay/GetSearchTasks.php | 16 ++- .../crmSearchBatchRunner.component.js | 18 +++- .../crmSearchTasks/crmSearchTaskTag.ctrl.js | 99 +++++++++++++++++++ .../ang/crmSearchTasks/crmSearchTaskTag.html | 57 +++++++++++ 4 files changed, 185 insertions(+), 5 deletions(-) create mode 100644 ext/search_kit/ang/crmSearchTasks/crmSearchTaskTag.ctrl.js create mode 100644 ext/search_kit/ang/crmSearchTasks/crmSearchTaskTag.html diff --git a/ext/search_kit/Civi/Api4/Action/SearchDisplay/GetSearchTasks.php b/ext/search_kit/Civi/Api4/Action/SearchDisplay/GetSearchTasks.php index 94c332ccb6..165441a173 100644 --- a/ext/search_kit/Civi/Api4/Action/SearchDisplay/GetSearchTasks.php +++ b/ext/search_kit/Civi/Api4/Action/SearchDisplay/GetSearchTasks.php @@ -61,6 +61,17 @@ class GetSearchTasks extends \Civi\Api4\Generic\AbstractAction { 'icon' => 'fa-save', 'uiDialog' => ['templateUrl' => '~/crmSearchTasks/crmSearchTaskUpdate.html'], ]; + + $taggable = \CRM_Core_OptionGroup::values('tag_used_for', FALSE, FALSE, FALSE, NULL, 'name'); + if (in_array($entity['name'], $taggable, TRUE)) { + $tasks[$entity['name']]['tag'] = [ + 'module' => 'crmSearchTasks', + 'title' => E::ts('Tag - Add/Remove Tags'), + 'icon' => 'fa-tags', + 'uiDialog' => ['templateUrl' => '~/crmSearchTasks/crmSearchTaskTag.html'], + ]; + } + } if (array_key_exists('delete', $entity['actions'])) { @@ -75,13 +86,14 @@ class GetSearchTasks extends \Civi\Api4\Generic\AbstractAction { if ($entity['name'] === 'Contact') { // Add contact tasks which support standalone mode $contactTasks = $this->checkPermissions ? \CRM_Contact_Task::permissionedTaskTitles(\CRM_Core_Permission::getPermission()) : NULL; + // These tasks are redundant with the new api-based ones in SearchKit + $redundant = [\CRM_Core_Task::TAG_ADD, \CRM_Core_Task::TAG_REMOVE, \CRM_Core_Task::TASK_DELETE]; foreach (\CRM_Contact_Task::tasks() as $id => $task) { if ( (!$this->checkPermissions || isset($contactTasks[$id])) && // Must support standalone mode (with a 'url' property) !empty($task['url']) && - // The delete task is redundant with the new api-based one - $task['url'] !== 'civicrm/task/delete-contact' + !in_array($id, $redundant) ) { if ($task['url'] === 'civicrm/task/pick-profile') { $task['title'] = E::ts('Profile Update'); diff --git a/ext/search_kit/ang/crmSearchTasks/crmSearchBatchRunner.component.js b/ext/search_kit/ang/crmSearchTasks/crmSearchBatchRunner.component.js index 415d44d68a..6b910d825a 100644 --- a/ext/search_kit/ang/crmSearchTasks/crmSearchBatchRunner.component.js +++ b/ext/search_kit/ang/crmSearchTasks/crmSearchBatchRunner.component.js @@ -6,6 +6,7 @@ entity: '<', action: '@', ids: '<', + idField: '@', params: '<', success: '&', error: '&' @@ -22,7 +23,7 @@ // Number of records to process in each batch var BATCH_SIZE = 500, - // Extimated number of seconds each batch will take (for auto-incrementing the progress bar) + // Estimated number of seconds each batch will take (for auto-incrementing the progress bar) EST_BATCH_TIME = 5; this.$onInit = function() { @@ -41,8 +42,19 @@ ctrl.last = ctrl.ids.length; } var params = _.cloneDeep(ctrl.params); - params.where = params.where || []; - params.where.push(['id', 'IN', ctrl.ids.slice(ctrl.first, ctrl.last)]); + if (ctrl.action === 'save') { + // For the save action, take each record from params and copy it with each supplied id + params.records = _.transform(ctrl.ids.slice(ctrl.first, ctrl.last), function(records, id) { + _.each(_.cloneDeep(ctrl.params.records), function(record) { + record[ctrl.idField || 'id'] = id; + records.push(record); + }); + }); + } else { + // For other batch actions (update, delete), add supplied ids to the where clause + params.where = params.where || []; + params.where.push([ctrl.idField || 'id', 'IN', ctrl.ids.slice(ctrl.first, ctrl.last)]); + } crmApi4(ctrl.entity, ctrl.action, params).then( function(result) { stopIncrementer(); diff --git a/ext/search_kit/ang/crmSearchTasks/crmSearchTaskTag.ctrl.js b/ext/search_kit/ang/crmSearchTasks/crmSearchTaskTag.ctrl.js new file mode 100644 index 0000000000..bace427907 --- /dev/null +++ b/ext/search_kit/ang/crmSearchTasks/crmSearchTaskTag.ctrl.js @@ -0,0 +1,99 @@ +(function(angular, $, _) { + "use strict"; + + angular.module('crmSearchTasks').controller('crmSearchTaskTag', function($scope, crmApi4, searchTaskBaseTrait) { + var ts = $scope.ts = CRM.ts('org.civicrm.search_kit'), + // Combine this controller with model properties (ids, entity, entityInfo) and searchTaskBaseTrait + ctrl = angular.extend(this, $scope.model, searchTaskBaseTrait); + + this.entityTitle = this.getEntityTitle(); + this.action = 'save'; + this.selection = []; + this.selectedTags = []; + this.selectedTagsetTags = {}; + + crmApi4({ + tags: ['Tag', 'get', { + select: ['id', 'name', 'color', 'description', 'is_selectable', 'parent_id'], + where: [ + ['is_tagset', '=', false], + ['used_for:name', 'CONTAINS', this.entity], + ['OR', [['parent_id', 'IS NULL'], ['parent_id.is_tagset', '=', false]]] + ], + orderBy: {name: 'ASC'} + }], + tagsets: ['Tag', 'get', { + select: ['id', 'name'], + where: [['is_tagset', '=', true], ['used_for:name', 'CONTAINS', this.entity]] + }], + }).then(function(result) { + ctrl.tagsets = result.tagsets; + ctrl.tags = sortTagsForSelect2(result.tags); + }); + + // Sort non-tagset tags into a nested hierarchy + function sortTagsForSelect2(rawTags) { + var sorted = _.transform(rawTags, function(sorted, tag) { + sorted[tag.id] = { + id: tag.id, + text: tag.name, + description: tag.description, + color: tag.color, + disabled: !tag.is_selectable, + parent_id: tag.parent_id + }; + }, {}); + // Capitalizing on the fact that javascript objects always copy-by-reference, + // this creates a multi-level hierarchy in a single pass by placing child tags under their parents + // while keeping a reference to children at the top level (which allows them to receive children of their own). + _.each(sorted, function(tag) { + if (tag.parent_id && sorted[tag.parent_id]) { + sorted[tag.parent_id].children = sorted[tag.parent_id].children || []; + sorted[tag.parent_id].children.push(tag); + } + }); + // Remove the child tags from the top level, and what remains is a nested hierarchy + return _.filter(sorted, {parent_id: null}); + } + + this.saveTags = function() { + var params = {}; + if (ctrl.action === 'save') { + params.defaults = { + 'entity_table:name': ctrl.entity + }; + params.records = _.transform(ctrl.selection, function(records, tagId) { + records.push({tag_id: tagId}); + }); + } else { + params.where = [ + ['entity_table:name', '=', ctrl.entity], + ['tag_id', 'IN', ctrl.selection] + ]; + } + ctrl.start(params); + }; + + this.onSelectTags = function() { + ctrl.selection = _.cloneDeep(ctrl.selectedTags); + _.each(ctrl.selectedTagsetTags, function(set) { + ctrl.selection = ctrl.selection.concat(set); + }); + }; + + this.onSuccess = function() { + if (ctrl.action === 'delete') { + CRM.alert(ts('Removed tags from %1 %2.', {1: ctrl.ids.length, 2: ctrl.entityTitle}), ts('Saved'), 'success'); + } else { + CRM.alert(ts('Added tags to %1 %2.', {1: ctrl.ids.length, 2: ctrl.entityTitle}), ts('Saved'), 'success'); + } + this.close(); + }; + + this.onError = function() { + CRM.alert(ts('An error occurred while updating tags.'), ts('Error'), 'error'); + this.cancel(); + }; + + }); +})(angular, CRM.$, CRM._); diff --git a/ext/search_kit/ang/crmSearchTasks/crmSearchTaskTag.html b/ext/search_kit/ang/crmSearchTasks/crmSearchTaskTag.html new file mode 100644 index 0000000000..39cefc2967 --- /dev/null +++ b/ext/search_kit/ang/crmSearchTasks/crmSearchTaskTag.html @@ -0,0 +1,57 @@ +
+
+
+ + +
+
+ +
+
+
+
+ + +
+
+ + +
+
+
+
{{:: $ctrl.action === 'save' ? ts('Adding tags...') : ts('Removing tags...') }}
+ +
+
+
+ + +
+
+
-- 2.25.1