From fefdb396be43fd9a7390824722b39b67f3db73c1 Mon Sep 17 00:00:00 2001 From: colemanw Date: Mon, 30 Oct 2023 10:32:40 -0400 Subject: [PATCH] APIv4 - Add getLinks action --- CRM/Utils/String.php | 22 ++ Civi/Api4/Action/GetLinks.php | 212 ++++++++++++++++++ Civi/Api4/Generic/AbstractEntity.php | 9 + Civi/Api4/Generic/Traits/GetSetValueTrait.php | 33 ++- .../Traits/SavedSearchInspectorTrait.php | 7 +- .../Service/Links/ContactLinksProvider.php | 77 +++++++ .../Links/RelationshipCacheLinksProvider.php | 37 +++ tests/phpunit/api/v4/Action/GetLinksTest.php | 107 +++++++++ 8 files changed, 494 insertions(+), 10 deletions(-) create mode 100644 Civi/Api4/Action/GetLinks.php create mode 100644 Civi/Api4/Service/Links/ContactLinksProvider.php create mode 100644 Civi/Api4/Service/Links/RelationshipCacheLinksProvider.php create mode 100644 tests/phpunit/api/v4/Action/GetLinksTest.php diff --git a/CRM/Utils/String.php b/CRM/Utils/String.php index bb83724dc2..2d716d457e 100644 --- a/CRM/Utils/String.php +++ b/CRM/Utils/String.php @@ -1044,4 +1044,26 @@ class CRM_Utils_String { return $templateString; } + /** + * Parse a string for SearchKit-style [square_bracket] tokens. + * @internal + * @param string $raw + * @return array + */ + public static function getSquareTokens(string $raw): array { + $matches = $tokens = []; + if (str_contains($raw, '[')) { + preg_match_all('/\\[([^]]+)\\]/', $raw, $matches); + foreach (array_unique($matches[1]) as $match) { + [$field, $suffix] = array_pad(explode(':', $match), 2, NULL); + $tokens[$match] = [ + 'token' => "[$match]", + 'field' => $field, + 'suffix' => $suffix, + ]; + } + } + return $tokens; + } + } diff --git a/Civi/Api4/Action/GetLinks.php b/Civi/Api4/Action/GetLinks.php new file mode 100644 index 0000000000..9978ab4bdc --- /dev/null +++ b/Civi/Api4/Action/GetLinks.php @@ -0,0 +1,212 @@ + 'Individual']) + * + * @var array + */ + protected $values = []; + + /** + * @var bool|string + */ + protected $entityTitle = TRUE; + + public function _run(Result $result) { + parent::_run($result); + // Do expensive processing *after* parent has filtered down the result per the WHERE clause + if ($this->getSelect() !== ['row_count']) { + $links = $result->getArrayCopy(); + $this->replaceTokens($links); + $this->filterByPermission($links); + $result->exchangeArray(array_values($links)); + } + } + + protected function getRecords(): array { + $entityName = $this->getEntityName(); + $locale = $GLOBALS['tsLocale'] ?? ''; + $cacheKey = "api4.$entityName.links.$locale"; + $links = \Civi::cache('metadata')->get($cacheKey); + if (!isset($links)) { + $links = $this->fetchLinks(); + \Civi::cache('metadata')->set($cacheKey, $links); + } + return $links; + } + + /** + * Get the full set of links for an entity + * + * This function sits behind a cache so the heavy processing happens here. + * @return array + */ + private function fetchLinks(): array { + $links = []; + $apiActionMap = [ + 'add' => 'create', + 'delete' => 'delete', + 'view' => 'get', + 'browse' => 'get', + 'export' => 'get', + 'preview' => 'get', + ]; + $entityName = $this->getEntityName(); + $paths = CoreUtil::getInfoItem($entityName, 'paths') ?? []; + foreach ($paths as $actionName => $path) { + $actionKey = \CRM_Core_Action::mapItem($actionName); + $link = [ + 'ui_action' => $actionName, + 'api_action' => $apiActionMap[$actionName] ?? 'update', + 'api_values' => NULL, + 'entity' => $entityName, + 'path' => $path, + 'text' => \CRM_Core_Action::getTitle($actionKey, '%1'), + 'icon' => \CRM_Core_Action::getIcon($actionKey), + 'weight' => (int) \CRM_Core_Action::getWeight($actionKey), + 'target' => 'crm-popup', + ]; + $links[] = $link; + } + // Allow entity to override with extra links + $event = GenericHookEvent::create(['entity' => $entityName, 'links' => &$links]); + \Civi::dispatcher()->dispatch('civi.api4.getLinks', $event); + // Fill in optional keys from hook links + foreach ($links as $index => $link) { + $links[$index] += [ + 'api_values' => NULL, + 'entity' => $entityName, + 'weight' => 0, + 'target' => 'crm-popup', + ]; + } + usort($links, ['CRM_Utils_Sort', 'cmpFunc']); + return $links; + } + + /** + * Replace [square_bracket] tokens in the path and `%1` placeholders in the text. + * @param array $links + * @return void + */ + private function replaceTokens(array &$links): void { + // Text was translated with `%1` placeholders preserved so it could be cached + // Now we'll replace `%1` placeholders with the entityTitle, unless FALSE + $entityTitle = $this->entityTitle === TRUE ? CoreUtil::getInfoItem($this->getEntityName(), 'title') : $this->entityTitle; + foreach ($links as &$link) { + // Swap placeholders with $entityTitle (TRUE means use default title) + if ($entityTitle !== FALSE && !empty($link['text'])) { + $link['text'] = str_replace('%1', $entityTitle, $link['text']); + } + // Swap path tokens with values + if ($this->getValues() && !empty($link['path'])) { + $tokens = \CRM_Utils_String::getSquareTokens($link['path']); + foreach ($tokens as $fieldExpr => $token) { + $value = $this->getValue($fieldExpr); + if (isset($value)) { + $link['path'] = str_replace($token['token'], $value, $link['path']); + } + } + } + } + } + + private function filterByPermission(array &$links): void { + if (!$this->getCheckPermissions()) { + return; + } + $allowedApiActions = $this->getAllowedEntityActions(); + foreach ($links as $index => $link) { + if (!in_array($link['api_action'], $allowedApiActions, TRUE)) { + unset($links[$index]); + continue; + } + $values = array_merge($this->values, (array) $link['api_values']); + // These 2 lines are the heart of the `checkAccess` api action. + // Calling this directly is more performant than going through the api wrapper + $apiRequest = Request::create($link['entity'], $link['api_action'], ['version' => 4, 'checkPermissions' => TRUE]); + if (!CoreUtil::checkAccessRecord($apiRequest, $values)) { + unset($links[$index]); + } + } + } + + private function getAllowedEntityActions(): array { + $uid = \CRM_Core_Session::getLoggedInContactID(); + $entityName = $this->getEntityName(); + if (!isset(\Civi::$statics[__CLASS__]['actions'][$entityName][$uid])) { + \Civi::$statics[__CLASS__]['actions'][$entityName][$uid] = civicrm_api4($entityName, 'getActions', ['checkPermissions' => TRUE])->column('name'); + } + return \Civi::$statics[__CLASS__]['actions'][$entityName][$uid]; + } + + public function fields() { + return [ + [ + 'name' => 'ui_action', + 'description' => 'Action corresponding to CRM_Core_Action', + ], + [ + 'name' => 'api_action', + 'description' => 'Action corresponding to API.getActions', + ], + [ + 'name' => 'api_values', + 'data_type' => 'Array', + 'description' => 'API values associated with this action (e.g. for a sub_type)', + ], + [ + 'name' => 'entity', + 'description' => 'API entity name', + ], + [ + 'name' => 'path', + 'description' => 'Link path', + ], + [ + 'name' => 'text', + 'description' => 'Link text', + ], + [ + 'name' => 'icon', + 'description' => 'Link icon css class', + ], + [ + 'name' => 'weight', + 'data_type' => 'Integer', + 'description' => 'Sort order', + ], + [ + 'name' => 'target', + 'description' => 'HTML target attribute', + ], + ]; + } + +} diff --git a/Civi/Api4/Generic/AbstractEntity.php b/Civi/Api4/Generic/AbstractEntity.php index 432ab7b8d4..bdcf97551c 100644 --- a/Civi/Api4/Generic/AbstractEntity.php +++ b/Civi/Api4/Generic/AbstractEntity.php @@ -56,6 +56,15 @@ abstract class AbstractEntity { return new CheckAccessAction(static::getEntityName(), __FUNCTION__); } + /** + * @param bool $checkPermissions + * @return \Civi\Api4\Action\GetLinks + */ + public static function getLinks($checkPermissions = TRUE) { + return (new \Civi\Api4\Action\GetLinks(static::getEntityName(), __FUNCTION__)) + ->setCheckPermissions($checkPermissions); + } + /** * Returns a list of permissions needed to access the various actions in this api. * diff --git a/Civi/Api4/Generic/Traits/GetSetValueTrait.php b/Civi/Api4/Generic/Traits/GetSetValueTrait.php index fb9649eb5d..5d9a3530cc 100644 --- a/Civi/Api4/Generic/Traits/GetSetValueTrait.php +++ b/Civi/Api4/Generic/Traits/GetSetValueTrait.php @@ -12,6 +12,8 @@ namespace Civi\Api4\Generic\Traits; +use Civi\Api4\Utils\FormattingUtil; + /** * Trait for actions with a `$values` array */ @@ -45,13 +47,36 @@ trait GetSetValueTrait { } /** - * Retrieve a single value + * Retrieve a single value, transforming pseudoconstants as necessary * - * @param string $fieldName + * @param string $fieldExpr * @return mixed|null */ - public function getValue(string $fieldName) { - return $this->values[$fieldName] ?? NULL; + public function getValue(string $fieldExpr) { + if (array_key_exists($fieldExpr, $this->values)) { + return $this->values[$fieldExpr]; + } + // If exact match not found, try pseudoconstants + [$fieldName, $suffix] = array_pad(explode(':', $fieldExpr), 2, NULL); + $field = civicrm_api4($this->getEntityName(), 'getFields', [ + 'checkPermissions' => FALSE, + 'where' => [['name', '=', $fieldName]], + ])->first(); + if (empty($field['options'])) { + return NULL; + } + foreach ($this->values as $key => $value) { + // Resolve pseudoconstant expressions + if (!array_key_exists($fieldName, $this->values) && str_starts_with($key, "$fieldName:")) { + $options = FormattingUtil::getPseudoconstantList($field, $key, $this->getValues()); + $this->values[$fieldName] = FormattingUtil::replacePseudoconstant($options, $value, TRUE); + } + } + if ($suffix && array_key_exists($fieldName, $this->values)) { + $options = FormattingUtil::getPseudoconstantList($field, $fieldExpr, $this->getValues()); + $this->values[$fieldExpr] = FormattingUtil::replacePseudoconstant($options, $this->values[$fieldName]); + } + return $this->values[$fieldExpr] ?? NULL; } /** diff --git a/Civi/Api4/Generic/Traits/SavedSearchInspectorTrait.php b/Civi/Api4/Generic/Traits/SavedSearchInspectorTrait.php index f10c09e818..3d25cf1411 100644 --- a/Civi/Api4/Generic/Traits/SavedSearchInspectorTrait.php +++ b/Civi/Api4/Generic/Traits/SavedSearchInspectorTrait.php @@ -361,12 +361,7 @@ trait SavedSearchInspectorTrait { * @return array */ protected function getTokens(string $str): array { - if (strpos($str, '[') === FALSE) { - return []; - } - $tokens = []; - preg_match_all('/\\[([^]]+)\\]/', $str, $tokens); - return array_unique($tokens[1]); + return array_keys(\CRM_Utils_String::getSquareTokens($str)); } /** diff --git a/Civi/Api4/Service/Links/ContactLinksProvider.php b/Civi/Api4/Service/Links/ContactLinksProvider.php new file mode 100644 index 0000000000..8bdad79ee0 --- /dev/null +++ b/Civi/Api4/Service/Links/ContactLinksProvider.php @@ -0,0 +1,77 @@ + 'alterContactLinks', + ]; + } + + public static function alterContactLinks(GenericHookEvent $e): void { + if (CoreUtil::isContact($e->entity)) { + foreach ($e->links as $index => $link) { + // Contacts are too cumbersome to view in a popup + if (in_array($link['ui_action'], ['view', 'update'], TRUE)) { + $e->links[$index]['target'] = ''; + } + // Unset the generic "add" link and replace it with links per contact-type and sub-type + if ($link['ui_action'] === 'add') { + $addTemplate = $link; + unset($e->links[$index]); + } + } + if ($e->entity === 'Contact') { + foreach (\CRM_Contact_BAO_ContactType::basicTypes() as $contactType) { + self::addLinks($contactType, $addTemplate, $e); + } + } + else { + self::addLinks($e->entity, $addTemplate, $e); + } + } + } + + private static function addLinks(string $contactType, array $addTemplate, GenericHookEvent $e) { + $addTemplate['path'] = str_replace('[contact_type]', $contactType, $addTemplate['path']); + $link = $addTemplate; + if ($e->entity === 'Contact') { + $link['text'] = str_replace('%1', CoreUtil::getInfoItem($contactType, 'title'), $link['text']); + $link['api_values'] = ['contact_type' => $contactType]; + $link['icon'] = CoreUtil::getInfoItem($contactType, 'icon'); + } + $e->links[] = $link; + $subTypes = \CRM_Contact_BAO_ContactType::subTypeInfo($contactType); + $labels = array_column($subTypes, 'label'); + array_multisort($labels, SORT_NATURAL, $subTypes); + foreach ($subTypes as $subType) { + $addTemplate['weight']++; + $link = $addTemplate; + $link['path'] .= '&cst=' . $subType['name']; + $link['icon'] = $subType['icon'] ?? $link['icon']; + $link['text'] = str_replace('%1', $subType['label'], $link['text']); + $link['api_values'] = ['contact_sub_type' => $subType['name']]; + $e->links[] = $link; + } + } + +} diff --git a/Civi/Api4/Service/Links/RelationshipCacheLinksProvider.php b/Civi/Api4/Service/Links/RelationshipCacheLinksProvider.php new file mode 100644 index 0000000000..15f645c960 --- /dev/null +++ b/Civi/Api4/Service/Links/RelationshipCacheLinksProvider.php @@ -0,0 +1,37 @@ + 'alterRelationshipCacheLinks', + ]; + } + + public static function alterRelationshipCacheLinks(GenericHookEvent $e): void { + if ($e->entity === 'RelationshipCache') { + foreach ($e->links as &$link) { + $link['entity'] = 'Relationship'; + } + } + } + +} diff --git a/tests/phpunit/api/v4/Action/GetLinksTest.php b/tests/phpunit/api/v4/Action/GetLinksTest.php new file mode 100644 index 0000000000..fe7355e0e5 --- /dev/null +++ b/tests/phpunit/api/v4/Action/GetLinksTest.php @@ -0,0 +1,107 @@ +addWhere('api_action', '=', 'create') + ->execute()->indexBy('path'); + $this->assertEquals('Add Individual', $links['civicrm/contact/add?reset=1&ct=Individual']['text']); + $this->assertEquals('fa-user', $links['civicrm/contact/add?reset=1&ct=Individual']['icon']); + $this->assertEquals('Add Organization', $links['civicrm/contact/add?reset=1&ct=Organization']['text']); + $this->assertEquals('Add Household', $links['civicrm/contact/add?reset=1&ct=Household']['text']); + $this->assertEquals(['contact_type' => 'Household'], $links['civicrm/contact/add?reset=1&ct=Household']['api_values']); + + $links = Contact::getLinks(FALSE) + ->addWhere('ui_action', 'IN', ['view', 'update', 'delete']) + ->execute()->indexBy('ui_action'); + $this->assertEquals('', $links['view']['target']); + $this->assertEquals('', $links['update']['target']); + $this->assertEquals('crm-popup', $links['delete']['target']); + } + + public function testIndividualLinks(): void { + // Add some individual contact types + foreach (['Squirrel', 'Chipmunk', 'Rabbit'] as $type) { + ContactType::create(FALSE) + ->addValue('label', $type) + ->addValue('name', substr($type, 0, 3)) + ->addValue('icon', strtolower("fa-$type")) + ->addValue('parent_id:name', 'Individual') + ->execute(); + } + // Red herring belongs to Organization not Individual + ContactType::create(FALSE) + ->addValue('label', 'Herring') + ->addValue('name', 'Her') + ->addValue('icon', "fa-herring") + ->addValue('parent_id:name', 'Organization') + ->execute(); + $links = Individual::getLinks(FALSE) + ->addWhere('api_action', '=', 'create') + ->execute(); + $this->assertStringContainsString('ct=Individual', $links[0]['path']); + $this->assertEquals('Add Individual', $links[0]['text']); + $this->assertEquals(0, $links[0]['weight']); + $this->assertNull($links[0]['api_values']); + $links = $links->indexBy('text'); + $this->assertEquals('fa-squirrel', $links['Add Squirrel']['icon']); + $this->assertGreaterThan($links['Add Rabbit']['weight'], $links['Add Squirrel']['weight']); + $this->assertGreaterThan($links['Add Chipmunk']['weight'], $links['Add Rabbit']['weight']); + $this->assertStringContainsString('ct=Individual', $links['Add Squirrel']['path']); + $this->assertStringContainsString('cst=Squ', $links['Add Squirrel']['path']); + $this->assertArrayNotHasKey('Add Organization', (array) $links); + $this->assertArrayNotHasKey('Add Household', (array) $links); + $this->assertArrayNotHasKey('Add Herring', (array) $links); + } + + public function testRelationshipCacheLinks(): void { + $links = RelationshipCache::getLinks(FALSE) + ->addValue('relationship_id', 1) + ->addValue('near_contact_id', 2) + ->addValue('orientation:name', 'a_b') + ->execute()->indexBy('ui_action'); + $this->assertGreaterThan(1, $links->count()); + foreach ($links as $link) { + $this->assertEquals('Relationship', $link['entity']); + } + $this->assertStringContainsString('id=1', $links['view']['path']); + $this->assertStringContainsString('id=1', $links['update']['path']); + $this->assertStringContainsString('id=1', $links['delete']['path']); + $this->assertStringContainsString('cid=2', $links['add']['path']); + $this->assertStringContainsString('cid=2', $links['view']['path']); + $this->assertStringContainsString('cid=2', $links['update']['path']); + $this->assertStringContainsString('rtype=a_b', $links['update']['path']); + $this->assertStringContainsString('cid=2', $links['delete']['path']); + } + +} -- 2.25.1