From 406f10144f6bbb5ff35cbdbeb820751095c53b18 Mon Sep 17 00:00:00 2001 From: Coleman Watts Date: Thu, 11 Feb 2021 15:11:10 -0500 Subject: [PATCH] SearchKit - Refactor search displays to go through centralized api wrapper --- .../Civi/Api4/Action/SearchDisplay/Run.php | 146 ++++++++++++++++++ ext/search/Civi/Api4/SearchDisplay.php | 9 ++ .../Search/AfformSearchMetadataInjector.php | 5 +- ext/search/Civi/Search/Display.php | 2 +- .../crmSearchAdminDisplay.component.js | 2 +- ext/search/ang/crmSearchDisplay.module.js | 79 ++++------ ext/search/ang/crmSearchDisplay/Pager.html | 2 +- .../crmSearchDisplayList.component.js | 29 ++-- .../crmSearchDisplayTable.component.js | 58 +++---- .../crmSearchDisplayTable.html | 4 +- ext/search/ang/crmSearchPage.module.js | 4 +- 11 files changed, 242 insertions(+), 98 deletions(-) create mode 100644 ext/search/Civi/Api4/Action/SearchDisplay/Run.php diff --git a/ext/search/Civi/Api4/Action/SearchDisplay/Run.php b/ext/search/Civi/Api4/Action/SearchDisplay/Run.php new file mode 100644 index 0000000000..d50bd79e9c --- /dev/null +++ b/ext/search/Civi/Api4/Action/SearchDisplay/Run.php @@ -0,0 +1,146 @@ +savedSearch) && is_string($this->display)) && $this->checkPermissions && !\CRM_Core_Permission::check('administer CiviCRM')) { + throw new UnauthorizedException('Access denied'); + } + if (is_string($this->savedSearch)) { + $this->savedSearch = SavedSearch::get(FALSE) + ->addWhere('name', '=', $this->savedSearch) + ->execute()->first(); + } + if (is_string($this->display)) { + $this->display = SearchDisplay::get(FALSE) + ->addWhere('name', '=', $this->display) + ->addWhere('saved_search_id', '=', $this->savedSearch['id']) + ->execute()->first(); + } + $entityName = $this->savedSearch['api_entity']; + $apiParams =& $this->savedSearch['api_params']; + $settings = $this->display['settings']; + $page = NULL; + + switch ($this->return) { + case 'row_count': + case 'id': + if (empty($apiParams['having'])) { + $apiParams['select'] = []; + } + if (!in_array($this->return, $apiParams)) { + $apiParams['select'][] = $this->return; + } + unset($apiParams['orderBy'], $apiParams['limit']); + break; + + default: + if (!empty($settings['pager']) && preg_match('/^page:\d+$/', $this->return)) { + $page = explode(':', $this->return)[1]; + } + $apiParams['limit'] = $settings['limit'] ?? NULL; + $apiParams['offset'] = $page ? $apiParams['limit'] * ($page - 1) : 0; + $apiParams['orderBy'] = $this->getOrderByFromSort(); + + // Select the ids of joined entities (helps with displaying links) + foreach ($apiParams['join'] ?? [] as $join) { + $joinEntity = explode(' AS ', $join[0])[1]; + $idField = $joinEntity . '.id'; + if (!in_array($idField, $apiParams['select']) && !$this->canAggregate('id', $joinEntity . '.')) { + $apiParams['select'][] = $idField; + } + } + + } + + $apiResult = civicrm_api4($entityName, 'get', $apiParams); + + $result->rowCount = $apiResult->rowCount; + $result->exchangeArray($apiResult->getArrayCopy()); + } + + private function getOrderByFromSort() { + $defaultSort = $this->display['settings']['sort'] ?? []; + $currentSort = $this->sort; + + // Validate that requested sort fields are part of the SELECT + foreach ($this->sort as $item) { + if (!in_array($item[0], $this->getSelectAliases())) { + $currentSort = NULL; + } + } + + $orderBy = []; + foreach ($currentSort ?: $defaultSort as $item) { + $orderBy[$item[0]] = $item[1]; + } + return $orderBy; + } + + private function getSelectAliases() { + return array_map(function($select) { + return array_slice(explode(' AS ', $select), -1)[0]; + }, $this->savedSearch['api_params']['select']); + } + + private function canAggregate($fieldName, $prefix) { + $apiParams = $this->savedSearch['api_params']; + + // If the query does not use grouping, never + if (empty($apiParams['groupBy'])) { + return FALSE; + } + // If the column is used for a groupBy, no + if (in_array($prefix . $fieldName, $apiParams['groupBy'])) { + return FALSE; + } + // If the entity this column belongs to is being grouped by id, then also no + return !in_array($prefix . 'id', $apiParams['groupBy']); + } + +} diff --git a/ext/search/Civi/Api4/SearchDisplay.php b/ext/search/Civi/Api4/SearchDisplay.php index 93503b83d4..2ea595cbf1 100644 --- a/ext/search/Civi/Api4/SearchDisplay.php +++ b/ext/search/Civi/Api4/SearchDisplay.php @@ -11,4 +11,13 @@ namespace Civi\Api4; */ class SearchDisplay extends Generic\DAOEntity { + /** + * @param bool $checkPermissions + * @return Action\SearchDisplay\Run + */ + public static function run($checkPermissions = TRUE) { + return (new Action\SearchDisplay\Run(__CLASS__, __FUNCTION__)) + ->setCheckPermissions($checkPermissions); + } + } diff --git a/ext/search/Civi/Search/AfformSearchMetadataInjector.php b/ext/search/Civi/Search/AfformSearchMetadataInjector.php index e65323c197..0ab479f7ef 100644 --- a/ext/search/Civi/Search/AfformSearchMetadataInjector.php +++ b/ext/search/Civi/Search/AfformSearchMetadataInjector.php @@ -40,8 +40,9 @@ class AfformSearchMetadataInjector { ->execute()->first(); if ($display) { pq($component)->attr('settings', htmlspecialchars(\CRM_Utils_JS::encode($display['settings'] ?? []))); - pq($component)->attr('api-entity', htmlspecialchars(\CRM_Utils_JS::encode($display['saved_search.api_entity']))); - pq($component)->attr('api-params', htmlspecialchars(\CRM_Utils_JS::encode($display['saved_search.api_params']))); + pq($component)->attr('api-entity', htmlspecialchars($display['saved_search.api_entity'])); + pq($component)->attr('search', htmlspecialchars(\CRM_Utils_JS::encode($searchName))); + pq($component)->attr('display', htmlspecialchars(\CRM_Utils_JS::encode($displayName))); // Add entity names to the fieldset so that afform can populate field metadata $fieldset = pq($component)->parents('[af-fieldset]'); diff --git a/ext/search/Civi/Search/Display.php b/ext/search/Civi/Search/Display.php index ccfe437118..1e784ea38a 100644 --- a/ext/search/Civi/Search/Display.php +++ b/ext/search/Civi/Search/Display.php @@ -24,7 +24,7 @@ class Display { $partials = []; foreach (self::getDisplayTypes(['id', 'name']) as $type) { $partials["~/$moduleName/displayType/{$type['id']}.html"] = - '<' . $type['name'] . ' api-entity="$ctrl.apiEntity" api-params="$ctrl.apiParams" settings="$ctrl.display.settings">'; + '<' . $type['name'] . ' api-entity="{{:: $ctrl.apiEntity }}" search="$ctrl.searchName" display="$ctrl.display.name" settings="$ctrl.display.settings">'; } return $partials; } diff --git a/ext/search/ang/crmSearchAdmin/crmSearchAdminDisplay.component.js b/ext/search/ang/crmSearchAdmin/crmSearchAdminDisplay.component.js index 27ba2ae469..24cd177261 100644 --- a/ext/search/ang/crmSearchAdmin/crmSearchAdminDisplay.component.js +++ b/ext/search/ang/crmSearchAdmin/crmSearchAdminDisplay.component.js @@ -22,7 +22,7 @@ ' \n' + '
\n' + '
\n' + - ' <' + type.name + ' api-entity="$ctrl.savedSearch.api_entity" api-params="$ctrl.savedSearch.api_params" settings="$ctrl.display.settings">\n' + + ' <' + type.name + ' api-entity="{{:: $ctrl.savedSearch.api_entity }}" search="$ctrl.savedSearch" display="$ctrl.display" settings="$ctrl.display.settings">\n' + '
\n' + '\n'; }); diff --git a/ext/search/ang/crmSearchDisplay.module.js b/ext/search/ang/crmSearchDisplay.module.js index 72bd7c6a1e..56429b554a 100644 --- a/ext/search/ang/crmSearchDisplay.module.js +++ b/ext/search/ang/crmSearchDisplay.module.js @@ -4,7 +4,7 @@ // Declare module angular.module('crmSearchDisplay', CRM.angRequires('crmSearchDisplay')) - .factory('searchDisplayUtils', function() { + .factory('searchDisplayUtils', function(crmApi4) { function replaceTokens(str, data) { if (!str) { @@ -48,69 +48,46 @@ return result; } - function canAggregate(fieldName, prefix, apiParams) { - // If the query does not use grouping, never - if (!apiParams.groupBy.length) { - return false; - } - // If the column is used for a groupBy, no - if (apiParams.groupBy.indexOf(prefix + fieldName) > -1) { - return false; - } - // If the entity this column belongs to is being grouped by id, then also no - return apiParams.groupBy.indexOf(prefix + 'id') < 0; - } - - function prepareColumns(columns, apiParams) { + function prepareColumns(columns) { columns = _.cloneDeep(columns); - _.each(columns, function(col, num) { - var index = apiParams.select.indexOf(col.expr); - if (_.includes(col.expr, '(') && !_.includes(col.expr, ' AS ')) { - col.expr += ' AS column_' + num; - apiParams.select[index] += ' AS column_' + num; - } + _.each(columns, function(col) { col.key = _.last(col.expr.split(' AS ')); }); return columns; } - function prepareParams(ctrl) { - var params = _.cloneDeep(ctrl.apiParams); - if (_.isEmpty(params.where)) { - params.where = []; - } - // Select the ids of joined entities (helps with displaying links) - _.each(params.join, function(join) { - var joinEntity = join[0].split(' AS ')[1], - idField = joinEntity + '.id'; - if (!_.includes(params.select, idField) && !canAggregate('id', joinEntity + '.', params)) { - params.select.push(idField); + function getApiParams(ctrl, mode) { + return { + return: mode || 'page:' + ctrl.page, + savedSearch: ctrl.search, + display: ctrl.display, + sort: ctrl.sort, + filters: _.assign({}, (ctrl.afFieldset ? ctrl.afFieldset.getFieldData() : {}), ctrl.filters) + }; + } + + function getResults(ctrl) { + var params = getApiParams(ctrl); + crmApi4('SearchDisplay', 'run', params).then(function(results) { + ctrl.results = results; + if (ctrl.settings.pager && !ctrl.rowCount) { + if (results.length < ctrl.settings.limit) { + ctrl.rowCount = results.length; + } else { + var params = getApiParams(ctrl, 'row_count'); + crmApi4('SearchDisplay', 'run', params).then(function(result) { + ctrl.rowCount = result.count; + }); + } } }); - function addFilter(value, key) { - if (value) { - params.where.push([key, 'CONTAINS', value]); - } - } - // Add filters explicitly passed into controller - _.each(ctrl.filters, addFilter); - // Add filters when nested in an afform fieldset - if (ctrl.afFieldset) { - _.each(ctrl.afFieldset.getFieldData(), addFilter); - } - - if (ctrl.settings && ctrl.settings.pager && ctrl.page) { - params.offset = (ctrl.page - 1) * params.limit; - params.select.push('row_count'); - } - return params; } return { formatSearchValue: formatSearchValue, - canAggregate: canAggregate, prepareColumns: prepareColumns, - prepareParams: prepareParams, + getApiParams: getApiParams, + getResults: getResults, replaceTokens: replaceTokens }; }); diff --git a/ext/search/ang/crmSearchDisplay/Pager.html b/ext/search/ang/crmSearchDisplay/Pager.html index 54ece31837..d760bec287 100644 --- a/ext/search/ang/crmSearchDisplay/Pager.html +++ b/ext/search/ang/crmSearchDisplay/Pager.html @@ -5,7 +5,7 @@ total-items="$ctrl.rowCount" ng-model="$ctrl.page" ng-change="$ctrl.getResults()" - items-per-page="$ctrl.apiParams.limit" + items-per-page="$ctrl.settings.limit" max-size="6" force-ellipses="true" previous-text="‹" diff --git a/ext/search/ang/crmSearchDisplayList/crmSearchDisplayList.component.js b/ext/search/ang/crmSearchDisplayList/crmSearchDisplayList.component.js index 45688ba1c9..38ed943580 100644 --- a/ext/search/ang/crmSearchDisplayList/crmSearchDisplayList.component.js +++ b/ext/search/ang/crmSearchDisplayList/crmSearchDisplayList.component.js @@ -3,7 +3,9 @@ angular.module('crmSearchDisplayList').component('crmSearchDisplayList', { bindings: { - apiEntity: '<', + apiEntity: '@', + search: '<', + display: '<', apiParams: '<', settings: '<', filters: '<' @@ -15,28 +17,31 @@ controller: function($scope, crmApi4, searchDisplayUtils) { var ts = $scope.ts = CRM.ts(), ctrl = this; + this.page = 1; + this.rowCount = null; this.$onInit = function() { - this.apiParams = _.cloneDeep(this.apiParams); - this.apiParams.limit = parseInt(this.settings.limit || 0, 10); - this.columns = searchDisplayUtils.prepareColumns(this.settings.columns, this.apiParams); + this.columns = searchDisplayUtils.prepareColumns(this.settings.columns); + this.sort = this.settings.sort ? _.cloneDeep(this.settings.sort) : []; $scope.displayUtils = searchDisplayUtils; + if (this.afFieldset) { - $scope.$watch(this.afFieldset.getFieldData, this.getResults, true); + $scope.$watch(this.afFieldset.getFieldData, refresh, true); } - $scope.$watch('$ctrl.filters', ctrl.getResults, true); + $scope.$watch('$ctrl.filters', refresh, true); }; this.getResults = _.debounce(function() { - var params = searchDisplayUtils.prepareParams(ctrl); - - crmApi4(ctrl.apiEntity, 'get', params).then(function(results) { - ctrl.results = results; - ctrl.rowCount = results.count; - }); + searchDisplayUtils.getResults(ctrl); }, 100); + function refresh() { + ctrl.page = 1; + ctrl.rowCount = null; + ctrl.getResults(); + } + $scope.formatResult = function(row, col) { var value = row[col.key], formatted = searchDisplayUtils.formatSearchValue(row, col, value), diff --git a/ext/search/ang/crmSearchDisplayTable/crmSearchDisplayTable.component.js b/ext/search/ang/crmSearchDisplayTable/crmSearchDisplayTable.component.js index 1606ef39a0..3f98f63c21 100644 --- a/ext/search/ang/crmSearchDisplayTable/crmSearchDisplayTable.component.js +++ b/ext/search/ang/crmSearchDisplayTable/crmSearchDisplayTable.component.js @@ -3,8 +3,9 @@ angular.module('crmSearchDisplayTable').component('crmSearchDisplayTable', { bindings: { - apiEntity: '<', - apiParams: '<', + apiEntity: '@', + search: '<', + display: '<', settings: '<', filters: '<' }, @@ -17,37 +18,40 @@ ctrl = this; this.page = 1; + this.rowCount = null; this.selectedRows = []; this.allRowsSelected = false; this.$onInit = function() { - this.apiParams = _.cloneDeep(this.apiParams); - this.apiParams.limit = parseInt(this.settings.limit || 0, 10); - this.columns = searchDisplayUtils.prepareColumns(this.settings.columns, this.apiParams); + this.columns = searchDisplayUtils.prepareColumns(this.settings.columns); + this.sort = this.settings.sort ? _.cloneDeep(this.settings.sort) : []; $scope.displayUtils = searchDisplayUtils; if (this.afFieldset) { - $scope.$watch(this.afFieldset.getFieldData, this.getResults, true); + $scope.$watch(this.afFieldset.getFieldData, refresh, true); } - $scope.$watch('$ctrl.filters', ctrl.getResults, true); + $scope.$watch('$ctrl.filters', refresh, true); }; this.getResults = _.debounce(function() { - var params = searchDisplayUtils.prepareParams(ctrl); - - crmApi4(ctrl.apiEntity, 'get', params).then(function(results) { - ctrl.results = results; - ctrl.rowCount = results.count; - }); + searchDisplayUtils.getResults(ctrl); }, 100); + function refresh() { + ctrl.page = 1; + ctrl.rowCount = null; + ctrl.getResults(); + } + /** * Returns crm-i icon class for a sortable column * @param col * @returns {string} */ - $scope.getOrderBy = function(col) { - var dir = ctrl.apiParams.orderBy && ctrl.apiParams.orderBy[col.key]; + $scope.getSort = function(col) { + var dir = _.reduce(ctrl.sort, function(dir, item) { + return item[0] === col.key ? item[1] : dir; + }, null); if (dir) { return 'fa-sort-' + dir.toLowerCase(); } @@ -59,12 +63,17 @@ * @param col * @param $event */ - $scope.setOrderBy = function(col, $event) { - var dir = $scope.getOrderBy(col) === 'fa-sort-asc' ? 'DESC' : 'ASC'; - if (!$event.shiftKey || !ctrl.apiParams.orderBy) { - ctrl.apiParams.orderBy = {}; + $scope.setSort = function(col, $event) { + var dir = $scope.getSort(col) === 'fa-sort-asc' ? 'DESC' : 'ASC'; + if (!$event.shiftKey || !ctrl.sort) { + ctrl.sort = []; + } + var index = _.findIndex(ctrl.sort, [col.key]); + if (index > -1) { + ctrl.sort[index][1] = dir; + } else { + ctrl.sort.push([col.key, dir]); } - ctrl.apiParams.orderBy[col.key] = dir; ctrl.getResults(); }; @@ -82,17 +91,14 @@ } // Select all ctrl.allRowsSelected = true; - if (ctrl.page === 1 && ctrl.results.length < ctrl.apiParams.limit) { + if (ctrl.page === 1 && ctrl.results.length < ctrl.settings.limit) { ctrl.selectedRows = _.pluck(ctrl.results, 'id'); return; } // If more than one page of results, use ajax to fetch all ids $scope.loadingAllRows = true; - var params = _.cloneDeep(ctrl.apiParams); - delete params.limit; - // Select only ids unless HAVING clause is present - params.select = params.having && params.having.length? params.select : ['id']; - crmApi4(ctrl.apiEntity, 'get', params, ['id']).then(function(ids) { + var params = searchDisplayUtils.getApiParams(ctrl, 'id'); + crmApi4('SearchDisplay', 'run', params, ['id']).then(function(ids) { $scope.loadingAllRows = false; ctrl.selectedRows = _.toArray(ids); }); diff --git a/ext/search/ang/crmSearchDisplayTable/crmSearchDisplayTable.html b/ext/search/ang/crmSearchDisplayTable/crmSearchDisplayTable.html index d23d5efe9c..6f10531991 100644 --- a/ext/search/ang/crmSearchDisplayTable/crmSearchDisplayTable.html +++ b/ext/search/ang/crmSearchDisplayTable/crmSearchDisplayTable.html @@ -7,8 +7,8 @@ - - + + {{ col.label }} diff --git a/ext/search/ang/crmSearchPage.module.js b/ext/search/ang/crmSearchPage.module.js index d890d95c6b..42e429e85e 100644 --- a/ext/search/ang/crmSearchPage.module.js +++ b/ext/search/ang/crmSearchPage.module.js @@ -17,7 +17,7 @@ var params = $route.current.params; return crmApi4('SearchDisplay', 'get', { where: [['name', '=', params.displayName], ['saved_search.name', '=', params.savedSearchName]], - select: ['*', 'saved_search.api_entity', 'saved_search.api_params'] + select: ['*', 'saved_search.api_entity', 'saved_search.name'] }, 0); } } @@ -27,8 +27,8 @@ // Controller for displaying a search .controller('crmSearchPageDisplay', function($scope, $routeParams, $location, display) { this.display = display; + this.searchName = display['saved_search.name']; this.apiEntity = display['saved_search.api_entity']; - this.apiParams = display['saved_search.api_params']; $scope.$ctrl = this; }); -- 2.25.1