From ea04af0c9483be804df873fbb54515e97286662a Mon Sep 17 00:00:00 2001 From: Coleman Watts Date: Wed, 27 Oct 2021 01:22:23 -0400 Subject: [PATCH] SearchKit - Autogenerate default display table for saved searches; calculate links by entity+action This adds a SearchDisplay.getDefault APIv4 action, which autogenerates the default table display which is used on the admin screen. It also refactors links so they can be calculated based on a given entity+action which does not require paths to be stored in the search display. --- Civi/Api4/Query/Api4SelectQuery.php | 11 +- Civi/Api4/Query/SqlFunction.php | 2 +- .../SearchDisplay/AbstractRunAction.php | 191 +++++++------- .../Api4/Action/SearchDisplay/Download.php | 2 + .../Api4/Action/SearchDisplay/GetDefault.php | 234 ++++++++++++++++++ .../Civi/Api4/Action/SearchDisplay/Run.php | 4 + .../SavedSearchInspectorTrait.php | 145 +++++++++++ ext/search_kit/Civi/Api4/SearchDisplay.php | 11 +- ext/search_kit/Civi/Search/Admin.php | 2 + ext/search_kit/Civi/Search/Display.php | 56 +++++ .../crmSearchAdmin.component.js | 30 ++- .../searchAdminDisplayTable.component.js | 6 +- .../crmSearchAdminResultsTable.component.js | 66 +---- 13 files changed, 571 insertions(+), 189 deletions(-) create mode 100644 ext/search_kit/Civi/Api4/Action/SearchDisplay/GetDefault.php create mode 100644 ext/search_kit/Civi/Api4/Action/SearchDisplay/SavedSearchInspectorTrait.php diff --git a/Civi/Api4/Query/Api4SelectQuery.php b/Civi/Api4/Query/Api4SelectQuery.php index aee952cc6b..7e7d19bfb4 100644 --- a/Civi/Api4/Query/Api4SelectQuery.php +++ b/Civi/Api4/Query/Api4SelectQuery.php @@ -1097,7 +1097,7 @@ class Api4SelectQuery { else { $fieldArray['sql_name'] = '`' . $baseTableAlias . '`.`' . $link->getBaseColumn() . '`'; } - $fieldArray['implicit_join'] = $link->getBaseColumn(); + $fieldArray['implicit_join'] = rtrim($joinTreeNode[$joinName]['#path'], '.'); $fieldArray['explicit_join'] = $explicitJoin ? $explicitJoin['alias'] : NULL; // Custom fields will already have the group name prefixed $fieldName = $isCustom ? explode('.', $fieldArray['name'])[1] : $fieldArray['name']; @@ -1241,12 +1241,19 @@ class Api4SelectQuery { /** * @param string $alias - * @return array|NULL + * @return array{entity: string, alias: string, table: string, bridge: string|NULL}|NULL */ public function getExplicitJoin($alias) { return $this->explicitJoins[$alias] ?? NULL; } + /** + * @return array{entity: string, alias: string, table: string, bridge: string|NULL}[] + */ + public function getExplicitJoins() { + return $this->explicitJoins; + } + /** * @param string $path * @param array $field diff --git a/Civi/Api4/Query/SqlFunction.php b/Civi/Api4/Query/SqlFunction.php index 6380211026..5b146ee979 100644 --- a/Civi/Api4/Query/SqlFunction.php +++ b/Civi/Api4/Query/SqlFunction.php @@ -182,7 +182,7 @@ abstract class SqlFunction extends SqlExpression { /** * Get the arguments passed to this sql function instance. - * @return array[] + * @return array{prefix: array, suffix: array, expr: SqlExpression}[] */ public function getArgs(): array { return $this->args; diff --git a/ext/search_kit/Civi/Api4/Action/SearchDisplay/AbstractRunAction.php b/ext/search_kit/Civi/Api4/Action/SearchDisplay/AbstractRunAction.php index d807e3447d..1b4ff89aac 100644 --- a/ext/search_kit/Civi/Api4/Action/SearchDisplay/AbstractRunAction.php +++ b/ext/search_kit/Civi/Api4/Action/SearchDisplay/AbstractRunAction.php @@ -3,29 +3,34 @@ namespace Civi\Api4\Action\SearchDisplay; use Civi\API\Exception\UnauthorizedException; -use Civi\Api4\Query\SqlExpression; -use Civi\Api4\SavedSearch; use Civi\Api4\SearchDisplay; use Civi\Api4\Utils\CoreUtil; /** * Base class for running a search. * + * @method $this setDisplay(array|string $display) + * @method array|string|null getDisplay() + * @method $this setSort(array $sort) + * @method array getSort() + * @method $this setFilters(array $filters) + * @method array getFilters() + * @method $this setSeed(string $seed) + * @method string getSeed() + * @method $this setAfform(string $afform) + * @method string getAfform() * @package Civi\Api4\Action\SearchDisplay */ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction { - /** - * Either the name of the savedSearch or an array containing the savedSearch definition (for preview mode) - * @var string|array - * @required - */ - protected $savedSearch; + use SavedSearchInspectorTrait; /** * Either the name of the display or an array containing the display definition (for preview mode) - * @var string|array - * @required + * + * Leave NULL to use the autogenerated default. + * + * @var string|array|null */ protected $display; @@ -55,21 +60,11 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction { */ protected $afform; - /** - * @var \Civi\Api4\Query\Api4SelectQuery - */ - private $_selectQuery; - /** * @var array */ private $_afform; - /** - * @var array - */ - private $_selectClause; - /** * @param \Civi\Api4\Generic\Result $result * @throws UnauthorizedException @@ -77,23 +72,22 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction { */ public function _run(\Civi\Api4\Generic\Result $result) { // Only administrators can use this in unsecured "preview mode" - if (!(is_string($this->savedSearch) && is_string($this->display)) && $this->checkPermissions && !\CRM_Core_Permission::check('administer CiviCRM data')) { + if ((is_array($this->savedSearch) || is_array($this->display)) && $this->checkPermissions && !\CRM_Core_Permission::check('administer CiviCRM data')) { 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) && !empty($this->savedSearch['id'])) { + $this->loadSavedSearch(); + if (is_string($this->display)) { $this->display = SearchDisplay::get(FALSE) ->setSelect(['*', 'type:name']) ->addWhere('name', '=', $this->display) ->addWhere('saved_search_id', '=', $this->savedSearch['id']) - ->execute()->first(); + ->execute()->single(); } - if (!$this->savedSearch || !$this->display) { - throw new \API_Exception("Error: SearchDisplay not found."); + elseif (is_null($this->display)) { + $this->display = SearchDisplay::getDefault(FALSE) + ->setSavedSearch($this->savedSearch) + ->execute()->first(); + $this->display['type:name'] = 'crm-search-display-table'; } // Displays with acl_bypass must be embedded on an afform which the user has access to if ( @@ -182,11 +176,17 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction { if (isset($column['title']) && strlen($column['title'])) { $out['title'] = $this->replaceTokens($column['title'], $data, 'view'); } - if (!empty($column['link']['path'])) { - $out['links'] = $this->formatFieldLinks($column, $data, $out['val']); + if (!empty($column['link'])) { + $links = $this->formatFieldLinks($column, $data, $out['val']); + if ($links) { + $out['links'] = $links; + } } elseif (!empty($column['editable']) && !$column['rewrite']) { - $out['edit'] = $this->formatEditableColumn($column, $data); + $edit = $this->formatEditableColumn($column, $data); + if ($edit) { + $out['edit'] = $edit; + } } break; @@ -218,7 +218,8 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction { $value = ['']; } foreach ((array) $value as $index => $val) { - $path = $this->replaceTokens($column['link']['path'], $data, 'url', $index); + $path = $this->getLinkPath($column['link'], $data, $index); + $path = $this->replaceTokens($path, $data, 'url', $index); if ($path) { $link = [ 'text' => $val, @@ -235,8 +236,8 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction { /** * Format links for a menu/buttons/links column - * @param $column - * @param $data + * @param array $column + * @param array $data * @return array{text: string, url: string, target: string, style: string, icon: string}[] */ private function formatLinksColumn($column, $data): array { @@ -245,7 +246,7 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction { $out['text'] = $this->replaceTokens($column['text'], $data, 'view'); } foreach ($column['links'] as $item) { - $path = $this->replaceTokens($item['path'], $data, 'url'); + $path = $this->replaceTokens($this->getLinkPath($item, $data), $data, 'url'); if ($path) { $link = [ 'text' => $this->replaceTokens($item['text'] ?? '', $data, 'view'), @@ -262,6 +263,38 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction { return $out; } + /** + * @param array $link + * @param array $data + * @param int $index + * @return string|null + */ + private function getLinkPath($link, $data = NULL, $index = 0) { + $path = $link['path'] ?? NULL; + if (!$path && !empty($link['entity']) && !empty($link['action'])) { + $entity = $link['entity']; + $idField = $idKey = CoreUtil::getIdFieldName($entity); + // Hack to support links to relationships + if ($entity === 'Relationship') { + $entity = 'RelationshipCache'; + $idKey = 'relationship_id'; + } + $path = CoreUtil::getInfoItem($entity, 'paths')[$link['action']] ?? NULL; + $prefix = ''; + if ($path && !empty($link['join'])) { + $prefix = $link['join'] . '.'; + } + // This is a bit clunky, the function_join_field gets un-munged later by $this->getJoinFromAlias() + if ($this->canAggregate($prefix . $idKey)) { + $prefix = 'GROUP_CONCAT_' . str_replace('.', '_', $prefix); + } + if ($prefix) { + $path = str_replace('[', '[' . $prefix, $path); + } + } + return $path; + } + /** * @param string $path * @return string @@ -342,57 +375,6 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction { ]; } - /** - * Returns field definition for a given field or NULL if not found - * @param $fieldName - * @return array|null - */ - protected function getField($fieldName) { - if (!$this->_selectQuery) { - $api = \Civi\API\Request::create($this->savedSearch['api_entity'], 'get', $this->savedSearch['api_params']); - $this->_selectQuery = new \Civi\Api4\Query\Api4SelectQuery($api); - } - return $this->_selectQuery->getField($fieldName, FALSE); - } - - /** - * Returns the select clause enhanced with metadata - * - * @return array - */ - protected function getSelectClause() { - if (!isset($this->_selectClause)) { - $this->_selectClause = []; - foreach ($this->savedSearch['api_params']['select'] as $selectExpr) { - $expr = SqlExpression::convert($selectExpr, TRUE); - $item = [ - 'fields' => [], - 'type' => $expr->getType(), - 'dataType' => $expr->getDataType(), - ]; - foreach ($expr->getFields() as $fieldName) { - $fieldMeta = $this->getField($fieldName); - if ($fieldMeta) { - $item['fields'][] = $fieldMeta; - } - } - if (!isset($item['dataType']) && $item['fields']) { - $item['dataType'] = $item['fields'][0]['data_type']; - } - $this->_selectClause[$expr->getAlias()] = $item; - } - } - return $this->_selectClause; - } - - /** - * @param string $key - * @return array{fields: array, dataType: string}|NULL - */ - protected function getSelectExpression($key) { - return $this->getSelectClause()[$key] ?? NULL; - } - /** * @param string $tokenExpr * @param array $data @@ -401,17 +383,19 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction { * @return string */ private function replaceTokens($tokenExpr, $data, $format, $index = 0) { - foreach ($this->getTokens($tokenExpr) as $token) { - $val = $data[$token] ?? NULL; - if (isset($val) && $format === 'view') { - $val = $this->formatViewValue($token, $val); - } - $replacement = is_array($val) ? $val[$index] ?? '' : $val; - // A missing token value in a url invalidates it - if ($format === 'url' && (!isset($replacement) || $replacement === '')) { - return NULL; + if ($tokenExpr) { + foreach ($this->getTokens($tokenExpr) as $token) { + $val = $data[$token] ?? NULL; + if (isset($val) && $format === 'view') { + $val = $this->formatViewValue($token, $val); + } + $replacement = is_array($val) ? $val[$index] ?? '' : $val; + // A missing token value in a url invalidates it + if ($format === 'url' && (!isset($replacement) || $replacement === '')) { + return NULL; + } + $tokenExpr = str_replace('[' . $token . ']', $replacement, $tokenExpr); } - $tokenExpr = str_replace('[' . $token . ']', $replacement, $tokenExpr); } return $tokenExpr; } @@ -623,10 +607,13 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction { $possibleTokens = ''; foreach ($this->display['settings']['columns'] as $column) { // Collect display values in which a token is allowed - $possibleTokens .= ($column['rewrite'] ?? '') . ($column['link']['path'] ?? ''); - if (!empty($column['links'])) { - $possibleTokens .= implode('', array_column($column['links'], 'path')); - $possibleTokens .= implode('', array_column($column['links'], 'text')); + $possibleTokens .= ($column['rewrite'] ?? ''); + if (!empty($column['link'])) { + $possibleTokens .= $this->getLinkPath($column['link']) ?? ''; + } + foreach ($column['links'] ?? [] as $link) { + $possibleTokens .= $link['text'] ?? ''; + $possibleTokens .= $this->getLinkPath($link) ?? ''; } // Select id & value for in-place editing diff --git a/ext/search_kit/Civi/Api4/Action/SearchDisplay/Download.php b/ext/search_kit/Civi/Api4/Action/SearchDisplay/Download.php index 432648ff34..a234f3751b 100644 --- a/ext/search_kit/Civi/Api4/Action/SearchDisplay/Download.php +++ b/ext/search_kit/Civi/Api4/Action/SearchDisplay/Download.php @@ -12,6 +12,8 @@ use PhpOffice\PhpSpreadsheet\Spreadsheet; * Note: unlike other APIs this action will directly output a file * if 'format' is set to anything other than 'array'. * + * @method $this setFormat(string $format) + * @method string getFormat() * @package Civi\Api4\Action\SearchDisplay */ class Download extends AbstractRunAction { diff --git a/ext/search_kit/Civi/Api4/Action/SearchDisplay/GetDefault.php b/ext/search_kit/Civi/Api4/Action/SearchDisplay/GetDefault.php new file mode 100644 index 0000000000..7618682a4c --- /dev/null +++ b/ext/search_kit/Civi/Api4/Action/SearchDisplay/GetDefault.php @@ -0,0 +1,234 @@ +savedSearch) && $this->checkPermissions && !\CRM_Core_Permission::check('administer CiviCRM data')) { + throw new UnauthorizedException('Access denied'); + } + $this->loadSavedSearch(); + // Use label from saved search + $label = $this->savedSearch['label'] ?? ''; + // Fall back on entity title as label + if (!strlen($label) && !empty($this->savedSearch['api_entity'])) { + $label = CoreUtil::getInfoItem($this->savedSearch['api_entity'], 'title_plural'); + } + $display = [ + 'saved_search_id' => $this->savedSearch['id'] ?? NULL, + 'name' => NULL, + 'label' => $label, + 'type' => 'table', + 'acl_bypass' => FALSE, + 'settings' => [ + 'button' => E::ts('Search'), + 'actions' => TRUE, + 'limit' => \Civi::settings()->get('default_pager_size'), + 'classes' => ['table', 'table-striped'], + 'pager' => [ + 'show_count' => TRUE, + 'expose_limit' => TRUE, + ], + 'columns' => [], + ], + ]; + foreach ($this->getSelectClause() as $key => $clause) { + $display['settings']['columns'][] = $this->configureColumn($clause, $key); + } + $display['settings']['columns'][] = [ + 'label' => '', + 'type' => 'menu', + 'icon' => 'fa-bars', + 'size' => 'btn-xs', + 'style' => 'secondary-outline', + 'alignment' => 'text-right', + 'links' => $this->getLinksMenu(), + ]; + $result[] = $display; + } + + /** + * @param array{fields: array, expr: SqlExpression, dataType: string} $clause + * @param string $key + * @return array + */ + private function configureColumn($clause, $key) { + $col = [ + 'type' => 'field', + 'key' => $key, + 'sortable' => !empty($clause['fields']), + 'label' => $this->getColumnLabel($clause['expr']), + ]; + $this->getColumnLink($col, $clause); + return $col; + } + + /** + * @param \Civi\Api4\Query\SqlExpression $expr + * @return string + */ + private function getColumnLabel(SqlExpression $expr) { + if ($expr instanceof SqlFunction) { + $args = []; + foreach ($expr->getArgs() as $arg) { + foreach ($arg['expr'] ?? [] as $ex) { + $args[] = $this->getColumnLabel($ex); + } + } + return '(' . $expr->getTitle() . ')' . ($args ? ' ' . implode(',', array_filter($args)) : ''); + } + if ($expr instanceof SqlEquation) { + $args = []; + foreach ($expr->getArgs() as $arg) { + $args[] = $this->getColumnLabel($arg['expr']); + } + return '(' . implode(',', array_filter($args)) . ')'; + } + elseif ($expr instanceof SqlField) { + $field = $this->getField($expr->getExpr()); + $label = ''; + if (!empty($field['explicit_join'])) { + $label = $this->getJoinLabel($field['explicit_join']) . ': '; + } + if (!empty($field['implicit_join'])) { + $field = $this->getField(substr($expr->getAlias(), 0, -1 - strlen($field['name']))); + } + return $label . $field['label']; + } + else { + return NULL; + } + } + + /** + * @param string $joinAlias + * @return string + */ + private function getJoinLabel($joinAlias) { + if (!isset($this->_joinMap)) { + $this->_joinMap = []; + $joinCount = [$this->savedSearch['api_entity'] => 1]; + foreach ($this->savedSearch['api_params']['join'] ?? [] as $join) { + [$entityName, $alias] = explode(' AS ', $join[0]); + $num = ''; + if (!empty($joinCount[$entityName])) { + $num = ' ' . (++$joinCount[$entityName]); + } + else { + $joinCount[$entityName] = 1; + } + $label = CoreUtil::getInfoItem($entityName, 'title'); + $this->_joinMap[$alias] = $label . $num; + } + } + return $this->_joinMap[$joinAlias]; + } + + /** + * @param array $col + * @param array{fields: array, expr: SqlExpression, dataType: string} $clause + */ + private function getColumnLink(&$col, $clause) { + if ($clause['expr'] instanceof SqlField || $clause['expr'] instanceof SqlFunctionGROUP_CONCAT) { + $field = $clause['fields'][0] ?? NULL; + if ($field && + CoreUtil::getInfoItem($field['entity'], 'label_field') === $field['name'] && + !empty(CoreUtil::getInfoItem($field['entity'], 'paths')['view']) + ) { + $col['link'] = [ + 'entity' => $field['entity'], + 'join' => implode('.', array_filter([$field['explicit_join'], $field['implicit_join']])), + 'action' => 'view', + ]; + // Hack to support links to relationships + if ($col['link']['entity'] === 'RelationshipCache') { + $col['link']['entity'] = 'Relationship'; + } + $col['title'] = E::ts('View %1', [1 => CoreUtil::getInfoItem($field['entity'], 'title')]); + } + } + } + + /** + * return array[] + */ + private function getLinksMenu() { + $menu = []; + $mainEntity = $this->savedSearch['api_entity'] ?? NULL; + if ($mainEntity && !$this->canAggregate(CoreUtil::getIdFieldName($mainEntity))) { + foreach (CoreUtil::getInfoItem($mainEntity, 'paths') as $action => $path) { + $link = $this->formatMenuLink($mainEntity, $action); + if ($link) { + $menu[] = $link; + } + } + } + $keys = ['entity' => TRUE, 'bridge' => TRUE]; + foreach ($this->getJoins() as $join) { + if (!$this->canAggregate($join['alias'] . '.' . CoreUtil::getIdFieldName($join['entity']))) { + foreach (array_filter(array_intersect_key($join, $keys)) as $joinEntity) { + foreach (CoreUtil::getInfoItem($joinEntity, 'paths') as $action => $path) { + $link = $this->formatMenuLink($joinEntity, $action, $join['alias']); + if ($link) { + $menu[] = $link; + } + } + } + } + } + return $menu; + } + + /** + * @param string $entity + * @param string $action + * @param string $joinAlias + * @return array|NULL + */ + private function formatMenuLink(string $entity, string $action, string $joinAlias = NULL) { + if ($joinAlias && $entity === $this->getJoin($joinAlias)['entity']) { + $entityLabel = $this->getJoinLabel($joinAlias); + } + else { + $entityLabel = TRUE; + } + $link = Display::getEntityLinks($entity, $entityLabel)[$action] ?? NULL; + return $link ? $link + ['join' => $joinAlias] : NULL; + } + +} diff --git a/ext/search_kit/Civi/Api4/Action/SearchDisplay/Run.php b/ext/search_kit/Civi/Api4/Action/SearchDisplay/Run.php index 3ba06edbd3..564545765d 100644 --- a/ext/search_kit/Civi/Api4/Action/SearchDisplay/Run.php +++ b/ext/search_kit/Civi/Api4/Action/SearchDisplay/Run.php @@ -5,6 +5,10 @@ namespace Civi\Api4\Action\SearchDisplay; /** * Load the results for rendering a SearchDisplay. * + * @method $this setReturn(string $return) + * @method string getReturn() + * @method $this setLimit(int $limit) + * @method int getLimit() * @package Civi\Api4\Action\SearchDisplay */ class Run extends AbstractRunAction { diff --git a/ext/search_kit/Civi/Api4/Action/SearchDisplay/SavedSearchInspectorTrait.php b/ext/search_kit/Civi/Api4/Action/SearchDisplay/SavedSearchInspectorTrait.php new file mode 100644 index 0000000000..51210eb7d6 --- /dev/null +++ b/ext/search_kit/Civi/Api4/Action/SearchDisplay/SavedSearchInspectorTrait.php @@ -0,0 +1,145 @@ +savedSearch)) { + $this->savedSearch = SavedSearch::get(FALSE) + ->addWhere('name', '=', $this->savedSearch) + ->execute()->single(); + } + } + + /** + * Returns field definition for a given field or NULL if not found + * @param $fieldName + * @return array|null + */ + protected function getField($fieldName) { + return $this->getQuery() ? $this->getQuery()->getField($fieldName, FALSE) : NULL; + } + + /** + * @param $joinAlias + * @return array{entity: string, alias: string, table: string, bridge: string|NULL}|NULL + */ + protected function getJoin($joinAlias) { + return $this->getQuery()->getExplicitJoin($joinAlias); + } + + /** + * @return array{entity: string, alias: string, table: string, bridge: string|NULL}[] + */ + protected function getJoins() { + return $this->getQuery() ? $this->getQuery()->getExplicitJoins() : []; + } + + /** + * @return \Civi\Api4\Query\Api4SelectQuery + */ + private function getQuery() { + if (!$this->_selectQuery && !empty($this->savedSearch['api_entity'])) { + $api = \Civi\API\Request::create($this->savedSearch['api_entity'], 'get', $this->savedSearch['api_params']); + $this->_selectQuery = new \Civi\Api4\Query\Api4SelectQuery($api); + } + return $this->_selectQuery; + } + + /** + * Returns the select clause enhanced with metadata + * + * @return array{fields: array, expr: SqlExpression, dataType: string}[] + */ + protected function getSelectClause() { + if (!isset($this->_selectClause)) { + $this->_selectClause = []; + foreach ($this->savedSearch['api_params']['select'] ?? [] as $selectExpr) { + $expr = SqlExpression::convert($selectExpr, TRUE); + $item = [ + 'fields' => [], + 'expr' => $expr, + 'dataType' => $expr->getDataType(), + ]; + foreach ($expr->getFields() as $fieldName) { + $fieldMeta = $this->getField($fieldName); + if ($fieldMeta) { + $item['fields'][] = $fieldMeta; + } + } + if (!isset($item['dataType']) && $item['fields']) { + $item['dataType'] = $item['fields'][0]['data_type']; + } + $this->_selectClause[$expr->getAlias()] = $item; + } + } + return $this->_selectClause; + } + + /** + * @param string $key + * @return array{fields: array, expr: SqlExpression, dataType: string}|NULL + */ + protected function getSelectExpression($key) { + return $this->getSelectClause()[$key] ?? NULL; + } + + /** + * Determines if a column belongs to an aggregate grouping + * @param string $fieldPath + * @return bool + */ + private function canAggregate($fieldPath) { + $apiParams = $this->savedSearch['api_params'] ?? []; + $field = $this->getField($fieldPath); + + // If the query does not use grouping or the field doesn't exist, never + if (empty($apiParams['groupBy']) || !$field) { + return FALSE; + } + // If the column is used for a groupBy, no + if (in_array($fieldPath, $apiParams['groupBy'])) { + return FALSE; + } + + // If the entity this column belongs to is being grouped by id, then also no + $suffix = strstr($fieldPath, ':') ?: ''; + $idField = substr($fieldPath, 0, 0 - strlen($field['name'] . $suffix)) . CoreUtil::getIdFieldName($field['entity']); + return !in_array($idField, $apiParams['groupBy']); + } + +} diff --git a/ext/search_kit/Civi/Api4/SearchDisplay.php b/ext/search_kit/Civi/Api4/SearchDisplay.php index 41ea74c95d..26ccfecd68 100644 --- a/ext/search_kit/Civi/Api4/SearchDisplay.php +++ b/ext/search_kit/Civi/Api4/SearchDisplay.php @@ -31,13 +31,22 @@ class SearchDisplay extends Generic\DAOEntity { /** * @param bool $checkPermissions - * @return Action\SearchDisplay\Run + * @return Action\SearchDisplay\Download */ public static function download($checkPermissions = TRUE) { return (new Action\SearchDisplay\Download(__CLASS__, __FUNCTION__)) ->setCheckPermissions($checkPermissions); } + /** + * @param bool $checkPermissions + * @return Action\SearchDisplay\GetDefault + */ + public static function getDefault($checkPermissions = TRUE) { + return (new Action\SearchDisplay\GetDefault(__CLASS__, __FUNCTION__)) + ->setCheckPermissions($checkPermissions); + } + public static function permissions() { $permissions = parent::permissions(); $permissions['default'] = ['administer CiviCRM data']; diff --git a/ext/search_kit/Civi/Search/Admin.php b/ext/search_kit/Civi/Search/Admin.php index 5a912e3c7d..6e94549377 100644 --- a/ext/search_kit/Civi/Search/Admin.php +++ b/ext/search_kit/Civi/Search/Admin.php @@ -14,6 +14,7 @@ namespace Civi\Search; use Civi\Api4\Action\SearchDisplay\AbstractRunAction; use Civi\Api4\Query\SqlEquation; use Civi\Api4\Query\SqlFunction; +use Civi\Api4\SearchDisplay; use Civi\Api4\Tag; use CRM_Search_ExtensionUtil as E; @@ -38,6 +39,7 @@ class Admin { 'displayTypes' => Display::getDisplayTypes(['id', 'name', 'label', 'description', 'icon']), 'styles' => \CRM_Utils_Array::makeNonAssociative(self::getStyles()), 'defaultPagerSize' => \Civi::settings()->get('default_pager_size'), + 'defaultDisplay' => SearchDisplay::getDefault(FALSE)->setSavedSearch(['id' => NULL])->execute()->first(), 'afformEnabled' => $extensions->isActiveModule('afform'), 'afformAdminEnabled' => $extensions->isActiveModule('afform_admin'), 'tags' => Tag::get() diff --git a/ext/search_kit/Civi/Search/Display.php b/ext/search_kit/Civi/Search/Display.php index 302164de06..3268807f43 100644 --- a/ext/search_kit/Civi/Search/Display.php +++ b/ext/search_kit/Civi/Search/Display.php @@ -11,6 +11,9 @@ namespace Civi\Search; +use CRM_Search_ExtensionUtil as E; +use Civi\Api4\Utils\CoreUtil; + /** * Class Display * @package Civi\Search @@ -45,4 +48,57 @@ class Display { } } + /** + * Returns all links for a given entity + * + * @param string $entity + * @param string|bool $addLabel + * Pass a string to supply a custom label, TRUE to use the default, + * or FALSE to keep the %1 placeholders in the text (used for the admin UI) + * @return array[]|null + */ + public static function getEntityLinks(string $entity, $addLabel = FALSE) { + $paths = CoreUtil::getInfoItem($entity, 'paths'); + // Hack to support links to relationships + if ($entity === 'RelationshipCache') { + $entity = 'Relationship'; + } + if ($addLabel === TRUE) { + $addLabel = CoreUtil::getInfoItem($entity, 'title'); + } + $label = $addLabel ? [1 => $addLabel] : []; + if ($paths) { + $links = [ + 'view' => [ + 'action' => 'view', + 'entity' => $entity, + 'text' => E::ts('View %1', $label), + 'icon' => 'fa-external-link', + 'style' => 'default', + // Contacts and cases are too cumbersome to view in a popup + 'target' => in_array($entity, ['Contact', 'Case']) ? '_blank' : 'crm-popup', + ], + 'update' => [ + 'action' => 'update', + 'entity' => $entity, + 'text' => E::ts('Edit %1', $label), + 'icon' => 'fa-pencil', + 'style' => 'default', + // Contacts and cases are too cumbersome to edit in a popup + 'target' => in_array($entity, ['Contact', 'Case']) ? '_blank' : 'crm-popup', + ], + 'delete' => [ + 'action' => 'delete', + 'entity' => $entity, + 'text' => E::ts('Delete %1', $label), + 'icon' => 'fa-trash', + 'style' => 'danger', + 'target' => 'crm-popup', + ], + ]; + return array_intersect_key($links, $paths) ?: NULL; + } + return NULL; + } + } diff --git a/ext/search_kit/ang/crmSearchAdmin/crmSearchAdmin.component.js b/ext/search_kit/ang/crmSearchAdmin/crmSearchAdmin.component.js index 64868513b7..1100fe36f6 100644 --- a/ext/search_kit/ang/crmSearchAdmin/crmSearchAdmin.component.js +++ b/ext/search_kit/ang/crmSearchAdmin/crmSearchAdmin.component.js @@ -715,25 +715,23 @@ if (display.id && display.name) { findDisplays.push(['search_displays', 'CONTAINS', ctrl.savedSearch.name + '.' + display.name]); } - }, []); - if (findDisplays.length) { - afformLoad = crmApi4('Afform', 'get', { - select: ['name', 'title', 'search_displays'], - where: [['OR', findDisplays]] - }).then(function(afforms) { - ctrl.afforms = {}; - _.each(afforms, function(afform) { - _.each(_.uniq(afform.search_displays), function(searchNameDisplayName) { - var displayName = searchNameDisplayName.split('.')[1]; - ctrl.afforms[displayName] = ctrl.afforms[displayName] || []; - ctrl.afforms[displayName].push({ - title: afform.title, - link: ctrl.afformAdminEnabled ? CRM.url('civicrm/admin/afform#/edit/' + afform.name) : '', - }); + }, [['search_displays', 'CONTAINS', ctrl.savedSearch.name]]); + afformLoad = crmApi4('Afform', 'get', { + select: ['name', 'title', 'search_displays'], + where: [['OR', findDisplays]] + }).then(function(afforms) { + ctrl.afforms = {}; + _.each(afforms, function(afform) { + _.each(_.uniq(afform.search_displays), function(searchNameDisplayName) { + var displayName = searchNameDisplayName.split('.')[1] || ''; + ctrl.afforms[displayName] = ctrl.afforms[displayName] || []; + ctrl.afforms[displayName].push({ + title: afform.title, + link: ctrl.afformAdminEnabled ? CRM.url('civicrm/admin/afform#/edit/' + afform.name) : '', }); }); }); - } + }); } } diff --git a/ext/search_kit/ang/crmSearchAdmin/displays/searchAdminDisplayTable.component.js b/ext/search_kit/ang/crmSearchAdmin/displays/searchAdminDisplayTable.component.js index c4df08c5c5..7c45809d27 100644 --- a/ext/search_kit/ang/crmSearchAdmin/displays/searchAdminDisplayTable.component.js +++ b/ext/search_kit/ang/crmSearchAdmin/displays/searchAdminDisplayTable.component.js @@ -35,11 +35,7 @@ this.$onInit = function () { if (!ctrl.display.settings) { - ctrl.display.settings = { - limit: CRM.crmSearchAdmin.defaultPagerSize, - classes: ['table'], - pager: {} - }; + ctrl.display.settings = _.extend({}, CRM.crmSearchAdmin.defaultDisplay.settings, {columns: null}); } // Displays created prior to 5.43 may not have this property ctrl.display.settings.classes = ctrl.display.settings.classes || []; diff --git a/ext/search_kit/ang/crmSearchAdmin/resultsTable/crmSearchAdminResultsTable.component.js b/ext/search_kit/ang/crmSearchAdmin/resultsTable/crmSearchAdminResultsTable.component.js index b74b81933b..5c0ee89aee 100644 --- a/ext/search_kit/ang/crmSearchAdmin/resultsTable/crmSearchAdminResultsTable.component.js +++ b/ext/search_kit/ang/crmSearchAdmin/resultsTable/crmSearchAdminResultsTable.component.js @@ -15,82 +15,24 @@ // Mix in traits to this controller ctrl = angular.extend(this, searchDisplayBaseTrait, searchDisplayTasksTrait, searchDisplaySortableTrait); - // Output user-facing name/label fields as a link, if possible - function getViewLink(fieldExpr, links) { - var info = searchMeta.parseExpr(fieldExpr), - firstField = _.findWhere(info.args, {type: 'field'}), - entity = firstField && searchMeta.getEntity(firstField.field.entity); - if (entity && firstField.field.fieldName === entity.label_field) { - var joinEntity = searchMeta.getJoinEntity(info); - return _.find(links, {join: joinEntity, action: 'view'}); - } - } - function buildSettings() { - var links = ctrl.crmSearchAdmin.buildLinks(); ctrl.apiEntity = ctrl.search.api_entity; - ctrl.display = { - type: 'table', - settings: { - limit: CRM.crmSearchAdmin.defaultPagerSize, - pager: {show_count: true, expose_limit: true}, - actions: true, - classes: ['table', 'table-striped'], - button: ts('Search'), - columns: _.transform(ctrl.search.api_params.select, function(columns, fieldExpr) { - var column = {label: true, sortable: true}, - link = getViewLink(fieldExpr, links); - if (link) { - column.title = link.title; - column.link = { - path: link.path, - target: '_blank' - }; - } - columns.push(searchMeta.fieldToColumn(fieldExpr, column)); - }) - } - }; - if (links.length) { - ctrl.display.settings.columns.push({ - text: '', - icon: 'fa-bars', - type: 'menu', - size: 'btn-xs', - style: 'secondary-outline', - alignment: 'text-right', - links: _.transform(links, function(links, link) { - if (!link.isAggregate) { - links.push({ - path: link.path, - text: link.title, - icon: link.icon, - style: link.style, - target: link.action === 'view' ? '_blank' : 'crm-popup' - }); - } - }) - }); - } + ctrl.settings = _.cloneDeep(CRM.crmSearchAdmin.defaultDisplay.settings); + ctrl.settings.columns = _.transform(ctrl.search.api_params.select, function(columns, fieldExpr) { + columns.push(searchMeta.fieldToColumn(fieldExpr, {label: true, sortable: true})); + }).concat(ctrl.settings.columns); ctrl.debug = { apiParams: JSON.stringify(ctrl.search.api_params, null, 2) }; - ctrl.settings = ctrl.display.settings; - setLabel(); ctrl.results = null; ctrl.rowCount = null; ctrl.page = 1; } - function setLabel() { - ctrl.display.label = ctrl.search.label || searchMeta.getEntity(ctrl.search.api_entity).title_plural; - } - this.$onInit = function() { buildSettings(); this.initializeDisplay($scope, $element); $scope.$watch('$ctrl.search.api_params', buildSettings, true); - $scope.$watch('$ctrl.search.label', setLabel); }; // Add callbacks for pre & post run -- 2.25.1