Afform - support filters in custom contactRef & core entityRef fields
authorColeman Watts <coleman@civicrm.org>
Tue, 25 Oct 2022 02:11:50 +0000 (22:11 -0400)
committerColeman Watts <coleman@civicrm.org>
Thu, 10 Nov 2022 17:34:45 +0000 (12:34 -0500)
Fixes dev/core#3425
Provides a common format for "filters" in both core and custom entityRef fields.
For example, the `employer_id` field now automatically filters `contact_type=Organization`.
Custom fields use the same format for filters (basically URL encoded format),
and APIv4 now reads them both and applies them when the field is included in an Afform.

12 files changed:
CRM/Contact/DAO/Contact.php
CRM/Contact/Form/Edit/Individual.php
CRM/Core/CodeGen/Specification.php
CRM/Core/Form.php
Civi/Api4/Generic/Traits/SavedSearchInspectorTrait.php
Civi/Api4/Service/Spec/SpecFormatter.php
ext/afform/core/Civi/Api4/Subscriber/AutocompleteSubscriber.php
ext/afform/mock/tests/phpunit/api/v4/AfformAutocompleteUsageTest.php
tests/phpunit/api/v4/Action/GetFieldsTest.php
tests/phpunit/api/v4/Custom/CustomFieldGetFieldsTest.php
xml/schema/Contact/Contact.xml
xml/templates/dao.tpl

index ff31535268931a471e83371f9a5739a0f5c9a5af..19890da4926d6c2a02cfd4952a3886ae60a97c20 100644 (file)
@@ -6,7 +6,7 @@
  *
  * Generated from xml/schema/CRM/Contact/Contact.xml
  * DO NOT EDIT.  Generated by CRM_Core_CodeGen
- * (GenCodeChecksum:8cdd2fa476983d8770b53cc7665586cf)
+ * (GenCodeChecksum:44f607f6289003a3b6715c7b9103359b)
  */
 
 /**
@@ -1603,6 +1603,9 @@ class CRM_Contact_DAO_Contact extends CRM_Core_DAO {
           'html' => [
             'type' => 'EntityRef',
             'label' => ts("Current Employer"),
+            'filter' => [
+              'contact_type=Organization',
+            ],
           ],
           'add' => '2.1',
         ],
index af646a350c47b415f66a14c0c86e84fe9f2b09be..30bb35f67dea7d685250f5306d7fd245d33ee32c 100644 (file)
@@ -72,11 +72,7 @@ class CRM_Contact_Form_Edit_Individual {
       $form->addField('job_title', ['size' => '30']);
 
       //Current Employer Element
-      $props = [
-        'api' => ['params' => ['contact_type' => 'Organization']],
-        'create' => TRUE,
-      ];
-      $form->addField('employer_id', $props);
+      $form->addField('employer_id', ['create' => TRUE]);
       $form->addField('contact_source', ['class' => 'big']);
     }
 
index e8b2145c52121f2ad44e6fbb298f2f0212b504bf..44309594fb4f55e450b2f1c521eb53beadf6a4e7 100644 (file)
@@ -413,6 +413,9 @@ class CRM_Core_CodeGen_Specification {
           $field['html'][$htmlOption] = $this->value($htmlOption, $fieldXML->html);
         }
       }
+      if (isset($fieldXML->html->filter)) {
+        $field['html']['filter'] = (array) $fieldXML->html->filter;
+      }
     }
 
     // in multilingual context popup, we need extra information to create appropriate widget
index 2d5d6c745ad9975fca02a156812e78018d42a5d4..95834816b6d52dc0e2b1fac7220888627dd89ff5 100644 (file)
@@ -1788,7 +1788,9 @@ class CRM_Core_Form extends HTML_QuickForm_Page {
         $props['data-api-field'] = $props['name'];
       }
     }
-    $props += CRM_Utils_Array::value('html', $fieldSpec, []);
+    $htmlProps = (array) ($fieldSpec['html'] ?? []);
+    CRM_Utils_Array::remove($htmlProps, 'label', 'filter');
+    $props += $htmlProps;
     if (in_array($widget, ['Select', 'Select2'])
       && !array_key_exists('placeholder', $props)
       && $placeholder = self::selectOrAnyPlaceholder($props, $required, $label)) {
@@ -1889,6 +1891,11 @@ class CRM_Core_Form extends HTML_QuickForm_Page {
         return $this->add('wysiwyg', $name, $label, $props, $required);
 
       case 'EntityRef':
+        // Auto-apply filters from field metadata
+        foreach ($fieldSpec['html']['filter'] ?? [] as $filter) {
+          [$k, $v] = explode('=', $filter);
+          $props['api']['params'][$k] = $v;
+        }
         return $this->addEntityRef($name, $label, $props, $required);
 
       case 'Password':
index c9b03ab24d2497b7d2ddc2719074168efb1ed773..27094dd9141f65d436e3d6f87bedf3770b58e2b0 100644 (file)
@@ -218,6 +218,7 @@ trait SavedSearchInspectorTrait {
     foreach ($fieldNames as $fieldName) {
       $field = $this->getField($fieldName);
       $dataType = $field['data_type'] ?? NULL;
+      $operators = ($field['operators'] ?? []) ?: CoreUtil::getOperators();
       // Array is either associative `OP => VAL` or sequential `IN (...)`
       if (is_array($value)) {
         $value = array_filter($value, [$this, 'hasValue']);
@@ -245,18 +246,24 @@ trait SavedSearchInspectorTrait {
           $filterClauses[] = ['AND', $andGroup];
         }
       }
-      elseif (!empty($field['serialize'])) {
+      elseif (!empty($field['serialize']) && in_array('CONTAINS', $operators, TRUE)) {
         $filterClauses[] = [$fieldName, 'CONTAINS', $value];
       }
-      elseif (!empty($field['options']) || in_array($dataType, ['Integer', 'Boolean', 'Date', 'Timestamp'])) {
+      elseif ((!empty($field['options']) || in_array($dataType, ['Integer', 'Boolean', 'Date', 'Timestamp'])) && in_array('=', $operators, TRUE)) {
         $filterClauses[] = [$fieldName, '=', $value];
       }
-      elseif ($prefixWithWildcard) {
+      elseif ($prefixWithWildcard && in_array('CONTAINS', $operators, TRUE)) {
         $filterClauses[] = [$fieldName, 'CONTAINS', $value];
       }
-      else {
+      elseif (in_array('LIKE', $operators, TRUE)) {
         $filterClauses[] = [$fieldName, 'LIKE', $value . '%'];
       }
+      elseif (in_array('IN', $operators, TRUE)) {
+        $filterClauses[] = [$fieldName, 'IN', (array) $value];
+      }
+      else {
+        $filterClauses[] = [$fieldName, '=', $value];
+      }
     }
     // Single field
     if (count($filterClauses) === 1) {
index 73579605747a7e6ed8ec9207990067c26fae28f3..4f280e66c2b3c9ed29e5797eaecf76d92e479833 100644 (file)
@@ -281,6 +281,11 @@ class SpecFormatter {
     $inputType = $data['html']['type'] ?? $data['html_type'] ?? NULL;
     $inputAttrs = $data['html'] ?? [];
     unset($inputAttrs['type']);
+    // Custom field contact ref filters
+    if (is_string($data['filter'] ?? NULL) && strpos($data['filter'], '=')) {
+      $filters = explode('&', $data['filter']);
+      $inputAttrs['filter'] = $filters;
+    }
 
     $map = [
       'Select Date' => 'Date',
@@ -321,9 +326,25 @@ class SpecFormatter {
     foreach ($inputAttrs as $key => $val) {
       if ($key !== strtolower($key)) {
         unset($inputAttrs[$key]);
-        $key = strtolower(preg_replace('/(?=[A-Z])/', '_$0', $key));
+        $key = \CRM_Utils_String::convertStringToSnakeCase($key);
         $inputAttrs[$key] = $val;
       }
+      // Format EntityRef filter property (core and custom fields)
+      if ($key === 'filter' && is_array($val)) {
+        $filters = [];
+        foreach ($val as $filter) {
+          [$k, $v] = explode('=', $filter);
+          $filters[$k] = $v;
+        }
+        // Legacy APIv3 custom field stuff
+        if ($dataTypeName === 'ContactReference') {
+          if (!empty($filters['group'])) {
+            $filters['groups'] = $filters['group'];
+          }
+          unset($filters['action'], $filters['group']);
+        }
+        $inputAttrs['filter'] = $filters;
+      }
     }
     $fieldSpec
       ->setInputType($inputType)
index d288a99c6f7de980b398ad183d67a7763172d371..5f6631fdea39a0a618045863595bae9c7e9a3b32 100644 (file)
@@ -54,12 +54,15 @@ class AutocompleteSubscriber implements EventSubscriberInterface {
       $formDataModel = new FormDataModel($afform['layout']);
       $entity = $formDataModel->getEntity($entityName);
       $isId = $fieldName === CoreUtil::getIdFieldName($entity['type']);
+      $field = civicrm_api4($entity['type'], 'getFields', [
+        'checkPermissions' => FALSE,
+        'where' => [['name', '=', $fieldName]],
+      ])->single();
 
       // For the "Existing Entity" selector,
       // Look up the "type" fields (e.g. contact_type, activity_type_id, case_type_id, etc)
       // And apply it as a filter if specified on the form.
       if ($isId) {
-        $typeFields = [];
         if ($entity['type'] === 'Contact') {
           $typeFields = ['contact_type', 'contact_sub_type'];
         }
@@ -74,6 +77,9 @@ class AutocompleteSubscriber implements EventSubscriberInterface {
           }
         }
       }
+      foreach ($field['input_attrs']['filter'] ?? [] as $key => $value) {
+        $apiRequest->addFilter($key, $value);
+      }
 
       $apiRequest->setCheckPermissions($entity['security'] !== 'FBAC');
       $apiRequest->setSavedSearch($entity['fields'][$fieldName]['defn']['saved_search'] ?? NULL);
index 748c8fe32e4ff2a4c16e6ee5bcce0e26843e51de..00dab56e672ef95c14c945c979640ab0db8445a2 100644 (file)
@@ -1,6 +1,7 @@
 <?php
 
 use Civi\Api4\Contact;
+use Civi\Api4\GroupContact;
 
 /**
  * Test case for Afform with autocomplete.
@@ -71,4 +72,62 @@ EOHTML;
     $this->assertEquals('B ' . $lastName, $result[1]['label']);
   }
 
+  public function testCustomContactRefFieldWithGroupsFilter(): void {
+    $lastName = uniqid(__FUNCTION__);
+
+    $sampleData = [
+      ['last_name' => $lastName, 'first_name' => 'A'],
+      ['last_name' => $lastName, 'first_name' => 'B'],
+      ['last_name' => $lastName, 'first_name' => 'C'],
+    ];
+
+    $contacts = Contact::save(FALSE)
+      ->setRecords($sampleData)
+      ->execute();
+
+    $group = \Civi\Api4\Group::create(FALSE)
+      ->addValue('name', $lastName)
+      ->addValue('title', $lastName)
+      ->addChain('A', GroupContact::create()->addValue('group_id', '$id')->addValue('contact_id', $contacts[0]['id']))
+      ->addChain('B', GroupContact::create()->addValue('group_id', '$id')->addValue('contact_id', $contacts[1]['id']))
+      ->execute()->single();
+
+    \Civi\Api4\CustomGroup::create(FALSE)
+      ->addValue('title', 'test_af_fields')
+      ->addValue('extends', 'Contact')
+      ->addChain('fields', \Civi\Api4\CustomField::save()
+        ->addDefault('custom_group_id', '$id')
+        ->setRecords([
+          ['label' => 'contact_ref', 'data_type' => 'ContactReference', 'html_type' => 'Autocomplete', 'filter' => 'action=get&group=' . $group['id']],
+        ])
+      )
+      ->execute();
+
+    $layout = <<<EOHTML
+<af-form ctrl="afform">
+  <af-entity data="{contact_type: 'Individual'}" type="Contact" name="Individual1" label="Individual 1" actions="{create: true, update: true}" security="RBAC" />
+  <fieldset af-fieldset="Individual1" class="af-container" af-title="Individual 1">
+    <div class="af-container">
+      <af-field name="test_af_fields.contact_ref" />
+    </div>
+  </fieldset>
+</af-form>
+EOHTML;
+
+    $this->useValues([
+      'layout' => $layout,
+      'permission' => CRM_Core_Permission::ALWAYS_ALLOW_PERMISSION,
+    ]);
+
+    $result = Contact::autocomplete()
+      ->setFormName('afform:' . $this->formName)
+      ->setFieldName('Individual1:test_af_fields.contact_ref')
+      ->setInput($lastName)
+      ->execute();
+
+    $this->assertCount(2, $result);
+    $this->assertEquals('A ' . $lastName, $result[0]['label']);
+    $this->assertEquals('B ' . $lastName, $result[1]['label']);
+  }
+
 }
index 840e9ceebbadbb73a0beb5a9ae38727cd5229eb0..006cd6cfaa4137b5ad8ae5363af17b83db839b66 100644 (file)
@@ -139,4 +139,11 @@ class GetFieldsTest extends Api4TestBase implements TransactionalInterface {
     $this->assertEquals('Contact', $tagFields['entity_id']['fk_entity']);
   }
 
+  public function testFiltersAreReturned(): void {
+    $field = Contact::getFields(FALSE)
+      ->addWhere('name', '=', 'employer_id')
+      ->execute()->single();
+    $this->assertEquals(['contact_type' => 'Organization'], $field['input_attrs']['filter']);
+  }
+
 }
index 064a7cf8efec27c7c634c3a059658056044e7d74..191c956d69d6c983e37fdd49c6e7f782e8694fe3 100644 (file)
@@ -280,4 +280,21 @@ class CustomFieldGetFieldsTest extends CustomTestBase {
     $this->assertArrayHasKey('always.on', $participant3Fields);
   }
 
+  public function testFiltersAreReturnedForContactRefFields(): void {
+    $grp = CustomGroup::create(FALSE)
+      ->addValue('extends', 'Activity')
+      ->addValue('title', 'act_test_grp2')
+      ->execute()->single();
+    $field = $this->createTestRecord('CustomField', [
+      'data_type' => 'ContactReference',
+      'html_type' => 'Autocomplete-Select',
+      'custom_group_id' => $grp['id'],
+      'filter' => 'action=get&contact_type=Household&group=2',
+    ]);
+    $getField = Activity::getFields(FALSE)
+      ->addWhere('custom_field_id', '=', $field['id'])
+      ->execute()->single();
+    $this->assertEquals(['contact_type' => 'Household', 'groups' => 2], $getField['input_attrs']['filter']);
+  }
+
 }
index f00299697ebfe03136e58b2687bac43b87c074e7..94fffd3b48135397e776e625c1308c18241aa052 100644 (file)
     <html>
       <type>EntityRef</type>
       <label>Current Employer</label>
+      <filter>contact_type=Organization</filter>
     </html>
     <contactType>Individual</contactType>
   </field>
index 7a14de4129c6f8aa92856dea30b72ea074cfa3d9..7504906dbd2320a788351d3c3ba0ee3cbe37f4e9 100644 (file)
@@ -229,7 +229,7 @@ class {$table.className} extends CRM_Core_DAO {ldelim}
 {if $field.html}
   'html' => array(
   {foreach from=$field.html item=val key=key}
-    '{$key}' => {if $key eq 'label'}{$tsFunctionName}("{$val}"){else}'{$val}'{/if},
+    '{$key}' => {if $key eq 'label'}{$tsFunctionName}("{$val}"){elseif is_array($val)}{$val|@print_array}{else}'{$val}'{/if},
   {/foreach}
   ),
 {/if}