APIv4 - Add SortableEntity and ManagedEntity traits to Navigation menu entity
authorColeman Watts <coleman@civicrm.org>
Sun, 28 Nov 2021 16:06:21 +0000 (11:06 -0500)
committerColeman Watts <coleman@civicrm.org>
Mon, 29 Nov 2021 13:46:02 +0000 (08:46 -0500)
Excludes 'weight' from managed entity calculations for references,
adds unit tests for the interaction of managed entities and sortable entities

CRM/Core/Module.php
CRM/Utils/Weight.php
Civi/Api4/Generic/ExportAction.php
Civi/Api4/Generic/Traits/DAOActionTrait.php
Civi/Api4/Navigation.php
tests/phpunit/api/v4/Entity/ManagedEntityTest.php

index 39e006bb9def5e40e9579c0ea02398ca17987f86..7f0daefb2a0923c0bc6a928efcf06b10738c0351 100644 (file)
@@ -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;
index b98353882f718d81b07b49b15806ee4f1d7cbe14..ec0176c614638e9aebc62023467e0f092e3b25d7 100644 (file)
@@ -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);
index b7de9e8d77552861168b1257192a5ee28c430d19..a935eda87a16e19aac8b8e23c10999a09d47d12e 100644 (file)
@@ -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');
index 277f2a727f10c2a80ff31b49487ce8f2c95152a6..59a62fce039fb492f1a20dde785d4122502e5e1a 100644 (file)
@@ -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])) {
index 5c8a65c50013973ffc4d4044f49856a1ac3c7284..ecf14ffe4165b23698655f48572603c5945a1f21 100644 (file)
 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;
 
 }
index 96683664b50c0339d775835da55fb5b98fd4ead4..8d185581c6fba4d3295f7a99a9034c2468a980cb 100644 (file)
@@ -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,