Afform - Support ContactType-specific tabs and blocks
authorColeman Watts <coleman@civicrm.org>
Mon, 28 Nov 2022 19:50:40 +0000 (14:50 -0500)
committerColeman Watts <coleman@civicrm.org>
Mon, 28 Nov 2022 23:14:41 +0000 (18:14 -0500)
CRM/Contact/DAO/ContactType.php
CRM/Contact/Page/View/Summary.php
Civi/Api4/Service/Autocomplete/ContactTypeAutocompleteProvider.php [new file with mode: 0644]
Civi/Api4/Utils/FormattingUtil.php
ext/afform/admin/ang/afGuiEditor/config-form.html
ext/afform/core/Civi/Api4/Afform.php
ext/afform/core/afform.php
ext/afform/core/tests/phpunit/Civi/Afform/AfformContactSummaryTest.php [new file with mode: 0644]
xml/schema/Contact/ContactType.xml

index 835c6a31c3ec06afba61f8fee5c47f100b8dcf0a..a85c0bde7364d505f50c66345e48fb6d145869bb 100644 (file)
@@ -6,7 +6,7 @@
  *
  * Generated from xml/schema/CRM/Contact/ContactType.xml
  * DO NOT EDIT.  Generated by CRM_Core_CodeGen
- * (GenCodeChecksum:6ac1343e8af16b17a161fda746457d93)
+ * (GenCodeChecksum:b66036d325985536154ec623ab362e39)
  */
 
 /**
@@ -23,6 +23,13 @@ class CRM_Contact_DAO_ContactType extends CRM_Core_DAO {
    */
   public static $_tableName = 'civicrm_contact_type';
 
+  /**
+   * Field to show when displaying a record.
+   *
+   * @var string
+   */
+  public static $_labelField = 'label';
+
   /**
    * Should CiviCRM log any modifications to this table in the civicrm_log table.
    *
index 07bded7595ce02d385a442964af2a69b79a74e9f..3112a4ad70e80ca8e9b9b5bd54b4c7e1b17169ba 100644 (file)
@@ -250,7 +250,7 @@ class CRM_Contact_Page_View_Summary extends CRM_Contact_Page_View {
     $changeLog = $this->_viewOptions['log'];
     $this->assign_by_ref('changeLog', $changeLog);
 
-    $this->assign('allTabs', $this->getTabs());
+    $this->assign('allTabs', $this->getTabs($defaults));
 
     // hook for contact summary
     // ignored but needed to prevent warnings
@@ -342,7 +342,7 @@ class CRM_Contact_Page_View_Summary extends CRM_Contact_Page_View {
    * @return array
    * @throws \CRM_Core_Exception
    */
-  public function getTabs() {
+  public function getTabs(array $contact) {
     $allTabs = [];
     $getCountParams = [];
     $weight = 10;
@@ -418,7 +418,11 @@ class CRM_Contact_Page_View_Summary extends CRM_Contact_Page_View {
     }
 
     // Allow other modules to add or remove tabs
-    $context = ['contact_id' => $this->_contactId];
+    $context = [
+      'contact_id' => $contact['id'],
+      'contact_type' => $contact['contact_type'],
+      'contact_sub_type' => CRM_Utils_Array::explodePadded($contact['contact_sub_type'] ?? NULL),
+    ];
     CRM_Utils_Hook::tabset('civicrm/contact/view', $allTabs, $context);
 
     $expectedKeys = ['count', 'class', 'template', 'hideCount', 'icon'];
diff --git a/Civi/Api4/Service/Autocomplete/ContactTypeAutocompleteProvider.php b/Civi/Api4/Service/Autocomplete/ContactTypeAutocompleteProvider.php
new file mode 100644 (file)
index 0000000..d2da560
--- /dev/null
@@ -0,0 +1,54 @@
+<?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\Service\Autocomplete;
+
+use Civi\Core\Event\GenericHookEvent;
+use Civi\Core\HookInterface;
+
+/**
+ * @service
+ * @internal
+ */
+class ContactTypeAutocompleteProvider extends \Civi\Core\Service\AutoService implements HookInterface {
+
+  /**
+   * Provide default SearchDisplay for ContactType autocompletes
+   *
+   * @param \Civi\Core\Event\GenericHookEvent $e
+   */
+  public static function on_civi_search_defaultDisplay(GenericHookEvent $e) {
+    if ($e->display['settings'] || $e->display['type'] !== 'autocomplete' || $e->savedSearch['api_entity'] !== 'ContactType') {
+      return;
+    }
+    $e->display['settings'] = [
+      'sort' => [
+        ['label', 'ASC'],
+      ],
+      'columns' => [
+        [
+          'type' => 'field',
+          'key' => 'label',
+          'icons' => [
+            ['field' => 'icon'],
+          ],
+        ],
+        [
+          'type' => 'field',
+          'key' => 'description',
+          'rewrite' => "{if '[description]'}[description]{elseif '[parent_id]'}" . ts('Subtype of %1', [1 => '[parent_id:label]']) . "{/if}",
+        ],
+      ],
+    ];
+  }
+
+}
index 59c2f1e8df7feb13cfbc781aa6315b791c27904c..d91138107ccc1283fd719d9b2a8686b39d208717 100644 (file)
@@ -126,7 +126,7 @@ class FormattingUtil {
     }
 
     // Special handling for 'current_user' and user lookups
-    if ($fk === 'Contact' && !is_numeric($value)) {
+    if ($fk === 'Contact' && isset($value) && !is_numeric($value)) {
       $value = \_civicrm_api3_resolve_contactID($value);
       if ('unknown-user' === $value) {
         throw new \CRM_Core_Exception("\"{$fieldSpec['name']}\" \"{$value}\" cannot be resolved to a contact ID", 2002, ['error_field' => $fieldSpec['name'], "type" => "integer"]);
index 9648f8adb627d516c2f3c46f325d795b6fbe75a2..bc858eef3e7b7f59f3f70e8ad1178b812728f165 100644 (file)
           <option value="tab">{{:: ts('As Tab') }}</option>
         </select>
       </div>
+      <div class="form-inline" ng-if="editor.afform.contact_summary">
+        <label>{{:: ts('For') }}</label>
+        <input class="form-control" crm-autocomplete="'ContactType'" ng-model="editor.afform.summary_contact_type" auto-open="true" multi="true" crm-autocomplete-params="{key: 'name'}" placeholder="{{:: ts('Any contact type') }}">
+      </div>
       <p class="help-block" ng-show="editor.afform.contact_summary">
         {{:: ts('Placement can be configured using the Contact Layout Editor.') }}
       </p>
index 8da02bd89d2f04892af08888f99c0c64d4c3e6e2..71ce29fa65d532b1c5b52aa5338f34a6528e766a 100644 (file)
@@ -176,6 +176,11 @@ class Afform extends Generic\AbstractEntity {
             'tab' => ts('Contact Summary Tab'),
           ],
         ],
+        [
+          'name' => 'summary_contact_type',
+          'data_type' => 'Array',
+          'options' => \CRM_Contact_BAO_ContactType::contactTypePairs(),
+        ],
         [
           'name' => 'icon',
           'description' => 'Icon shown in the contact summary tab',
index 2dba9ea74cde01a868bdcd9a1c7822d3fb7de927..2b085cbe1684034a84fff4d078149297996e5feb 100644 (file)
@@ -214,25 +214,29 @@ function afform_civicrm_tabset($tabsetName, &$tabs, $context) {
   if ($tabsetName !== 'civicrm/contact/view') {
     return;
   }
+  $contactTypes = array_merge([$context['contact_type']], $context['contact_sub_type'] ?? []);
   $afforms = Civi\Api4\Afform::get(FALSE)
+    ->addSelect('name', 'title', 'icon', 'module_name', 'directive_name', 'summary_contact_type')
     ->addWhere('contact_summary', '=', 'tab')
-    ->addSelect('name', 'title', 'icon', 'module_name', 'directive_name')
+    ->addOrderBy('title')
     ->execute();
   $weight = 111;
   foreach ($afforms as $afform) {
-    $tabs[] = [
-      'id' => $afform['name'],
-      'title' => $afform['title'],
-      'weight' => $weight++,
-      'icon' => 'crm-i ' . ($afform['icon'] ?: 'fa-list-alt'),
-      'is_active' => TRUE,
-      'template' => 'afform/contactSummary/AfformTab.tpl',
-      'module' => $afform['module_name'],
-      'directive' => $afform['directive_name'],
-    ];
-    // If this is the real contact summary page (and not a callback from ContactLayoutEditor), load module.
-    if (empty($context['caller'])) {
-      Civi::service('angularjs.loader')->addModules($afform['module_name']);
+    if (empty($afform['summary_contact_type']) || array_intersect($afform['summary_contact_type'], $contactTypes)) {
+      $tabs[] = [
+        'id' => $afform['name'],
+        'title' => $afform['title'],
+        'weight' => $weight++,
+        'icon' => 'crm-i ' . ($afform['icon'] ?: 'fa-list-alt'),
+        'is_active' => TRUE,
+        'template' => 'afform/contactSummary/AfformTab.tpl',
+        'module' => $afform['module_name'],
+        'directive' => $afform['directive_name'],
+      ];
+      // If this is the real contact summary page (and not a callback from ContactLayoutEditor), load module.
+      if (empty($context['caller'])) {
+        Civi::service('angularjs.loader')->addModules($afform['module_name']);
+      }
     }
   }
 }
@@ -246,24 +250,40 @@ function afform_civicrm_pageRun(&$page) {
   if (!in_array(get_class($page), ['CRM_Contact_Page_View_Summary', 'CRM_Contact_Page_View_Print'])) {
     return;
   }
-  $scanner = \Civi::service('afform_scanner');
+  $afforms = Civi\Api4\Afform::get(FALSE)
+    ->addSelect('name', 'title', 'icon', 'module_name', 'directive_name', 'summary_contact_type')
+    ->addWhere('contact_summary', '=', 'block')
+    ->addOrderBy('title')
+    ->execute();
   $cid = $page->get('cid');
+  $contact = NULL;
   $side = 'left';
-  foreach ($scanner->getMetas() as $afform) {
-    if (!empty($afform['contact_summary']) && $afform['contact_summary'] === 'block') {
-      $module = _afform_angular_module_name($afform['name']);
-      $block = [
-        'module' => $module,
-        'directive' => _afform_angular_module_name($afform['name'], 'dash'),
-      ];
-      $content = CRM_Core_Smarty::singleton()->fetchWith('afform/contactSummary/AfformBlock.tpl', ['contactId' => $cid, 'block' => $block]);
-      CRM_Core_Region::instance("contact-basic-info-$side")->add([
-        'markup' => '<div class="crm-summary-block">' . $content . '</div>',
-        'weight' => 1,
-      ]);
-      Civi::service('angularjs.loader')->addModules($module);
-      $side = $side === 'left' ? 'right' : 'left';
+  $weight = ['left' => 1, 'right' => 1];
+  foreach ($afforms as $afform) {
+    // If Afform specifies a contact type, lookup the contact and compare
+    if (!empty($afform['summary_contact_type'])) {
+      // Contact.get only needs to happen once
+      $contact = $contact ?? civicrm_api4('Contact', 'get', [
+        'select' => ['contact_type', 'contact_sub_type'],
+        'where' => [['id', '=', $cid]],
+      ])->first();
+      $contactTypes = array_merge([$contact['contact_type']], $contact['contact_sub_type'] ?? []);
+      if (!array_intersect($afform['summary_contact_type'], $contactTypes)) {
+        continue;
+      }
     }
+    $block = [
+      'module' => $afform['module_name'],
+      'directive' => _afform_angular_module_name($afform['name'], 'dash'),
+    ];
+    $content = CRM_Core_Smarty::singleton()->fetchWith('afform/contactSummary/AfformBlock.tpl', ['contactId' => $cid, 'block' => $block]);
+    CRM_Core_Region::instance("contact-basic-info-$side")->add([
+      'markup' => '<div class="crm-summary-block">' . $content . '</div>',
+      'name' => 'afform:' . $afform['name'],
+      'weight' => $weight[$side]++,
+    ]);
+    Civi::service('angularjs.loader')->addModules($afform['module_name']);
+    $side = $side === 'left' ? 'right' : 'left';
   }
 }
 
@@ -274,9 +294,11 @@ function afform_civicrm_pageRun(&$page) {
  */
 function afform_civicrm_contactSummaryBlocks(&$blocks) {
   $afforms = \Civi\Api4\Afform::get(FALSE)
-    ->setSelect(['name', 'title', 'directive_name', 'module_name', 'type', 'type:icon', 'type:label'])
+    ->setSelect(['name', 'title', 'directive_name', 'module_name', 'type', 'type:icon', 'type:label', 'summary_contact_type'])
     ->addWhere('contact_summary', '=', 'block')
+    ->addOrderBy('title')
     ->execute();
+  $allContactTypes = \CRM_Contact_BAO_ContactType::getAllContactTypes();
   foreach ($afforms as $index => $afform) {
     // Create a group per afform type
     $blocks += [
@@ -286,8 +308,17 @@ function afform_civicrm_contactSummaryBlocks(&$blocks) {
         'blocks' => [],
       ],
     ];
+    $contactType = [];
+    // If the form specifies contact types, resolve them to just the parent types (Individual, Organization, Household)
+    // because ContactLayout doesn't care about sub-types
+    foreach ($afform['summary_contact_type'] ?? [] as $name) {
+      $parent = $allContactTypes[$name]['parent'] ?? $name;
+      $contactType[$parent] = $parent;
+    }
     $blocks["afform_{$afform['type']}"]['blocks'][$afform['name']] = [
       'title' => $afform['title'],
+      // ContactLayout only supports a single contact type
+      'contact_type' => count($contactType) === 1 ? CRM_Utils_Array::first($contactType) : NULL,
       'tpl_file' => 'afform/contactSummary/AfformBlock.tpl',
       'module' => $afform['module_name'],
       'directive' => $afform['directive_name'],
diff --git a/ext/afform/core/tests/phpunit/Civi/Afform/AfformContactSummaryTest.php b/ext/afform/core/tests/phpunit/Civi/Afform/AfformContactSummaryTest.php
new file mode 100644 (file)
index 0000000..3b892ae
--- /dev/null
@@ -0,0 +1,145 @@
+<?php
+namespace Civi\Afform;
+
+// Hopefully temporry workaround for loading core test classes
+require_once __DIR__ . '/../../../../../../../tests/phpunit/api/v4/Api4TestBase.php';
+
+use Civi\Api4\Afform;
+
+/**
+ * @group headless
+ */
+class AfformContactSummaryTest extends \api\v4\Api4TestBase {
+
+  private $formNames = [
+    'contact_summary_test1',
+    'contact_summary_test2',
+    'contact_summary_test3',
+  ];
+
+  public function setUpHeadless() {
+    return \Civi\Test::headless()->installMe(__DIR__)->install('org.civicrm.search_kit')->apply();
+  }
+
+  public function tearDown(): void {
+    Afform::revert(FALSE)->addWhere('name', 'IN', $this->formNames)->execute();
+    parent::tearDown();
+  }
+
+  public function testAfformContactSummaryTab(): void {
+    $this->saveTestRecords('ContactType', [
+      'records' => [
+        ['name' => 'FooBar', 'label' => 'FooBar', 'parent_id:name' => 'Individual'],
+      ],
+      'match' => ['name'],
+    ]);
+
+    Afform::create()
+      ->addValue('name', $this->formNames[0])
+      ->addValue('title', 'Test B')
+      ->addValue('contact_summary', 'tab')
+      ->addValue('summary_contact_type', ['Organization'])
+      ->execute();
+    Afform::create()
+      ->addValue('name', $this->formNames[1])
+      ->addValue('title', 'Test C')
+      ->addValue('contact_summary', 'tab')
+      ->addValue('summary_contact_type', ['FooBar'])
+      ->addValue('icon', 'smiley-face')
+      ->execute();
+    Afform::create()
+      ->addValue('name', $this->formNames[2])
+      ->addValue('title', 'Test A')
+      ->addValue('contact_summary', 'tab')
+      ->execute();
+
+    $tabs = [];
+    $context = [
+      'contact_id' => 0,
+      'contact_type' => 'Individual',
+      'contact_sub_type' => ['FooBar'],
+      'caller' => 'UnitTests',
+    ];
+    \CRM_Utils_Hook::tabset('civicrm/contact/view', $tabs, $context);
+
+    $tabs = array_column($tabs, NULL, 'id');
+
+    $this->assertArrayHasKey($this->formNames[1], $tabs);
+    $this->assertArrayHasKey($this->formNames[2], $tabs);
+    $this->assertArrayNotHasKey($this->formNames[0], $tabs);
+    $this->assertEquals('Test C', $tabs[$this->formNames[1]]['title']);
+    $this->assertEquals('Test A', $tabs[$this->formNames[2]]['title']);
+    $this->assertEquals('crm-i smiley-face', $tabs[$this->formNames[1]]['icon']);
+    // Fallback icon
+    $this->assertEquals('crm-i fa-list-alt', $tabs[$this->formNames[2]]['icon']);
+    // Forms should be sorted by title alphabetically
+    $this->assertGreaterThan($tabs[$this->formNames[2]]['weight'], $tabs[$this->formNames[1]]['weight']);
+  }
+
+  public function testAfformContactSummaryBlock(): void {
+    $this->saveTestRecords('ContactType', [
+      'records' => [
+        ['name' => 'Farm', 'label' => 'Farm', 'parent_id:name' => 'Organization'],
+      ],
+      'match' => ['name'],
+    ]);
+
+    $cid = $this->createTestRecord('Contact', [
+      'contact_type' => 'Organization',
+      'contact_sub_type' => ['Farm'],
+    ])['id'];
+
+    Afform::create()
+      ->addValue('name', $this->formNames[0])
+      ->addValue('title', 'Test B')
+      ->addValue('type', 'search')
+      ->addValue('contact_summary', 'block')
+      ->addValue('summary_contact_type', ['Individual', 'Household'])
+      ->execute();
+    Afform::create()
+      ->addValue('name', $this->formNames[1])
+      ->addValue('title', 'Test C')
+      ->addValue('type', 'form')
+      ->addValue('contact_summary', 'block')
+      ->addValue('summary_contact_type', ['Farm'])
+      ->addValue('icon', 'smiley-face')
+      ->execute();
+    Afform::create()
+      ->addValue('name', $this->formNames[2])
+      ->addValue('type', 'form')
+      ->addValue('title', 'Test A')
+      ->addValue('contact_summary', 'block')
+      ->execute();
+
+    // Call pageRun hook and then assert afforms have been added to the appropriate region
+    $dummy = new \CRM_Contact_Page_View_Summary();
+    $dummy->set('cid', $cid);
+    \CRM_Utils_Hook::pageRun($dummy);
+
+    // TODO: Be more flexible
+    // The presence of any other afform blocks in the system might alter the left-right assumptions here
+    $blockA = \CRM_Core_Region::instance('contact-basic-info-left')->get('afform:' . $this->formNames[2]);
+    $this->assertStringContainsString("<contact-summary-test3 options=\"{contact_id: $cid}\"></contact-summary-test3>", $blockA['markup']);
+
+    $blockB = \CRM_Core_Region::instance('contact-basic-info-right')->get('afform:' . $this->formNames[1]);
+    $this->assertStringContainsString("<contact-summary-test2 options=\"{contact_id: $cid}\"></contact-summary-test2>", $blockB['markup']);
+
+    // Block for wrong contact type should not appear
+    $this->assertNull(\CRM_Core_Region::instance('contact-basic-info-left')->get('afform:' . $this->formNames[0]));
+    $this->assertNull(\CRM_Core_Region::instance('contact-basic-info-right')->get('afform:' . $this->formNames[0]));
+
+    // Ensure blocks show up in ContactLayoutEditor
+    $blocks = [];
+    afform_civicrm_contactSummaryBlocks($blocks);
+
+    // ContactLayout doesn't support > 1 contact type, so this ought to be null
+    $this->assertNull($blocks['afform_search']['blocks'][$this->formNames[0]]['contact_type']);
+    // Sub-type should have been converted to parent type
+    $this->assertEquals('Organization', $blocks['afform_form']['blocks'][$this->formNames[1]]['contact_type']);
+    $this->assertNull($blocks['afform_form']['blocks'][$this->formNames[2]]['contact_type']);
+    // Forms should be sorted by title
+    $order = array_flip(array_keys($blocks['afform_form']['blocks']));
+    $this->assertGreaterThan($order[$this->formNames[2]], $order[$this->formNames[1]]);
+  }
+
+}
index 7b0af3130795ac6c215d3fce7a467247d5fc0bba..7acb29f92670fc4386bb95fd68ca37ef570a44d3 100644 (file)
@@ -6,6 +6,7 @@
   <name>civicrm_contact_type</name>
   <comment>Provide type information for contacts</comment>
   <add>3.1</add>
+  <labelField>label</labelField>
   <paths>
     <add>civicrm/admin/options/subtype/edit?action=add&amp;reset=1</add>
     <update>civicrm/admin/options/subtype/edit?action=update&amp;id=[id]&amp;reset=1</update>
@@ -33,6 +34,7 @@
     <comment>Internal name of Contact Type (or Subtype).</comment>
     <html>
       <label>Name</label>
+      <type>Text</type>
     </html>
     <add>3.1</add>
     <required>true</required>
     <type>varchar</type>
     <length>64</length>
     <comment>localized Name of Contact Type.</comment>
+    <html>
+      <label>Label</label>
+      <type>Text</type>
+    </html>
     <localizable>true</localizable>
     <add>3.1</add>
   </field>
     </pseudoconstant>
     <html>
       <label>Parent</label>
+      <type>Select</type>
     </html>
     <add>3.1</add>
   </field>