From 9df894bb7eb8e3ce4a93c666e75aad88024813ca Mon Sep 17 00:00:00 2001 From: Coleman Watts Date: Sun, 28 Nov 2021 11:06:21 -0500 Subject: [PATCH] APIv4 - Add SortableEntity and ManagedEntity traits to Navigation menu entity Excludes 'weight' from managed entity calculations for references, adds unit tests for the interaction of managed entities and sortable entities --- CRM/Core/Module.php | 2 +- CRM/Utils/Weight.php | 13 +- Civi/Api4/Generic/ExportAction.php | 48 +++-- Civi/Api4/Generic/Traits/DAOActionTrait.php | 9 +- Civi/Api4/Navigation.php | 5 +- .../api/v4/Entity/ManagedEntityTest.php | 169 ++++++++++++++++++ 6 files changed, 224 insertions(+), 22 deletions(-) diff --git a/CRM/Core/Module.php b/CRM/Core/Module.php index 39e006bb9d..7f0daefb2a 100644 --- a/CRM/Core/Module.php +++ b/CRM/Core/Module.php @@ -48,7 +48,7 @@ class CRM_Core_Module { * @param bool $fresh * Force new results? * - * @return array + * @return CRM_Core_Module[] */ public static function getAll($fresh = FALSE) { static $result; diff --git a/CRM/Utils/Weight.php b/CRM/Utils/Weight.php index b98353882f..ec0176c614 100644 --- a/CRM/Utils/Weight.php +++ b/CRM/Utils/Weight.php @@ -305,10 +305,15 @@ class CRM_Utils_Weight { // invalid field specified. abort. throw new CRM_Core_Exception("Invalid field '$fieldName' for $daoName"); } - $fieldNum++; - $whereConditions[] = "$fieldName = %$fieldNum"; - $fieldType = $fields[$fieldName]['type']; - $params[$fieldNum] = [$value, CRM_Utils_Type::typeToString($fieldType)]; + if (CRM_Utils_System::isNull($value)) { + $whereConditions[] = "$fieldName IS NULL"; + } + else { + $fieldNum++; + $whereConditions[] = "$fieldName = %$fieldNum"; + $fieldType = $fields[$fieldName]['type']; + $params[$fieldNum] = [$value, CRM_Utils_Type::typeToString($fieldType)]; + } } } $where = implode(' AND ', $whereConditions); diff --git a/Civi/Api4/Generic/ExportAction.php b/Civi/Api4/Generic/ExportAction.php index b7de9e8d77..a935eda87a 100644 --- a/Civi/Api4/Generic/ExportAction.php +++ b/Civi/Api4/Generic/ExportAction.php @@ -69,14 +69,15 @@ class ExportAction extends AbstractAction { * @param int $entityId * @param \Civi\Api4\Generic\Result $result * @param string $parentName + * @param array $excludeFields */ - private function exportRecord(string $entityType, int $entityId, Result $result, $parentName = NULL) { + private function exportRecord(string $entityType, int $entityId, Result $result, $parentName = NULL, $excludeFields = []) { if (isset($this->exportedEntities[$entityType][$entityId])) { throw new \API_Exception("Circular reference detected: attempted to export $entityType id $entityId multiple times."); } $this->exportedEntities[$entityType][$entityId] = TRUE; $select = $pseudofields = []; - $allFields = $this->getFieldsForExport($entityType, TRUE); + $allFields = $this->getFieldsForExport($entityType, TRUE, $excludeFields); foreach ($allFields as $field) { // Use implicit join syntax but only if the fk entity has a `name` field if (!empty($field['fk_entity']) && array_key_exists('name', $this->getFieldsForExport($field['fk_entity']))) { @@ -135,12 +136,34 @@ class ExportAction extends AbstractAction { /** @var \CRM_Core_DAO $dao */ $dao = new $daoName(); $dao->id = $entityId; + // Collect references into arrays keyed by entity type + $references = []; foreach ($dao->findReferences() as $reference) { - $refEntity = $reference::fields()['id']['entity'] ?? ''; + $refEntity = \CRM_Utils_Array::first($reference::fields())['entity'] ?? ''; + $references[$refEntity][] = $reference; + } + foreach ($references as $refEntity => $records) { $refApiType = CoreUtil::getInfoItem($refEntity, 'type') ?? []; // Reference must be a ManagedEntity - if (in_array('ManagedEntity', $refApiType, TRUE)) { - $this->exportRecord($refEntity, $reference->id, $result, $name . '_'); + if (!in_array('ManagedEntity', $refApiType, TRUE)) { + continue; + } + $exclude = []; + // For sortable entities, order by weight and exclude weight from the export (it will be auto-managed) + if (in_array('SortableEntity', $refApiType, TRUE)) { + $exclude[] = $weightCol = CoreUtil::getInfoItem($refEntity, 'order_by'); + usort($records, function($a, $b) use ($weightCol) { + if (!isset($a->$weightCol)) { + $a->find(TRUE); + } + if (!isset($b->$weightCol)) { + $b->find(TRUE); + } + return $a->$weightCol < $b->$weightCol ? -1 : 1; + }); + } + foreach ($records as $record) { + $this->exportRecord($refEntity, $record->id, $result, $name . '_', $exclude); } } } @@ -170,16 +193,21 @@ class ExportAction extends AbstractAction { /** * @param $entityType * @param bool $loadOptions + * @param array $excludeFields * @return array */ - private function getFieldsForExport($entityType, $loadOptions = FALSE): array { + private function getFieldsForExport($entityType, $loadOptions = FALSE, $excludeFields = []): array { + $conditions = [ + ['type', 'IN', ['Field', 'Custom']], + ['readonly', '!=', TRUE], + ]; + if ($excludeFields) { + $conditions[] = ['name', 'NOT IN', $excludeFields]; + } try { return (array) civicrm_api4($entityType, 'getFields', [ 'action' => 'create', - 'where' => [ - ['type', 'IN', ['Field', 'Custom']], - ['readonly', '!=', TRUE], - ], + 'where' => $conditions, 'loadOptions' => $loadOptions, 'checkPermissions' => $this->checkPermissions, ])->indexBy('name'); diff --git a/Civi/Api4/Generic/Traits/DAOActionTrait.php b/Civi/Api4/Generic/Traits/DAOActionTrait.php index 277f2a727f..59a62fce03 100644 --- a/Civi/Api4/Generic/Traits/DAOActionTrait.php +++ b/Civi/Api4/Generic/Traits/DAOActionTrait.php @@ -115,6 +115,7 @@ trait DAOActionTrait { 'CustomField' => 'writeRecords', 'EntityTag' => 'add', 'GroupContact' => 'add', + 'Navigation' => 'writeRecords', ]; $method = $functionNames[$this->getEntityName()] ?? NULL; if (!isset($method)) { @@ -324,14 +325,10 @@ trait DAOActionTrait { $oldWeight = empty($record[$idField]) ? NULL : \CRM_Core_DAO::getFieldValue($daoName, $record[$idField], $weightField); // FIXME: Need a more metadata-ish approach. For now here's a hardcoded list of the fields sortable entities use for grouping. - $guesses = ['option_group_id', 'price_set_id', 'price_field_id', 'premiums_id', 'uf_group_id', 'custom_group_id', 'domain_id']; + $guesses = ['option_group_id', 'price_set_id', 'price_field_id', 'premiums_id', 'uf_group_id', 'custom_group_id', 'parent_id', 'domain_id']; $filters = []; foreach (array_intersect($guesses, array_keys($daoFields)) as $filter) { - $value = $record[$filter] ?? (empty($record[$idField]) ? NULL : \CRM_Core_DAO::getFieldValue($daoName, $record[$idField], $filter)); - // Ignore the db-formatted string 'null' and empty strings as well as NULL values - if (!\CRM_Utils_System::isNull($value)) { - $filters[$filter] = $value; - } + $filters[$filter] = $record[$filter] ?? (empty($record[$idField]) ? NULL : \CRM_Core_DAO::getFieldValue($daoName, $record[$idField], $filter)); } // Supply default weight for new record if (!isset($record[$weightField]) && empty($record[$idField])) { diff --git a/Civi/Api4/Navigation.php b/Civi/Api4/Navigation.php index 5c8a65c500..ecf14ffe41 100644 --- a/Civi/Api4/Navigation.php +++ b/Civi/Api4/Navigation.php @@ -11,12 +11,15 @@ namespace Civi\Api4; /** - * Navigation entity. + * Navigation menu items. * * @searchable none + * @orderBy weight * @since 5.19 * @package Civi\Api4 */ class Navigation extends Generic\DAOEntity { + use Generic\Traits\SortableEntity; + use Generic\Traits\ManagedEntity; } diff --git a/tests/phpunit/api/v4/Entity/ManagedEntityTest.php b/tests/phpunit/api/v4/Entity/ManagedEntityTest.php index 96683664b5..8d185581c6 100644 --- a/tests/phpunit/api/v4/Entity/ManagedEntityTest.php +++ b/tests/phpunit/api/v4/Entity/ManagedEntityTest.php @@ -19,6 +19,7 @@ namespace api\v4\Entity; use api\v4\UnitTestCase; +use Civi\Api4\Navigation; use Civi\Api4\OptionGroup; use Civi\Api4\OptionValue; use Civi\Api4\SavedSearch; @@ -363,6 +364,173 @@ class ManagedEntityTest extends UnitTestCase implements TransactionalInterface, $this->assertStringStartsWith('OptionGroup_from_email_address_OptionValue_', $result['export'][1]['name']); } + public function testManagedNavigationWeights() { + $this->_managedEntities = [ + [ + 'module' => 'unit.test.fake.ext', + 'name' => 'Navigation_Test_Parent', + 'entity' => 'Navigation', + 'cleanup' => 'unused', + 'update' => 'unmodified', + 'params' => [ + 'version' => 4, + 'values' => [ + 'label' => 'Test Parent', + 'name' => 'Test_Parent', + 'url' => NULL, + 'icon' => 'crm-i test', + 'permission' => 'access CiviCRM', + 'permission_operator' => '', + 'is_active' => TRUE, + 'weight' => 50, + 'parent_id' => NULL, + 'has_separator' => NULL, + 'domain_id' => 'current_domain', + ], + ], + ], + [ + 'module' => 'unit.test.fake.ext', + 'name' => 'Navigation_Test_Child_1', + 'entity' => 'Navigation', + 'cleanup' => 'unused', + 'update' => 'unmodified', + 'params' => [ + 'version' => 4, + 'values' => [ + 'label' => 'Test Child 1', + 'name' => 'Test_Child_1', + 'url' => 'civicrm/test1?reset=1', + 'icon' => NULL, + 'permission' => 'access CiviCRM', + 'permission_operator' => '', + 'parent_id.name' => 'Test_Parent', + 'is_active' => TRUE, + 'has_separator' => NULL, + 'domain_id' => 'current_domain', + ], + ], + ], + [ + 'module' => 'unit.test.fake.ext', + 'name' => 'Navigation_Test_Child_2', + 'entity' => 'Navigation', + 'cleanup' => 'unused', + 'update' => 'unmodified', + 'params' => [ + 'version' => 4, + 'values' => [ + 'label' => 'Test Child 2', + 'name' => 'Test_Child_2', + 'url' => 'civicrm/test2?reset=1', + 'icon' => NULL, + 'permission' => 'access CiviCRM', + 'permission_operator' => '', + 'parent_id.name' => 'Test_Parent', + 'is_active' => TRUE, + 'has_separator' => NULL, + 'domain_id' => 'current_domain', + ], + ], + ], + [ + 'module' => 'unit.test.fake.ext', + 'name' => 'Navigation_Test_Child_3', + 'entity' => 'Navigation', + 'cleanup' => 'unused', + 'update' => 'unmodified', + 'params' => [ + 'version' => 4, + 'values' => [ + 'label' => 'Test Child 3', + 'name' => 'Test_Child_3', + 'url' => 'civicrm/test3?reset=1', + 'icon' => NULL, + 'permission' => 'access CiviCRM', + 'permission_operator' => '', + 'parent_id.name' => 'Test_Parent', + 'is_active' => TRUE, + 'has_separator' => NULL, + 'domain_id' => 'current_domain', + ], + ], + ], + ]; + + // Refresh managed entities with module active + $allModules = [ + new \CRM_Core_Module('unit.test.fake.ext', TRUE), + ]; + (new \CRM_Core_ManagedEntities($allModules))->reconcile(); + + $nav = Navigation::get(FALSE) + ->addWhere('name', '=', 'Test_Parent') + ->addChain('export', Navigation::export()->setId('$id')) + ->execute()->first(); + + $this->assertCount(4, $nav['export']); + $this->assertEquals(TRUE, $nav['is_active']); + + $this->assertEquals(50, $nav['export'][0]['params']['values']['weight']); + $this->assertEquals('Navigation_Test_Parent_Navigation_Test_Child_1', $nav['export'][1]['name']); + $this->assertEquals('Navigation_Test_Parent_Navigation_Test_Child_2', $nav['export'][2]['name']); + $this->assertEquals('Navigation_Test_Parent_Navigation_Test_Child_3', $nav['export'][3]['name']); + // Weight should not be included in export of children, leaving it to be auto-managed + $this->assertArrayNotHasKey('weight', $nav['export'][1]['params']['values']); + + // Children should have been assigned correct auto-weights + $children = Navigation::get(FALSE) + ->addWhere('parent_id.name', '=', 'Test_Parent') + ->addOrderBy('weight') + ->execute(); + foreach ([1, 2, 3] as $index => $weight) { + $this->assertEquals($weight, $children[$index]['weight']); + $this->assertEquals(TRUE, $children[$index]['is_active']); + } + + // Refresh managed entities with module disabled + $allModules = [ + new \CRM_Core_Module('unit.test.fake.ext', FALSE), + ]; + (new \CRM_Core_ManagedEntities($allModules))->reconcile(); + + // Children's weight should have been unaffected, but they should be disabled + $children = Navigation::get(FALSE) + ->addWhere('parent_id.name', '=', 'Test_Parent') + ->addOrderBy('weight') + ->execute(); + foreach ([1, 2, 3] as $index => $weight) { + $this->assertEquals($weight, $children[$index]['weight']); + $this->assertEquals(FALSE, $children[$index]['is_active']); + } + + $nav = Navigation::get(FALSE) + ->addWhere('name', '=', 'Test_Parent') + ->execute()->first(); + $this->assertEquals(FALSE, $nav['is_active']); + + // Refresh managed entities with module active + $allModules = [ + new \CRM_Core_Module('unit.test.fake.ext', TRUE), + ]; + (new \CRM_Core_ManagedEntities($allModules))->reconcile(); + + // Children's weight should have been unaffected, but they should be enabled + $children = Navigation::get(FALSE) + ->addWhere('parent_id.name', '=', 'Test_Parent') + ->addOrderBy('weight') + ->execute(); + foreach ([1, 2, 3] as $index => $weight) { + $this->assertEquals($weight, $children[$index]['weight']); + $this->assertEquals(TRUE, $children[$index]['is_active']); + } + // Parent should also be re-enabled + $nav = Navigation::get(FALSE) + ->addWhere('name', '=', 'Test_Parent') + ->execute()->first(); + $this->assertEquals(TRUE, $nav['is_active']); + } + /** * @dataProvider sampleEntityTypes * @param string $entityName @@ -389,6 +557,7 @@ class ManagedEntityTest extends UnitTestCase implements TransactionalInterface, 'CustomField' => TRUE, 'CustomGroup' => TRUE, 'MembershipType' => TRUE, + 'Navigation' => TRUE, 'OptionGroup' => TRUE, 'OptionValue' => TRUE, 'SavedSearch' => TRUE, -- 2.25.1