APIv4 - Add API for RecentItem
authorColeman Watts <coleman@civicrm.org>
Tue, 5 Apr 2022 01:24:16 +0000 (21:24 -0400)
committerColeman Watts <coleman@civicrm.org>
Tue, 5 Apr 2022 23:37:22 +0000 (19:37 -0400)
This exposes an api entity for the Recent Items stored in the user session.
In the process, it simplifies the process of adding a recent item,
performing most of the lookups automatically.

12 files changed:
CRM/Activity/BAO/Activity.php
CRM/Contact/BAO/Contact.php
CRM/Contact/Form/Contact.php
CRM/Contact/Page/View.php
CRM/Core/DAO.php
CRM/Utils/Recent.php
Civi/Api4/Generic/BasicEntity.php
Civi/Api4/RecentItem.php [new file with mode: 0644]
tests/phpunit/api/v4/Action/RecentItemsTest.php
tests/phpunit/api/v4/Entity/ConformanceTest.php
tests/phpunit/api/v4/Entity/RecentItemTest.php [new file with mode: 0644]
tests/phpunit/api/v4/Service/TestCreationParameterProvider.php

index d106c6f5cf89ffdd6df388d500289b54774208aa..a6f4977010c4757bb4485f7d0cccd50de50452de 100644 (file)
@@ -2797,4 +2797,25 @@ INNER JOIN  civicrm_option_group grp ON (grp.id = option_group_id AND grp.name =
     ];
   }
 
+  /**
+   * Get icon for a particular activity (based on type).
+   *
+   * Example: `CRM_Activity_BAO_Activity::getIcon('Activity', 123)`
+   *
+   * @param string $entityName
+   *   Always "Activity".
+   * @param int $entityId
+   *   Id of the activity.
+   * @throws CRM_Core_Exception
+   */
+  public static function getEntityIcon(string $entityName, int $entityId) {
+    $field = Civi\Api4\Activity::getFields(FALSE)
+      ->addWhere('name', '=', 'activity_type_id')
+      ->setLoadOptions(['id', 'label', 'icon'])
+      ->execute()->single();
+    $activityTypes = array_column($field['options'], NULL, 'id');
+    $activityType = CRM_Core_DAO::getFieldValue(parent::class, $entityId, 'activity_type_id');
+    return $activityTypes[$activityType]['icon'] ?? self::$_icon;
+  }
+
 }
index f89c33b142c09790c27fe449eba4a359e35926c0..82103ea677148a800350a8a3608e43bb33aa9165 100644 (file)
@@ -742,7 +742,7 @@ WHERE     civicrm_contact.id = " . CRM_Utils_Type::escape($id, 'Integer');
       'id' => $contact->id,
       'is_deleted' => 1,
     ];
-    CRM_Utils_Hook::pre('update', $contact->contact_type, $contact->id, $updateParams);
+    CRM_Utils_Hook::pre('edit', $contact->contact_type, $contact->id, $updateParams);
 
     $params = [1 => [$contact->id, 'Integer']];
     $query = 'DELETE FROM civicrm_uf_match WHERE contact_id = %1';
@@ -752,7 +752,7 @@ WHERE     civicrm_contact.id = " . CRM_Utils_Type::escape($id, 'Integer');
     $contact->save();
     CRM_Core_BAO_Log::register($contact->id, 'civicrm_contact', $contact->id);
 
-    CRM_Utils_Hook::post('update', $contact->contact_type, $contact->id, $contact);
+    CRM_Utils_Hook::post('edit', $contact->contact_type, $contact->id, $contact);
 
     return TRUE;
   }
@@ -3736,4 +3736,28 @@ LEFT JOIN civicrm_address ON ( civicrm_address.contact_id = civicrm_contact.id )
     return CRM_Contact_BAO_Contact_Permission::allow($record['id'], $actionType, $userID);
   }
 
+  /**
+   * Get icon for a particular contact.
+   *
+   * Example: `CRM_Contact_BAO_Contact::getIcon('Contact', 123)`
+   *
+   * @param string $entityName
+   *   Always "Contact".
+   * @param int $entityId
+   *   Id of the contact.
+   * @throws CRM_Core_Exception
+   */
+  public static function getEntityIcon(string $entityName, int $entityId) {
+    $contactTypes = CRM_Contact_BAO_ContactType::getAllContactTypes();
+    $subTypes = CRM_Utils_Array::explodePadded(CRM_Core_DAO::getFieldValue(parent::class, $entityId, 'contact_sub_type'));
+    foreach ((array) $subTypes as $subType) {
+      if (!empty($contactTypes[$subType]['icon'])) {
+        return $contactTypes[$subType]['icon'];
+      }
+    }
+    // If no sub-type icon, lookup contact type
+    $contactType = CRM_Core_DAO::getFieldValue(parent::class, $entityId, 'contact_type');
+    return $contactTypes[$contactType]['icon'] ?? self::$_icon;
+  }
+
 }
index 76aa4b196a7b1e618bd2065dde5300e70456c9ce..8beb4d3e11bc7c10246579768ab014805a8601b0 100644 (file)
@@ -1046,7 +1046,7 @@ class CRM_Contact_Form_Contact extends CRM_Core_Form {
     CRM_Utils_Recent::add($contact->display_name,
       CRM_Utils_System::url('civicrm/contact/view', 'reset=1&cid=' . $contact->id),
       $contact->id,
-      $this->_contactType,
+      'Contact',
       $contact->id,
       $contact->display_name,
       $recentOther
index bbbc950dde6641bd12035ea9e992fd384a0ce079..180a528c9cc21828894fcab6cc8cb3a1b5521692 100644 (file)
@@ -169,8 +169,7 @@ class CRM_Contact_Page_View extends CRM_Core_Page {
 
     $recentOther = [
       'imageUrl' => $contactImageUrl,
-      'subtype' => $contactSubtype,
-      'isDeleted' => $isDeleted,
+      'is_deleted' => $isDeleted,
     ];
 
     if (CRM_Contact_BAO_Contact_Permission::allow($this->_contactId, CRM_Core_Permission::EDIT)) {
@@ -186,7 +185,7 @@ class CRM_Contact_Page_View extends CRM_Core_Page {
     CRM_Utils_Recent::add($displayName,
       CRM_Utils_System::url('civicrm/contact/view', "reset=1&cid={$this->_contactId}"),
       $this->_contactId,
-      $contactType,
+      'Contact',
       $this->_contactId,
       $displayName,
       $recentOther
index 31c43e0b78f626c515e62ec9ae8ab1fe88f13103..4e954132f8b20ea53db8d80643fb0e707ba02bee 100644 (file)
@@ -3291,6 +3291,27 @@ SELECT contact_id
     return static::$_paths ?? [];
   }
 
+  /**
+   * Overridable function to get icon for a particular entity.
+   *
+   * Example: `CRM_Contact_BAO_Contact::getIcon('Contact', 123)`
+   *
+   * @param string $entityName
+   *   Short name of the entity. This may seem redundant because the entity name can usually be inferred
+   *   from the BAO class being called, but not always. Some virtual entities share a BAO class.
+   * @param int $entityId
+   *   Id of the entity.
+   * @throws CRM_Core_Exception
+   */
+  public static function getEntityIcon(string $entityName, int $entityId) {
+    if (static::class === 'CRM_Core_DAO' || static::class !== CRM_Core_DAO_AllCoreTables::getBAOClassName(static::class)) {
+      throw new CRM_Core_Exception('CRM_Core_DAO::getIcon must be called on a BAO class e.g. CRM_Contact_BAO_Contact::getIcon("Contact", 123).');
+    }
+    // By default, just return the icon representing this entity. If there's more complex lookup to do,
+    // the BAO for this entity should override this method.
+    return static::$_icon;
+  }
+
   /**
    * When creating a record without a supplied name,
    * create a unique, clean name derived from the label.
index fc98cc37fe9488e238f2260aecd84783aa7e2cbf..a2a22ebb4127b75d3a1185cfa466b962d372439f 100644 (file)
@@ -14,6 +14,8 @@
  * @copyright CiviCRM LLC https://civicrm.org/licensing
  */
 
+use Civi\Api4\Utils\CoreUtil;
+
 /**
  * Recent items utility class.
  */
@@ -74,6 +76,20 @@ class CRM_Utils_Recent {
     return self::$_recent;
   }
 
+  /**
+   * Create function used by the API - supplies defaults
+   *
+   * @param array $params
+   */
+  public static function create(array $params) {
+    $params['title'] = $params['title'] ?? self::getTitle($params['entity_type'], $params['entity_id']);
+    $params['view_url'] = $params['view_url'] ?? self::getUrl($params['entity_type'], $params['entity_id'], 'view');
+    $params['edit_url'] = $params['edit_url'] ?? self::getUrl($params['entity_type'], $params['entity_id'], 'update');
+    $params['delete_url'] = $params['delete_url'] ?? (empty($params['is_deleted']) ? self::getUrl($params['entity_type'], $params['entity_id'], 'delete') : NULL);
+    self::add($params['title'], $params['view_url'], $params['entity_id'], $params['entity_type'], $params['contact_id'] ?? NULL, NULL, $params);
+    return $params;
+  }
+
   /**
    * Add an item to the recent stack.
    *
@@ -81,29 +97,33 @@ class CRM_Utils_Recent {
    *   The title to display.
    * @param string $url
    *   The link for the above title.
-   * @param string $id
+   * @param string $entityId
    *   Object id.
-   * @param string $type
+   * @param string $entityType
    * @param int $contactId
+   *   Deprecated, probably unused param
    * @param string $contactName
+   *   Deprecated, probably unused param
    * @param array $others
    */
   public static function add(
     $title,
     $url,
-    $id,
-    $type,
+    $entityId,
+    $entityType,
     $contactId,
     $contactName,
     $others = []
   ) {
+    $entityType = self::normalizeEntityType($entityType);
+
     // Abort if this entity type is not supported
-    if (!self::isProviderEnabled($type)) {
+    if (!self::isProviderEnabled($entityType)) {
       return;
     }
 
     // Ensure item is not already present in list
-    self::removeItems(['id' => $id, 'type' => $type]);
+    self::removeItems(['entity_id' => $entityId, 'entity_type' => $entityType]);
 
     if (!is_array($others)) {
       $others = [];
@@ -112,17 +132,28 @@ class CRM_Utils_Recent {
     array_unshift(self::$_recent,
       [
         'title' => $title,
+        // TODO: deprecate & remove "url" in favor of "view_url"
         'url' => $url,
-        'id' => $id,
-        'type' => $type,
+        'view_url' => $url,
+        // TODO: deprecate & remove "id" in favor of "entity_id"
+        'id' => $entityId,
+        'entity_id' => (int) $entityId,
+        // TODO: deprecate & remove "type" in favor of "entity_type"
+        'type' => $entityType,
+        'entity_type' => $entityType,
+        // Deprecated param
         'contact_id' => $contactId,
+        // Param appears to be unused
         'contactName' => $contactName,
         'subtype' => $others['subtype'] ?? NULL,
-        'isDeleted' => $others['isDeleted'] ?? FALSE,
+        // TODO: deprecate & remove "isDeleted" in favor of "is_deleted"
+        'isDeleted' => $others['is_deleted'] ?? $others['isDeleted'] ?? FALSE,
+        'is_deleted' => (bool) ($others['is_deleted'] ?? $others['isDeleted'] ?? FALSE),
+        // imageUrl is deprecated
         'image_url' => $others['imageUrl'] ?? NULL,
-        'edit_url' => $others['editUrl'] ?? NULL,
-        'delete_url' => $others['deleteUrl'] ?? NULL,
-        'icon' => $others['icon'] ?? self::getIcon($type, $others['subtype'] ?? NULL),
+        'edit_url' => $others['edit_url'] ?? $others['editUrl'] ?? NULL,
+        'delete_url' => $others['delete_url'] ?? $others['deleteUrl'] ?? NULL,
+        'icon' => $others['icon'] ?? self::getIcon($entityType, $entityId),
       ]
     );
 
@@ -138,27 +169,61 @@ class CRM_Utils_Recent {
   }
 
   /**
-   * @param $type
-   * @param $subType
+   * Get default title for this item, based on the entity's `label_field`
+   *
+   * @param string $entityType
+   * @param int $entityId
+   * @return string|null
+   */
+  private static function getTitle($entityType, $entityId) {
+    $labelField = CoreUtil::getInfoItem($entityType, 'label_field');
+    $title = NULL;
+    if ($labelField) {
+      $record = civicrm_api4($entityType, 'get', [
+        'where' => [['id', '=', $entityId]],
+        'select' => [$labelField],
+      ], 0);
+      $title = $record[$labelField] ?? NULL;
+    }
+    return $title ?? (CoreUtil::getInfoItem($entityType, 'label_field'));
+  }
+
+  /**
+   * Get a link to view/update/delete a given entity.
+   *
+   * @param string $entityType
+   * @param int $entityId
+   * @param string $action
+   *   Either 'view', 'update', or 'delete'
    * @return string|null
    */
-  private static function getIcon($type, $subType) {
-    $icon = NULL;
-    $contactTypes = CRM_Contact_BAO_ContactType::getAllContactTypes();
-    if (!empty($contactTypes[$type])) {
-      // Pick icon from contact sub-type first if available, then contact type
-      $subTypesAndType = array_merge((array) CRM_Utils_Array::explodePadded($subType), [$type]);
-      foreach ($subTypesAndType as $contactType) {
-        $icon = $icon ?? $contactTypes[$contactType]['icon'] ?? NULL;
+  private static function getUrl($entityType, $entityId, $action) {
+    if ($action !== 'view') {
+      $check = civicrm_api4($entityType, 'checkAccess', [
+        'action' => $action,
+        'values' => ['id' => $entityId],
+      ], 0);
+      if (empty($check['access'])) {
+        return NULL;
       }
-      // If no contact type icon, proceed to lookup icon from dao
-      $type = 'Contact';
     }
-    if (!$icon) {
-      $daoClass = CRM_Core_DAO_AllCoreTables::getFullName($type);
-      if ($daoClass) {
-        $icon = $daoClass::$_icon;
-      }
+    $paths = (array) CoreUtil::getInfoItem($entityType, 'paths');
+    if (!empty($paths[$action])) {
+      return CRM_Utils_System::url(str_replace('[id]', $entityId, $paths[$action]));
+    }
+    return NULL;
+  }
+
+  /**
+   * @param $entityType
+   * @param $entityId
+   * @return string|null
+   */
+  private static function getIcon($entityType, $entityId) {
+    $icon = NULL;
+    $daoClass = CRM_Core_DAO_AllCoreTables::getFullName($entityType);
+    if ($daoClass) {
+      $icon = CRM_Core_DAO_AllCoreTables::getBAOClassName($daoClass)::getEntityIcon($entityType, $entityId);
     }
     return $icon ?: 'fa-gear';
   }
@@ -168,11 +233,23 @@ class CRM_Utils_Recent {
    * @param \Civi\Core\Event\PostEvent $event
    */
   public static function on_hook_civicrm_post(\Civi\Core\Event\PostEvent $event) {
-    if ($event->action === 'delete' && $event->id && CRM_Core_Session::getLoggedInContactID()) {
-      // Is this an entity that might be in the recent items list?
-      $providersPermitted = Civi::settings()->get('recentItemsProviders') ?: array_keys(self::getProviders());
-      if (in_array($event->entity, $providersPermitted)) {
-        self::del(['id' => $event->id, 'type' => $event->entity]);
+    if ($event->id && CRM_Core_Session::getLoggedInContactID()) {
+      $entityType = self::normalizeEntityType($event->entity);
+      if ($event->action === 'delete') {
+        // Is this an entity that might be in the recent items list?
+        $providersPermitted = Civi::settings()->get('recentItemsProviders') ?: array_keys(self::getProviders());
+        if (in_array($entityType, $providersPermitted)) {
+          self::del(['entity_id' => $event->id, 'entity_type' => $entityType]);
+        }
+      }
+      elseif ($event->action === 'edit') {
+        if (isset($event->object->is_deleted)) {
+          \Civi\Api4\RecentItem::update()
+            ->addWhere('entity_type', '=', $entityType)
+            ->addWhere('entity_id', '=', $event->id)
+            ->addValue('is_deleted', (bool) $event->object->is_deleted)
+            ->execute();
+        }
       }
     }
   }
@@ -186,7 +263,7 @@ class CRM_Utils_Recent {
 
     self::$_recent = array_filter(self::$_recent, function($item) use ($props) {
       foreach ($props as $key => $val) {
-        if (isset($item[$key]) && $item[$key] != $val) {
+        if (($item[$key] ?? NULL) != $val) {
           return TRUE;
         }
       }
@@ -226,12 +303,6 @@ class CRM_Utils_Recent {
    * @return bool
    */
   public static function isProviderEnabled($providerName) {
-
-    // Join contact types to providerName 'Contact'
-    $contactTypes = CRM_Contact_BAO_ContactType::contactTypes(TRUE);
-    if (in_array($providerName, $contactTypes)) {
-      $providerName = 'Contact';
-    }
     $allowed = TRUE;
 
     // Use core setting recentItemsProviders if configured
@@ -243,9 +314,23 @@ class CRM_Utils_Recent {
     return $allowed;
   }
 
+  /**
+   * @param string $entityType
+   * @return string
+   */
+  private static function normalizeEntityType($entityType) {
+    // Change Individual/Organization/Household to 'Contact'
+    if (in_array($entityType, CRM_Contact_BAO_ContactType::basicTypes(TRUE), TRUE)) {
+      return 'Contact';
+    }
+    return $entityType;
+  }
+
   /**
    * Gets the list of available providers to civi's recent items stack
    *
+   * TODO: Make this an option group so extensions can extend it.
+   *
    * @return array
    */
   public static function getProviders() {
index 48c649b66150fc0589b4aba1e18eee685a17bb39..066ef04e7a5c54b379ccc72857337b2f351b51b4 100644 (file)
@@ -34,7 +34,7 @@ abstract class BasicEntity extends AbstractEntity {
   /**
    * Unique identifier for this entity.
    *
-   * @var string
+   * @var string|string[]
    */
   protected static $idField = 'id';
 
diff --git a/Civi/Api4/RecentItem.php b/Civi/Api4/RecentItem.php
new file mode 100644 (file)
index 0000000..e8b0da1
--- /dev/null
@@ -0,0 +1,86 @@
+<?php
+/*
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC. All rights reserved.                        |
+ |                                                                    |
+ | This work is published under the GNU AGPLv3 license with some      |
+ | permitted exceptions and without any warranty. For full license    |
+ | and copyright information, see https://civicrm.org/licensing       |
+ +--------------------------------------------------------------------+
+ */
+namespace Civi\Api4;
+
+/**
+ * Recent Items API.
+ *
+ * Lists the most recently viewed entities by the current user.
+ *
+ * The list is stored in the user's session.
+ * The number of items stored is determined by the setting `recentItemsMaxCount`.
+ *
+ * @searchable secondary
+ * @since 5.49
+ * @package Civi\Api4
+ */
+class RecentItem extends Generic\BasicEntity {
+
+  protected static $idField = ['entity_id', 'entity_type'];
+
+  protected static $getter = ['CRM_Utils_Recent', 'get'];
+  protected static $setter = ['CRM_Utils_Recent', 'create'];
+  protected static $deleter = ['CRM_Utils_Recent', 'del'];
+
+  /**
+   * @param bool $checkPermissions
+   * @return Generic\BasicGetFieldsAction
+   */
+  public static function getFields($checkPermissions = TRUE) {
+    return (new Generic\BasicGetFieldsAction('RecentItem', __FUNCTION__, function() {
+      return [
+        [
+          'name' => 'entity_id',
+          'data_type' => 'Integer',
+          'required' => TRUE,
+        ],
+        [
+          'name' => 'entity_type',
+          'title' => 'Entity Type',
+          'options' => \CRM_Utils_Recent::getProviders(),
+          'required' => TRUE,
+        ],
+        [
+          'name' => 'title',
+        ],
+        [
+          'name' => 'is_deleted',
+          'data_type' => 'Boolean',
+        ],
+        [
+          'name' => 'icon',
+        ],
+        [
+          'name' => 'view_url',
+          'title' => 'View URL',
+        ],
+        [
+          'name' => 'edit_url',
+          'title' => 'Edit URL',
+        ],
+        [
+          'name' => 'delete_url',
+          'title' => 'Delete URL',
+        ],
+      ];
+    }))->setCheckPermissions($checkPermissions);
+  }
+
+  /**
+   * @return array
+   */
+  public static function permissions() {
+    return [
+      'default' => ['access CiviCRM'],
+    ];
+  }
+
+}
index 4c81a618e13b23b490b331777a733e0a6f8cbbcb..259e186d586b2505a0a39426aba329b261e4f113 100644 (file)
@@ -21,6 +21,7 @@ namespace api\v4\Action;
 
 use api\v4\UnitTestCase;
 use Civi\Api4\Activity;
+use Civi\Api4\RecentItem;
 
 /**
  * @group headless
@@ -36,20 +37,23 @@ class RecentItemsTest extends UnitTestCase {
       ->addValue('subject', 'Hello recent!')
       ->execute()->first()['id'];
     $this->assertEquals(1, $this->getRecentItemCount(['type' => 'Activity', 'id' => $aid1]));
-    $this->assertStringContainsString('Hello recent!', \CRM_Utils_Recent::get()[0]['title']);
+    $recentItem = RecentItem::get(FALSE)->execute()->first();
+    $this->assertStringContainsString('Hello recent!', $recentItem['title']);
+    $this->assertStringContainsString("id=$aid1", $recentItem['view_url']);
+    $this->assertEquals('fa-slideshare', $recentItem['icon']);
 
     $aid2 = Activity::create(FALSE)
       ->addValue('activity_type_id:name', 'Meeting')
       ->addValue('source_contact_id', $cid)
       ->addValue('subject', 'Goodbye recent!')
       ->execute()->first()['id'];
-    $this->assertEquals(1, $this->getRecentItemCount(['type' => 'Activity', 'id' => $aid2]));
-    $this->assertStringContainsString('Goodbye recent!', \CRM_Utils_Recent::get()[0]['title']);
+    $this->assertEquals(1, $this->getRecentItemCount(['type' => 'Activity', 'entity_id' => $aid2]));
+    $this->assertStringContainsString('Goodbye recent!', RecentItem::get(FALSE)->execute()[0]['title']);
 
     Activity::delete(FALSE)->addWhere('id', '=', $aid1)->execute();
 
-    $this->assertEquals(0, $this->getRecentItemCount(['type' => 'Activity', 'id' => $aid1]));
-    $this->assertEquals(1, $this->getRecentItemCount(['type' => 'Activity', 'id' => $aid2]));
+    $this->assertEquals(0, $this->getRecentItemCount(['entity_type' => 'Activity', 'entity_id' => $aid1]));
+    $this->assertEquals(1, $this->getRecentItemCount(['entity_type' => 'Activity', 'entity_id' => $aid2]));
   }
 
   /**
@@ -57,16 +61,11 @@ class RecentItemsTest extends UnitTestCase {
    * @return int
    */
   private function getRecentItemCount($props) {
-    $count = 0;
-    foreach (\CRM_Utils_Recent::get() as $item) {
-      foreach ($props as $key => $val) {
-        if (($item[$key] ?? NULL) != $val) {
-          continue 2;
-        }
-      }
-      ++$count;
+    $recent = RecentItem::get(FALSE);
+    foreach ($props as $key => $val) {
+      $recent->addWhere($key, '=', $val);
     }
-    return $count;
+    return $recent->execute()->count();
   }
 
 }
index e6f0e56454b3ce1c102ede1aa7d46433336d2f9f..ae58ea64a1ccaa23dff0d11bfab75afe97061610 100644 (file)
@@ -210,11 +210,12 @@ class ConformanceTest extends UnitTestCase implements HookInterface {
       ->execute()
       ->indexBy('name');
 
-    $errMsg = sprintf('%s is missing required ID field', $entity);
-    $subset = ['data_type' => 'Integer'];
+    $idField = CoreUtil::getIdFieldName($entity);
 
-    $this->assertArrayHasKey('data_type', $fields['id'], $errMsg);
-    $this->assertEquals('Integer', $fields['id']['data_type']);
+    $errMsg = sprintf('%s getfields is missing primary key field', $entity);
+
+    $this->assertArrayHasKey($idField, $fields, $errMsg);
+    $this->assertEquals('Integer', $fields[$idField]['data_type']);
 
     // Ensure that the getFields (FieldSpec) format is generally consistent.
     foreach ($fields as $field) {
@@ -272,8 +273,10 @@ class ConformanceTest extends UnitTestCase implements HookInterface {
       ->execute()
       ->first();
 
-    $this->assertArrayHasKey('id', $createResult, "create missing ID");
-    $id = $createResult['id'];
+    $idField = CoreUtil::getIdFieldName($entity);
+
+    $this->assertArrayHasKey($idField, $createResult, "create missing ID");
+    $id = $createResult[$idField];
     $this->assertGreaterThanOrEqual(1, $id, "$entity ID not positive");
     if (!$isReadOnly) {
       $this->assertEquals(1, $this->checkAccessCounts["{$entity}::create"]);
@@ -342,7 +345,8 @@ class ConformanceTest extends UnitTestCase implements HookInterface {
       ->execute();
 
     $errMsg = sprintf('Failed to fetch a %s after creation', $entity);
-    $this->assertEquals($id, $getResult->first()['id'], $errMsg);
+    $idField = CoreUtil::getIdFieldName($entity);
+    $this->assertEquals($id, $getResult->first()[$idField], $errMsg);
     $this->assertEquals(1, $getResult->count(), $errMsg);
   }
 
@@ -361,7 +365,8 @@ class ConformanceTest extends UnitTestCase implements HookInterface {
       ->execute();
 
     $errMsg = sprintf('Failed to fetch a %s after creation', $entity);
-    $this->assertEquals($id, $getResult->first()['id'], $errMsg);
+    $idField = CoreUtil::getIdFieldName($entity);
+    $this->assertEquals($id, $getResult->first()[$idField], $errMsg);
     $this->assertEquals(1, $getResult->count(), $errMsg);
     $this->resetCheckAccess();
   }
@@ -372,8 +377,9 @@ class ConformanceTest extends UnitTestCase implements HookInterface {
    * @param string $entity
    */
   protected function checkGetCount($entityClass, $id, $entity): void {
+    $idField = CoreUtil::getIdFieldName($entity);
     $getResult = $entityClass::get(FALSE)
-      ->addWhere('id', '=', $id)
+      ->addWhere($idField, '=', $id)
       ->selectRowCount()
       ->execute();
     $errMsg = sprintf('%s getCount failed', $entity);
@@ -428,9 +434,10 @@ class ConformanceTest extends UnitTestCase implements HookInterface {
     $this->assertEquals(0, $this->checkAccessCounts["{$entity}::delete"]);
     $isReadOnly = $this->isReadOnly($entityClass);
 
+    $idField = CoreUtil::getIdFieldName($entity);
     $deleteAction = $entityClass::delete()
       ->setCheckPermissions(!$isReadOnly)
-      ->addWhere('id', '=', $id);
+      ->addWhere($idField, '=', $id);
 
     if (property_exists($deleteAction, 'useTrash')) {
       $deleteAction->setUseTrash(FALSE);
@@ -440,15 +447,17 @@ class ConformanceTest extends UnitTestCase implements HookInterface {
       $deleteResult = $deleteAction->execute();
     });
 
-    // We should have emitted an event.
-    $hookEntity = ($entity === 'Contact') ? 'Individual' : $entity; /* ooph */
-    $this->assertContains("pre.{$hookEntity}.delete", $log, "$entity should emit hook_civicrm_pre() for deletions");
-    $this->assertContains("post.{$hookEntity}.delete", $log, "$entity should emit hook_civicrm_post() for deletions");
+    if (in_array('DAOEntity', CoreUtil::getInfoItem($entity, 'type'))) {
+      // We should have emitted an event.
+      $hookEntity = ($entity === 'Contact') ? 'Individual' : $entity;/* ooph */
+      $this->assertContains("pre.{$hookEntity}.delete", $log, "$entity should emit hook_civicrm_pre() for deletions");
+      $this->assertContains("post.{$hookEntity}.delete", $log, "$entity should emit hook_civicrm_post() for deletions");
 
-    // should get back an array of deleted id
-    $this->assertEquals([['id' => $id]], (array) $deleteResult);
-    if (!$isReadOnly) {
-      $this->assertEquals(1, $this->checkAccessCounts["{$entity}::delete"]);
+      // should get back an array of deleted id
+      $this->assertEquals([['id' => $id]], (array) $deleteResult);
+      if (!$isReadOnly) {
+        $this->assertEquals(1, $this->checkAccessCounts["{$entity}::delete"]);
+      }
     }
     $this->resetCheckAccess();
   }
diff --git a/tests/phpunit/api/v4/Entity/RecentItemTest.php b/tests/phpunit/api/v4/Entity/RecentItemTest.php
new file mode 100644 (file)
index 0000000..fd146a1
--- /dev/null
@@ -0,0 +1,95 @@
+<?php
+
+/*
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC. All rights reserved.                        |
+ |                                                                    |
+ | This work is published under the GNU AGPLv3 license with some      |
+ | permitted exceptions and without any warranty. For full license    |
+ | and copyright information, see https://civicrm.org/licensing       |
+ +--------------------------------------------------------------------+
+ */
+
+/**
+ *
+ * @package CRM
+ * @copyright CiviCRM LLC https://civicrm.org/licensing
+ */
+
+namespace api\v4\Entity;
+
+use api\v4\UnitTestCase;
+use Civi\Api4\RecentItem;
+use Civi\Api4\Contact;
+use Civi\Test\TransactionalInterface;
+
+/**
+ * Test Address functionality
+ *
+ * @group headless
+ */
+class RecentItemTest extends UnitTestCase implements TransactionalInterface {
+
+  /**
+   *
+   */
+  public function testRecentContact() {
+    $cid = Contact::create(FALSE)
+      ->addValue('first_name', 'Hello')
+      ->execute()->single()['id'];
+
+    $this->createLoggedInUser();
+
+    RecentItem::create(FALSE)
+      ->addValue('entity_type', 'Contact')
+      ->addValue('entity_id', $cid)
+      ->execute();
+
+    $item = RecentItem::get(FALSE)
+      ->addWhere('entity_type', '=', 'Contact')
+      ->addWhere('entity_id', '=', $cid)
+      ->execute()->single();
+
+    $this->assertEquals('Hello', $item['title']);
+    $this->assertEquals('fa-user', $item['icon']);
+    $this->assertEquals(\CRM_Utils_System::url('civicrm/contact/view?reset=1&cid=' . $cid), $item['view_url']);
+
+    RecentItem::delete(FALSE)
+      ->addWhere('entity_type', '=', 'Contact')
+      ->addWhere('entity_id', '=', $cid)
+      ->execute();
+
+    $this->assertCount(0, RecentItem::get(FALSE)
+      ->addWhere('entity_type', '=', 'Contact')
+      ->addWhere('entity_id', '=', $cid)
+      ->execute());
+
+    RecentItem::create(FALSE)
+      ->addValue('entity_type', 'Contact')
+      ->addValue('entity_id', $cid)
+      ->execute();
+
+    $this->assertCount(1, RecentItem::get(FALSE)
+      ->addWhere('entity_type', '=', 'Contact')
+      ->addWhere('entity_id', '=', $cid)
+      ->execute());
+
+    // Move contact to trash
+    Contact::delete(FALSE)->addWhere('id', '=', $cid)->execute();
+    $item = RecentItem::get(FALSE)
+      ->addWhere('entity_type', '=', 'Contact')
+      ->addWhere('entity_id', '=', $cid)
+      ->execute()->single();
+    $this->assertEquals('Hello', $item['title']);
+    $this->assertTrue($item['is_deleted']);
+
+    // Delete contact
+    Contact::delete(FALSE)->setUseTrash(FALSE)->addWhere('id', '=', $cid)->execute();
+
+    $this->assertCount(0, RecentItem::get(FALSE)
+      ->addWhere('entity_type', '=', 'Contact')
+      ->addWhere('entity_id', '=', $cid)
+      ->execute());
+  }
+
+}
index 4db5a44423563130db98248b020e1cd67c6939b0..7d016aacc9bdfa51b54ab78d58e4a661aa30a05a 100644 (file)
@@ -51,6 +51,7 @@ class TestCreationParameterProvider {
       'loadOptions' => TRUE,
       'where' => [
         ['OR', [['required', '=', TRUE], ['required_if', 'IS NOT EMPTY']]],
+        ['readonly', 'IS EMPTY'],
       ],
     ], 'name');
 
@@ -76,8 +77,6 @@ class TestCreationParameterProvider {
       $requiredParams = array_merge($requiredParams, $overrides[$entity]);
     }
 
-    unset($requiredParams['id']);
-
     return $requiredParams;
   }
 
@@ -106,7 +105,7 @@ class TestCreationParameterProvider {
     }
     if ($field['name'] === 'entity_id') {
       // What could possibly go wrong with this?
-      switch ($field['table_name']) {
+      switch ($field['table_name'] ?? NULL) {
         case 'civicrm_financial_item':
           return $this->getFkID(FinancialItemCreationSpecProvider::DEFAULT_ENTITY);