Afform - configure existing entity widget
authorColeman Watts <coleman@civicrm.org>
Fri, 5 Aug 2022 18:10:51 +0000 (14:10 -0400)
committerColeman Watts <coleman@civicrm.org>
Wed, 10 Aug 2022 02:28:44 +0000 (22:28 -0400)
Civi/Api4/Generic/AutocompleteAction.php
Civi/Api4/Generic/Traits/SavedSearchInspectorTrait.php
ang/crmUi.js
ext/afform/admin/Civi/AfformAdmin/AfformAdminMeta.php
ext/afform/admin/ang/afGuiEditor/elements/afGuiField-menu.html
ext/afform/admin/ang/afGuiEditor/elements/afGuiField.component.js
ext/afform/admin/ang/afGuiEditor/inputType/Existing.html [new file with mode: 0644]
ext/afform/core/Civi/Afform/AfformMetadataInjector.php
ext/afform/core/Civi/Api4/Subscriber/AutocompleteSubscriber.php
ext/afform/core/ang/af/fields/Existing.html [new file with mode: 0644]

index 700310ad6a85f8e39485ce5bad31b16417a13ff3..0e406cfc75e0d4f55b0cde55368a723b56d13b4b 100644 (file)
@@ -182,6 +182,21 @@ class AutocompleteAction extends AbstractAction {
     ) {
       return TRUE;
     }
+    // Proceed only if permissions are being enforced.'
+    // Anonymous users in permission-bypass mode should not be allowed to set arbitrary filters.
+    if ($this->getCheckPermissions()) {
+      $field = $this->getField($fieldName);
+      try {
+        civicrm_api4($field['entity'], 'getFields', [
+          'action' => 'get',
+          'where' => [['name', '=', $fieldName]],
+        ])->single();
+        return TRUE;
+      }
+      catch (\CRM_Core_Exception $e) {
+        return FALSE;
+      }
+    }
     return FALSE;
   }
 
index 45e6f0c237a16544f6336be2ffdbae0ef5f13db5..0cd0f230a11929bc71edd4655f32b230d33d87f7 100644 (file)
@@ -56,7 +56,7 @@ trait SavedSearchInspectorTrait {
     }
     if (is_array($this->savedSearch)) {
       $this->savedSearch += ['api_params' => []];
-      $this->savedSearch['api_params'] += ['select' => [], 'where' => []];
+      $this->savedSearch['api_params'] += ['version' => 4, 'select' => [], 'where' => []];
     }
     $this->_apiParams = ($this->savedSearch['api_params'] ?? []) + ['select' => [], 'where' => []];
   }
index 1e59ef0dbd49fee8edb8c586b6b0680b436d69a6..33588accbd85052222f14d3873dddd5c14815818 100644 (file)
       };
     })
 
+    // Render a crmAutocomplete APIv4 widget
+    // usage: <input crm-autocomplete="'Contact'" crm-autocomplete-params={savedSearch: 'mySearch', filters: {is_deceased: false}}" ng-model="myobj.field" />
+    .directive('crmAutocomplete', function () {
+      return {
+        require: {
+          ngModel: '?ngModel'
+        },
+        bindToController: {
+          crmAutocomplete: '<',
+          crmAutocompleteParams: '<'
+        },
+        controller: function($element, $timeout) {
+          var ctrl = this;
+          $timeout(function() {
+            $element.crmAutocomplete(ctrl.crmAutocomplete, ctrl.crmAutocompleteParams);
+            // Ensure widget is updated when model changes
+            if (ctrl.ngModel) {
+              ctrl.ngModel.$render = function() {
+                $element.val(ctrl.ngModel.$viewValue || '').change();
+              };
+            }
+          });
+        }
+      };
+    })
+
     // validate multiple email text
     // usage: <input crm-multiple-email type="text" ng-model="myobj.field" />
     .directive('crmMultipleEmail', function ($parse, $timeout) {
index 566a5d387c320bcf0a02f36f4b6dffbe110ed7f3..469b97dd929510d197b35aecc9c20d2ac37dba4a 100644 (file)
@@ -127,7 +127,7 @@ class AfformAdminMeta {
       // Add existing entity field
       $idField = CoreUtil::getIdFieldName($entityName);
       $fields[$idField]['readonly'] = FALSE;
-      $fields[$idField]['input_type'] = 'EntityRef';
+      $fields[$idField]['input_type'] = 'Existing';
       $fields[$idField]['is_id'] = TRUE;
       $fields[$idField]['label'] = E::ts('Existing %1', [1 => CoreUtil::getInfoItem($entityName, 'title')]);
       // Mix in alterations declared by afform entities
index 85de8d309f5a915ef0e397608a59e5125ce622c7..fa12d3f8432c65ec64e76c9fa908f636d7dfd611 100644 (file)
@@ -1,4 +1,4 @@
-<li>
+<li ng-if="$ctrl.fieldDefn.input_type !== 'Existing'">
   <div href ng-click="$event.stopPropagation()" class="af-gui-field-select-in-dropdown">
     <label>{{:: ts('Type:') }}</label>
     <select class="form-control" ng-model="getSet('input_type')" ng-model-options="{getterSetter: true}" title="{{:: ts('Field type') }}">
@@ -6,19 +6,31 @@
     </select>
   </div>
 </li>
+<li ng-if="$ctrl.fieldDefn.input_type === 'Existing'" title="{{:: ts('Use a saved search to filter the autocomplete results') }}">
+  <div href ng-click="$event.stopPropagation()" class="af-gui-field-select-in-dropdown">
+    <label>{{:: ts('Saved Search:') }}</label>
+    <input class="form-control" crm-entityref="{entity: 'SavedSearch', api: {id_field: 'name', params: {api_entity: $ctrl.getEntity().name}}}" ng-model="getSet('saved_search')" ng-model-options="{getterSetter: true}">
+  </div>
+</li>
 <li>
   <a href ng-click="toggleRequired(); $event.stopPropagation(); $event.target.blur();" title="{{:: ts('Require this field') }}">
     <i class="crm-i fa-{{ getProp('required') ? 'check-' : '' }}square-o"></i>
     {{:: ts('Required') }}
   </a>
 </li>
-<li>
+<li ng-if="$ctrl.fieldDefn.input_type === 'Existing'">
+  <a href ng-click="toggleSkipPermissions(); $event.stopPropagation(); $event.target.blur();" title="{{:: ts('Allows non-permissioned users to access any record returned by the saved search') }}">
+    <i class="crm-i fa-{{ getProp('skip_permissions') ? 'check-' : '' }}square-o"></i>
+    {{:: ts('Disable Permission Checks') }}
+  </a>
+</li>
+<li ng-if="$ctrl.fieldDefn.input_type !== 'Existing'">
   <a href ng-click="toggleDefaultValue(); $event.stopPropagation(); $event.target.blur();" title="{{:: ts('Pre-fill this field with a value') }}">
     <i class="crm-i fa-{{ $ctrl.hasDefaultValue ? 'check-' : '' }}square-o"></i>
     {{:: ts('Default value') }}
   </a>
 </li>
-<li ng-if="$ctrl.hasDefaultValue">
+<li ng-if="$ctrl.fieldDefn.input_type !== 'Existing' && $ctrl.hasDefaultValue">
   <div ng-click="$event.stopPropagation()" class="af-gui-field-select-in-dropdown form-inline">
     <input class="form-control" af-gui-field-value="$ctrl.fieldDefn" ng-model="getSet('afform_default')" ng-model-options="{getterSetter: true}" >
   </div>
index aacb628252a5fc6f9a0e22d4a75605f52702cb96..b17c0294427980c60ef0a501a48a25adbc524084 100644 (file)
         return defn;
       };
 
+      // Get the api entity this field belongs to
+      this.getEntity = function() {
+        return afGui.getEntity(ctrl.container.getFieldEntityType(ctrl.node.name));
+      };
+
       $scope.getOriginalLabel = function() {
+        // Use afform entity if available (e.g. "Individual1")
         if (ctrl.container.getEntityName()) {
           return ctrl.editor.getEntity(ctrl.container.getEntityName()).label + ': ' + ctrl.getDefn().label;
         }
-        return afGui.getEntity(ctrl.container.getFieldEntityType(ctrl.node.name)).label + ': ' + ctrl.getDefn().label;
+        // Use generic entity (e.g. "Contact")
+        return ctrl.getEntity().label + ': ' + ctrl.getDefn().label;
       };
 
       $scope.hasOptions = function() {
         getSet('required', !getSet('required'));
       };
 
+      $scope.toggleSkipPermissions = function() {
+        getSet('skip_permissions', !getSet('skip_permissions'));
+      };
+
       $scope.toggleHelp = function(position) {
         getSet('help_' + position, $scope.propIsset('help_' + position) ? null : (ctrl.getDefn()['help_' + position] || ts('Enter text')));
       };
diff --git a/ext/afform/admin/ang/afGuiEditor/inputType/Existing.html b/ext/afform/admin/ang/afGuiEditor/inputType/Existing.html
new file mode 100644 (file)
index 0000000..d253836
--- /dev/null
@@ -0,0 +1,8 @@
+<div class="form-inline">
+  <div class="input-group">
+    <input autocomplete="off" type="text" class="form-control" placeholder="{{:: ts('Select %1', {1: $ctrl.getFkEntity().label}) }}" title="{{:: ts('Click to add placeholder text') }}" ng-model="getSet('input_attrs.placeholder')" ng-model-options="$ctrl.editor.debounceWithGetterSetter">
+    <div class="input-group-btn">
+      <button type="button" class="btn btn-default" disabled><i class="crm-i fa-search"></i></button>
+    </div>
+  </div>
+</div>
index ca20b3ac78f55ea0ab1ddd8b130fe375f071e43e..be6cc1a16c777324cc6624ca68ffa53060c4f5eb 100644 (file)
@@ -11,6 +11,7 @@
 
 namespace Civi\Afform;
 
+use Civi\Api4\Utils\CoreUtil;
 use CRM_Afform_ExtensionUtil as E;
 
 /**
@@ -41,7 +42,7 @@ class AfformMetadataInjector {
         // Each field can be nested within a fieldset, a join or a block
         foreach (pq('af-field', $doc) as $afField) {
           /** @var \DOMElement $afField */
-          $action = 'create';
+          $action = 'update';
           $joinName = pq($afField)->parents('[af-join]')->attr('af-join');
           if ($joinName) {
             self::fillFieldMetadata($joinName, $action, $afField);
@@ -178,6 +179,14 @@ class AfformMetadataInjector {
         break;
       }
     }
+    // Id field for selecting existing entity
+    if ($field['name'] === CoreUtil::getIdFieldName($entityName)) {
+      $entityTitle = CoreUtil::getInfoItem($entityName, 'title');
+      $field['input_type'] = 'Existing';
+      $field['entity'] = $entityName;
+      $field['label'] = E::ts('Existing %1', [1 => $entityTitle]);
+      $field['input_attrs']['placeholder'] = E::ts('Select %1', [1 => $entityTitle]);
+    }
     // If this is an implicit join, get new field from fk entity
     if ($field['name'] !== $fieldName && $field['fk_entity']) {
       $params['where'] = [['name', '=', substr($fieldName, 1 + strrpos($fieldName, '.'))]];
index 44c29051e76d3a87c04b2481725e7b96797ca8e7..2b5335f4750194cb66540a9a44b085bfcdd56809 100644 (file)
@@ -38,7 +38,7 @@ class AutocompleteSubscriber implements EventSubscriberInterface {
     $apiRequest = $event->getApiRequest();
     if (is_object($apiRequest) && is_a($apiRequest, 'Civi\Api4\Generic\AutocompleteAction')) {
       $formName = $apiRequest->getFormName();
-      if (!$formName || !str_starts_with('afform:', $formName) || !strpos(':', $apiRequest->getFieldName() ?: '')) {
+      if (!str_starts_with((string) $formName, 'afform:') || !strpos((string) $apiRequest->getFieldName(), ':')) {
         return;
       }
       [$entityName, $fieldName] = explode(':', $apiRequest->getFieldName());
@@ -53,11 +53,12 @@ class AutocompleteSubscriber implements EventSubscriberInterface {
       }
       $formDataModel = new FormDataModel($afform['layout']);
       $entity = $formDataModel->getEntity($entityName);
-      $field = $entity['fields'][$fieldName] ?? NULL;
-      if ($field) {
-        $apiRequest->setCheckPermissions(empty($field['defn']['bypass_permission']));
-        $apiRequest->setSavedSearch($field['defn']['saved_search'] ?? NULL);
+      $defn = [];
+      if (!empty($entity['fields'][$fieldName]['defn'])) {
+        $defn = \CRM_Utils_JS::decode($entity['fields'][$fieldName]['defn']);
       }
+      $apiRequest->setCheckPermissions(empty($defn['skip_permissions']));
+      $apiRequest->setSavedSearch($defn['saved_search'] ?? NULL);
     }
   }
 
diff --git a/ext/afform/core/ang/af/fields/Existing.html b/ext/afform/core/ang/af/fields/Existing.html
new file mode 100644 (file)
index 0000000..10855d4
--- /dev/null
@@ -0,0 +1 @@
+<input class="form-control" id="{{:: fieldId }}" ng-model="getSetSelect" ng-model-options="{getterSetter: true}" crm-autocomplete="$ctrl.defn.entity" crm-autocomplete-params="{formName: 'afform:' + $ctrl.afFieldset.getFormName(), fieldName: $ctrl.afFieldset.modelName + ':' + $ctrl.fieldName}" placeholder="{{:: $ctrl.defn.input_attrs.placeholder }}" >