From cb5eef6b8195e60851bb827d0569a695ed9972b9 Mon Sep 17 00:00:00 2001
From: colemanw <coleman@civicrm.org>
Date: Tue, 26 Sep 2023 19:56:35 -0400
Subject: [PATCH] Afform - Quick add links for Autocomplete fields

---
 CRM/Core/Form.php                             |  3 +
 CRM/Core/Resources.php                        | 37 ++++++++++
 ang/afform/afformQuickAddIndividual.aff.html  | 18 +++++
 ang/afform/afformQuickAddIndividual.aff.php   | 14 ++++
 ang/crmUi.js                                  |  2 +
 .../afGuiEditor/elements/afGuiField-menu.html |  5 ++
 .../elements/afGuiField.component.js          | 13 ++++
 ext/afform/core/ang/af/fields/EntityRef.html  |  1 +
 js/Common.js                                  | 73 ++++++++++++++++---
 templates/CRM/common/l10n.js.tpl              |  1 +
 10 files changed, 157 insertions(+), 10 deletions(-)
 create mode 100644 ang/afform/afformQuickAddIndividual.aff.html
 create mode 100644 ang/afform/afformQuickAddIndividual.aff.php

diff --git a/CRM/Core/Form.php b/CRM/Core/Form.php
index 0cffa991c2f5..3a04c3bb23f5 100644
--- a/CRM/Core/Form.php
+++ b/CRM/Core/Form.php
@@ -2324,6 +2324,9 @@ public function addAutocomplete(string $name, string $label = '', array $props =
     $props['data-select-params'] = json_encode($props['select']);
     $props['data-api-params'] = json_encode($props['api']);
     $props['data-api-entity'] = $props['entity'];
+    if (!empty($props['select']['quickAdd'])) {
+      Civi::service('angularjs.loader')->addModules(['af']);
+    }
     CRM_Utils_Array::remove($props, 'select', 'api', 'entity');
     return $this->add('text', $name, $label, $props, $required);
   }
diff --git a/CRM/Core/Resources.php b/CRM/Core/Resources.php
index f1610b35f63c..9b2e1e1c2d9a 100644
--- a/CRM/Core/Resources.php
+++ b/CRM/Core/Resources.php
@@ -432,10 +432,47 @@ public static function renderL10nJs(GenericHookEvent $e) {
       'contactSearch' => json_encode(!empty($params['includeEmailInName']) ? ts('Search by name/email or id...') : ts('Search by name or id...')),
       'otherSearch' => json_encode(ts('Enter search term or id...')),
       'entityRef' => self::getEntityRefMetadata(),
+      'quickAdd' => self::getQuickAddForms($e->params['cid']),
     ];
     $e->content = CRM_Core_Smarty::singleton()->fetchWith('CRM/common/l10n.js.tpl', $params);
   }
 
+  /**
+   * Gets links to "Quick Add" forms, for use in Autocomplete widgets
+   *
+   * @param int $cid
+   * @return array
+   */
+  private static function getQuickAddForms(int $cid): array {
+    $forms = [];
+    try {
+      $contactTypes = CRM_Contact_BAO_ContactType::getAllContactTypes();
+      $routes = \Civi\Api4\Route::get(FALSE)
+        ->addSelect('path', 'title', 'access_arguments')
+        ->addWhere('path', 'LIKE', 'civicrm/quick-add/%')
+        ->execute();
+      foreach ($routes as $route) {
+        // Ensure user has permission to use the form
+        if (!empty($route['access_arguments'][0]) && !CRM_Core_Permission::check($route['access_arguments'][0], $cid)) {
+          continue;
+        }
+        // Ensure API entity exists
+        [, , $entityType] = array_pad(explode('/', $route['path']), 3, '*');
+        if (\Civi\Api4\Utils\CoreUtil::entityExists($entityType)) {
+          $forms[] = [
+            'entity' => $entityType,
+            'path' => $route['path'],
+            'title' => $route['title'],
+            'icon' => \Civi\Api4\Utils\CoreUtil::getInfoItem($entityType, 'icon'),
+          ];
+        }
+      }
+    }
+    catch (CRM_Core_Exception $e) {
+    }
+    return $forms;
+  }
+
   /**
    * @return bool
    *   is this page request an ajax snippet?
diff --git a/ang/afform/afformQuickAddIndividual.aff.html b/ang/afform/afformQuickAddIndividual.aff.html
new file mode 100644
index 000000000000..93e8e7d84e52
--- /dev/null
+++ b/ang/afform/afformQuickAddIndividual.aff.html
@@ -0,0 +1,18 @@
+<af-form ctrl="afform">
+  <af-entity data="{contact_type: 'Individual'}" type="Individual" name="Individual1" actions="{create: true, update: false}" security="RBAC" />
+  <fieldset af-fieldset="Individual1" class="af-container">
+    <div class="af-container">
+      <div class="af-container af-layout-inline">
+        <af-field name="first_name" />
+        <af-field name="middle_name" />
+        <af-field name="last_name" />
+      </div>
+      <div af-join="Email" data="{is_primary: true}">
+        <div class="af-container af-layout-inline">
+          <af-field name="email" />
+        </div>
+      </div>
+    </div>
+  </fieldset>
+  <button class="af-button btn btn-primary" crm-icon="fa-check" ng-click="afform.submit()">Submit</button>
+</af-form>
diff --git a/ang/afform/afformQuickAddIndividual.aff.php b/ang/afform/afformQuickAddIndividual.aff.php
new file mode 100644
index 000000000000..4b7ca783f08b
--- /dev/null
+++ b/ang/afform/afformQuickAddIndividual.aff.php
@@ -0,0 +1,14 @@
+<?php
+
+return [
+  'type' => 'form',
+  'title' => ts('New Individual'),
+  'icon' => 'fa-list-alt',
+  'server_route' => 'civicrm/quick-add/Individual',
+  'permission' => [
+    'add contacts'
+  ],
+  'permission_operator' => 'AND',
+  'submit_enabled' => TRUE,
+  'create_submission' => FALSE,
+];
diff --git a/ang/crmUi.js b/ang/crmUi.js
index 718bf22b0a71..811fd8101ba0 100644
--- a/ang/crmUi.js
+++ b/ang/crmUi.js
@@ -729,6 +729,7 @@
           crmAutocompleteParams: '<',
           multi: '<',
           autoOpen: '<',
+          quickAdd: '<',
           staticOptions: '<'
         },
         link: function(scope, element, attr, ctrl) {
@@ -791,6 +792,7 @@
                 // Only auto-open if there are no static options
                 minimumInputLength: ctrl.autoOpen && _.isEmpty(ctrl.staticOptions) ? 0 : 1,
                 static: ctrl.staticOptions || [],
+                quickAdd: ctrl.quickAdd,
               });
             });
           };
diff --git a/ext/afform/admin/ang/afGuiEditor/elements/afGuiField-menu.html b/ext/afform/admin/ang/afGuiEditor/elements/afGuiField-menu.html
index 9c49cbcccdc7..38600b0901ca 100644
--- a/ext/afform/admin/ang/afGuiEditor/elements/afGuiField-menu.html
+++ b/ext/afform/admin/ang/afGuiEditor/elements/afGuiField-menu.html
@@ -38,6 +38,11 @@
     <input crm-ui-select="{data: $ctrl.editor.securityModes}" ng-model="getSet('security')" ng-model-options="{getterSetter: true}" class="form-control">
   </div>
 </li>
+<li ng-if="$ctrl.fieldDefn.input_type === 'EntityRef'" title="{{:: ts('Allow a new entity to be created via quick-add popup') }}">
+  <div href ng-click="$event.stopPropagation()" class="af-gui-field-select-in-dropdown">
+    <input crm-ui-select="{data: $ctrl.quickAddLinks, multiple: true, placeholder: ts('Quick Add')}" ng-model="getSet('input_attrs.quickAdd')" ng-model-options="{getterSetter: true}" class="form-control">
+  </div>
+</li>
 <li ng-if="$ctrl.fieldDefn.input_type === 'EntityRef'">
   <a href ng-click="toggleAttr('input_attrs.autoOpen'); $event.stopPropagation(); $event.target.blur();" title="{{:: ts('Show autocomplete results without typing') }}">
     <i class="crm-i fa-{{ getProp('input_attrs.autoOpen') ? 'check-' : '' }}square-o"></i>
diff --git a/ext/afform/admin/ang/afGuiEditor/elements/afGuiField.component.js b/ext/afform/admin/ang/afGuiEditor/elements/afGuiField.component.js
index 1b9f03495dbe..9e94963cf5aa 100644
--- a/ext/afform/admin/ang/afGuiEditor/elements/afGuiField.component.js
+++ b/ext/afform/admin/ang/afGuiEditor/elements/afGuiField.component.js
@@ -47,6 +47,19 @@
             inputTypes.push(type);
           }
         });
+        // Quick-add links for autocompletes
+        this.quickAddLinks = [];
+        let allowedEntity = (ctrl.getFkEntity() || {}).entity;
+        let allowedEntities = (allowedEntity === 'Contact') ? ['Individual', 'Household', 'Organization'] : [allowedEntity];
+        (CRM.config.quickAdd || []).forEach((link) => {
+          if (allowedEntities.includes(link.entity)) {
+            this.quickAddLinks.push({
+              id: link.path,
+              icon: link.icon,
+              text: link.title,
+            });
+          }
+        });
         this.searchOperators = CRM.afAdmin.search_operators;
         // If field has limited operators, set appropriately
         if (ctrl.fieldDefn.operators && ctrl.fieldDefn.operators.length) {
diff --git a/ext/afform/core/ang/af/fields/EntityRef.html b/ext/afform/core/ang/af/fields/EntityRef.html
index 38447c8eaf55..cd415943b978 100644
--- a/ext/afform/core/ang/af/fields/EntityRef.html
+++ b/ext/afform/core/ang/af/fields/EntityRef.html
@@ -8,5 +8,6 @@
        crm-autocomplete-params="{formName: 'afform:' + $ctrl.afFieldset.getFormName(), fieldName: $ctrl.afFieldset.getName() + ':' + $ctrl.fieldName}"
        multi="$ctrl.defn.input_attrs.multiple"
        auto-open="$ctrl.defn.input_attrs.autoOpen"
+       quick-add="$ctrl.defn.input_attrs.quickAdd"
        placeholder="{{:: $ctrl.defn.input_attrs.placeholder }}"
        ng-change="$ctrl.onSelectEntity()" >
diff --git a/js/Common.js b/js/Common.js
index f1bb53494466..1227255f0842 100644
--- a/js/Common.js
+++ b/js/Common.js
@@ -538,7 +538,23 @@ if (!CRM.vars) CRM.vars = {};
     });
   }
 
-  function getStaticOptionMarkup(staticItems) {
+  function renderQuickAddMarkup(quickAddLinks) {
+    if (!quickAddLinks || !quickAddLinks.length) {
+      return '';
+    }
+    let markup = '<div class="crm-entityref-links crm-entityref-quick-add">';
+    CRM.config.quickAdd.forEach((link) => {
+      if (quickAddLinks.includes(link.path)) {
+        markup += ' <a class="crm-hover-button" href="' + _.escape(CRM.url(link.path)) + '">' +
+          '<i class="crm-i ' + _.escape(link.icon) + '" aria-hidden="true"></i> ' +
+          _.escape(link.title) + '</a>';
+      }
+    });
+    markup += '</div>';
+    return markup;
+  }
+
+  function renderStaticOptionMarkup(staticItems) {
     if (!staticItems.length) {
       return '';
     }
@@ -559,9 +575,11 @@ if (!CRM.vars) CRM.vars = {};
     }
     select2Options = select2Options || {};
     return $(this).each(function() {
-      var $el = $(this).off('.crmEntity'),
-        staticItems = getStaticOptions(select2Options.static),
-        multiple = !!select2Options.multiple;
+      const $el = $(this).off('.crmEntity');
+      let staticItems = getStaticOptions(select2Options.static),
+        quickAddLinks = select2Options.quickAdd,
+        multiple = !!select2Options.multiple,
+        key = apiParams.key || 'id';
 
       $el.crmSelect2(_.extend({
         ajax: {
@@ -604,18 +622,25 @@ if (!CRM.vars) CRM.vars = {};
           }
         },
         formatInputTooShort: function() {
-          var txt = _.escape($.fn.select2.defaults.formatInputTooShort.call(this));
-          txt += getStaticOptionMarkup(staticItems);
-          return txt;
+          let html = _.escape($.fn.select2.defaults.formatInputTooShort.call(this));
+          html += renderStaticOptionMarkup(staticItems);
+          html += renderQuickAddMarkup(quickAddLinks);
+          return html;
+        },
+        formatNoMatches: function() {
+          let html = _.escape($.fn.select2.defaults.formatNoMatches);
+          html += renderQuickAddMarkup(quickAddLinks);
+          return html;
         }
       }, select2Options));
 
-      $el.on('select2-open.crmEntity', function() {
+      $el.on('select2-open.crmEntity', () => {
         var $el = $(this);
         $('#select2-drop')
           .off('.crmEntity')
-          .on('click.crmEntity', '.crm-entityref-links-static a', function(e) {
-            var id = $(this).attr('href').substr(1),
+          // Add static item to selection when clicking static links
+          .on('click.crmEntity', '.crm-entityref-links-static a', () => {
+            let id = $(this).attr('href').substring(1),
               item = _.findWhere(staticItems, {id: id});
             $el.select2('close');
             if (multiple) {
@@ -628,6 +653,34 @@ if (!CRM.vars) CRM.vars = {};
               $el.select2('data', item, true);
             }
             return false;
+          })
+          // Pop-up Afform when clicking quick-add links
+          .on('click.crmEntity', '.crm-entityref-quick-add a', () => {
+            let url = $(this).attr('href');
+            $el.select2('close');
+            CRM.loadForm(url).on('crmFormSuccess', (e, data) => {
+              // Quick-add Afform has been submitted, parse submission data for id of created entity
+              const response = data.submissionResponse && data.submissionResponse[0];
+              let createdId;
+              if (typeof response === 'object') {
+                // Loop through entities created by the afform (there should be only one)
+                Object.keys(response).forEach((entity) => {
+                  if (Array.isArray(response[entity]) && response[entity][0] && response[entity][0][key]) {
+                    createdId = response[entity][0][key];
+                  }
+                });
+              }
+              // Update field value with new id and the widget will automatically fetch the label
+              if (createdId) {
+                if (multiple && $el.val()) {
+                  // Select2 v3 uses a string instead of array for multiple values
+                  $el.val($el.val() + ',' + createdId).change();
+                } else {
+                  $el.val('' + createdId).change();
+                }
+              }
+            });
+            return false;
           });
       });
     });
diff --git a/templates/CRM/common/l10n.js.tpl b/templates/CRM/common/l10n.js.tpl
index 427ad77d5f26..5e8f34e45f6a 100644
--- a/templates/CRM/common/l10n.js.tpl
+++ b/templates/CRM/common/l10n.js.tpl
@@ -24,6 +24,7 @@
   CRM.config.ajaxPopupsEnabled = {$ajaxPopupsEnabled|@json_encode};
   CRM.config.allowAlertAutodismissal = {$allowAlertAutodismissal|@json_encode};
   CRM.config.resourceCacheCode = {$resourceCacheCode|@json_encode};
+  CRM.config.quickAdd = {$quickAdd|@json_encode};
 
   // Merge entityRef settings
   CRM.config.entityRef = $.extend({ldelim}{rdelim}, {$entityRef|@json_encode}, CRM.config.entityRef || {ldelim}{rdelim});