Api improvements and test fix
[civicrm-core.git] / ext / afform / core / afform.php
index 9ea9da18f69c6def3763fb33d29d968c60512a47..273ed37c83b8b2a09ff272818c9607d5bd470a34 100644 (file)
@@ -2,10 +2,7 @@
 
 require_once 'afform.civix.php';
 use CRM_Afform_ExtensionUtil as E;
-
-function _afform_fields() {
-  return ['name', 'title', 'description', 'requires', 'layout', 'server_route', 'client_route', 'is_public'];
-}
+use Civi\Api4\Action\Afform\Submit;
 
 /**
  * Filter the content of $params to only have supported afform fields.
@@ -15,15 +12,16 @@ function _afform_fields() {
  */
 function _afform_fields_filter($params) {
   $result = [];
-  foreach (_afform_fields() as $field) {
-    if (isset($params[$field])) {
-      $result[$field] = $params[$field];
+  $fields = \Civi\Api4\Afform::getfields()->setCheckPermissions(FALSE)->execute()->indexBy('name');
+  foreach ($fields as $fieldName => $field) {
+    if (isset($params[$fieldName])) {
+      $result[$fieldName] = $params[$fieldName];
     }
 
-    if (isset($result[$field])) {
-      switch ($field) {
+    if (isset($result[$fieldName])) {
+      switch ($fieldName) {
         case 'is_public':
-          $result[$field] = CRM_Utils_String::strtobool($result[$field]);
+          $result[$fieldName] = CRM_Utils_String::strtobool($result[$fieldName]);
           break;
 
       }
@@ -36,6 +34,7 @@ function _afform_fields_filter($params) {
  * @param \Symfony\Component\DependencyInjection\ContainerBuilder $container
  */
 function afform_civicrm_container($container) {
+  $container->addResource(new \Symfony\Component\Config\Resource\FileResource(__FILE__));
   $container->setDefinition('afform_scanner', new \Symfony\Component\DependencyInjection\Definition(
     'CRM_Afform_AfformScanner',
     []
@@ -49,6 +48,15 @@ function afform_civicrm_container($container) {
  */
 function afform_civicrm_config(&$config) {
   _afform_civix_civicrm_config($config);
+
+  if (isset(Civi::$statics[__FUNCTION__])) {
+    return;
+  }
+  Civi::$statics[__FUNCTION__] = 1;
+
+  // Civi::dispatcher()->addListener(Submit::EVENT_NAME, [Submit::class, 'processContacts'], -500);
+  Civi::dispatcher()->addListener(Submit::EVENT_NAME, [Submit::class, 'processGenericEntity'], -1000);
+  Civi::dispatcher()->addListener('hook_civicrm_angularModules', '_afform_civicrm_angularModules_autoReq', -1000);
 }
 
 /**
@@ -152,6 +160,7 @@ function afform_civicrm_caseTypes(&$caseTypes) {
 function afform_civicrm_angularModules(&$angularModules) {
   _afform_civix_civicrm_angularModules($angularModules);
 
+  /** @var CRM_Afform_AfformScanner $scanner */
   $scanner = Civi::service('afform_scanner');
   $names = array_keys($scanner->findFilePaths());
   foreach ($names as $name) {
@@ -161,6 +170,9 @@ function afform_civicrm_angularModules(&$angularModules) {
       'js' => ['assetBuilder://afform.js?name=' . urlencode($name)],
       'requires' => $meta['requires'],
       'basePages' => [],
+      'exports' => [
+        _afform_angular_module_name($name, 'dash') => 'AE',
+      ],
     ];
 
     // FIXME: The HTML layout template is embedded in the JS asset.
@@ -170,6 +182,119 @@ function afform_civicrm_angularModules(&$angularModules) {
   }
 }
 
+/**
+ * Scan the list of Angular modules and inject automatic-requirements.
+ *
+ * TLDR: if an afform uses element "<other-el/>", and if another module defines
+ * `$angularModules['otherMod']['exports']['el'][0] === 'other-el'`, then
+ * the 'otherMod' is automatically required.
+ *
+ * @param \Civi\Core\Event\GenericHookEvent $e
+ * @see CRM_Utils_Hook::angularModules()
+ */
+function _afform_civicrm_angularModules_autoReq($e) {
+  /** @var CRM_Afform_AfformScanner $scanner */
+  $scanner = Civi::service('afform_scanner');
+  $moduleEnvId = md5(\CRM_Core_Config_Runtime::getId() . implode(',', array_keys($e->angularModules)));
+  $depCache = CRM_Utils_Cache::create([
+    'name' => 'afdep_' . substr($moduleEnvId, 0, 32 - 6),
+    'type' => ['*memory*', 'SqlGroup', 'ArrayCache'],
+    'withArray' => 'fast',
+    'prefetch' => TRUE,
+  ]);
+  $depCacheTtl = 2 * 60 * 60;
+
+  $revMap = _afform_reverse_deps($e->angularModules);
+
+  $formNames = array_keys($scanner->findFilePaths());
+  foreach ($formNames as $formName) {
+    $angModule = _afform_angular_module_name($formName, 'camel');
+    $cacheLine = $depCache->get($formName, NULL);
+
+    $jFile = $scanner->findFilePath($formName, 'aff.json');
+    $hFile = $scanner->findFilePath($formName, 'aff.html');
+
+    $jStat = stat($jFile);
+    $hStat = stat($hFile);
+
+    if ($cacheLine === NULL) {
+      $needsUpdate = TRUE;
+    }
+    elseif ($jStat !== FALSE && $jStat['size'] !== $cacheLine['js']) {
+      $needsUpdate = TRUE;
+    }
+    elseif ($jStat !== FALSE && $jStat['mtime'] > $cacheLine['jm']) {
+      $needsUpdate = TRUE;
+    }
+    elseif ($hStat !== FALSE && $hStat['size'] !== $cacheLine['hs']) {
+      $needsUpdate = TRUE;
+    }
+    elseif ($hStat !== FALSE && $hStat['mtime'] > $cacheLine['hm']) {
+      $needsUpdate = TRUE;
+    }
+    else {
+      $needsUpdate = FALSE;
+    }
+
+    if ($needsUpdate) {
+      $cacheLine = [
+        'js' => $jStat['size'] ?? NULL,
+        'jm' => $jStat['mtime'] ?? NULL,
+        'hs' => $hStat['size'] ?? NULL,
+        'hm' => $hStat['mtime'] ?? NULL,
+        'r' => array_values(array_unique(array_merge(
+          [CRM_Afform_AfformScanner::DEFAULT_REQUIRES],
+          $e->angularModules[$angModule]['requires'] ?? [],
+          _afform_reverse_deps_find($formName, file_get_contents($hFile), $revMap)
+        ))),
+      ];
+      // print_r(['cache update:' . $formName => $cacheLine]);
+      $depCache->set($formName, $cacheLine, $depCacheTtl);
+    }
+
+    $e->angularModules[$angModule]['requires'] = $cacheLine['r'];
+  }
+}
+
+/**
+ * @param $angularModules
+ * @return array
+ *   'attr': array(string $attrName => string $angModuleName)
+ *   'el': array(string $elementName => string $angModuleName)
+ */
+function _afform_reverse_deps($angularModules) {
+  $revMap = ['attr' => [], 'el' => []];
+  foreach (array_keys($angularModules) as $module) {
+    if (!isset($angularModules[$module]['exports'])) {
+      continue;
+    }
+    foreach ($angularModules[$module]['exports'] as $symbolName => $symbolTypes) {
+      if (strpos($symbolTypes, 'A') !== FALSE) {
+        $revMap['attr'][$symbolName] = $module;
+      }
+      if (strpos($symbolTypes, 'E') !== FALSE) {
+        $revMap['el'][$symbolName] = $module;
+      }
+    }
+  }
+  return $revMap;
+}
+
+/**
+ * @param string $formName
+ * @param string $html
+ * @param array $revMap
+ *   The reverse-dependencies map from _afform_reverse_deps().
+ * @return array
+ * @see _afform_reverse_deps()
+ */
+function _afform_reverse_deps_find($formName, $html, $revMap) {
+  $symbols = \Civi\Afform\Symbols::scan($html);
+  $elems = array_intersect_key($revMap['el'], $symbols->elements);
+  $attrs = array_intersect_key($revMap['attr'], $symbols->attributes);
+  return array_values(array_unique(array_merge($elems, $attrs)));
+}
+
 /**
  * @param \Civi\Angular\Manager $angular
  * @see CRM_Utils_Hook::alterAngular()
@@ -182,30 +307,45 @@ function afform_civicrm_alterAngular($angular) {
       foreach (pq('af-field', $doc) as $afField) {
         /** @var DOMElement $afField */
         $fieldName = $afField->getAttribute('name');
-        $entityName = pq($afField)->parent('af-fieldset[model]')->attr('model');
+        $entityName = pq($afField)->parents('[af-fieldset]')->attr('af-fieldset');
         if (!preg_match(';^[a-zA-Z0-9\_\-\. ]+$;', $entityName)) {
           throw new \CRM_Core_Exception("Cannot process $path: malformed entity name ($entityName)");
         }
         $entityType = $entities[$entityName]['type'];
         $getFields = civicrm_api4($entityType, 'getFields', [
+          'action' => 'create',
           'where' => [['name', '=', $fieldName]],
           'select' => ['title', 'input_type', 'input_attrs', 'options'],
           'loadOptions' => TRUE,
         ]);
         // Merge field definition data with whatever's already in the markup
-        foreach ($getFields as $field) {
+        $deep = ['input_attrs'];
+        foreach ($getFields as $fieldInfo) {
           $existingFieldDefn = trim(pq($afField)->attr('defn') ?: '');
           if ($existingFieldDefn && $existingFieldDefn[0] != '{') {
             // If it's not an object, don't mess with it.
             continue;
           }
-          foreach ($field as &$prop) {
-            $prop = json_encode($prop, JSON_UNESCAPED_SLASHES);
+          // TODO: Teach the api to return options in this format
+          if (!empty($fieldInfo['options'])) {
+            $fieldInfo['options'] = CRM_Utils_Array::makeNonAssociative($fieldInfo['options'], 'key', 'label');
           }
-          if ($existingFieldDefn) {
-            $field = array_merge($field, CRM_Utils_JS::getRawProps($existingFieldDefn));
+          // Default placeholder for select inputs
+          if ($fieldInfo['input_type'] === 'Select') {
+            $fieldInfo['input_attrs'] = ($fieldInfo['input_attrs'] ?? []) + ['placeholder' => ts('Select')];
           }
-          pq($afField)->attr('defn', CRM_Utils_JS::writeObject($field));
+
+          $fieldDefn = $existingFieldDefn ? CRM_Utils_JS::getRawProps($existingFieldDefn) : [];
+          foreach ($fieldInfo as $name => $prop) {
+            // Merge array props 1 level deep
+            if (in_array($name, $deep) && !empty($fieldDefn[$name])) {
+              $fieldDefn[$name] = CRM_Utils_JS::writeObject(CRM_Utils_JS::getRawProps($fieldDefn[$name]) + array_map(['CRM_Utils_JS', 'encode'], $prop));
+            }
+            elseif (!isset($fieldDefn[$name])) {
+              $fieldDefn[$name] = CRM_Utils_JS::encode($prop);
+            }
+          }
+          pq($afField)->attr('defn', htmlspecialchars(CRM_Utils_JS::writeObject($fieldDefn)));
         }
       }
     });
@@ -214,7 +354,7 @@ function afform_civicrm_alterAngular($angular) {
 
 function _afform_getMetadata(phpQueryObject $doc) {
   $entities = [];
-  foreach ($doc->find('af-model') as $afmModelProp) {
+  foreach ($doc->find('af-entity') as $afmModelProp) {
     $entities[$afmModelProp->getAttribute('name')] = [
       'type' => $afmModelProp->getAttribute('type'),
     ];
@@ -242,6 +382,13 @@ function afform_civicrm_entityTypes(&$entityTypes) {
   _afform_civix_civicrm_entityTypes($entityTypes);
 }
 
+/**
+ * Implements hook_civicrm_themes().
+ */
+function afform_civicrm_themes(&$themes) {
+  _afform_civix_civicrm_themes($themes);
+}
+
 // --- Functions below this ship commented out. Uncomment as required. ---
 
 /**
@@ -257,29 +404,37 @@ function afform_civicrm_buildAsset($asset, $params, &$mimeType, &$content) {
   }
 
   $name = $params['name'];
-  // Hmm?? $scanner = new CRM_Afform_AfformScanner();
-  // Hmm?? afform_scanner
+  /** @var \CRM_Afform_AfformScanner $scanner */
   $scanner = Civi::service('afform_scanner');
   $meta = $scanner->getMeta($name);
-  // Hmm?? $scanner = new CRM_Afform_AfformScanner();
-
-  $fileName = '~afform/' . _afform_angular_module_name($name, 'camel');
-  $htmls = [
-    $fileName => file_get_contents($scanner->findFilePath($name, 'aff.html')),
-  ];
-  $htmls = \Civi\Angular\ChangeSet::applyResourceFilters(Civi::service('angular')->getChangeSets(), 'partials', $htmls);
 
   $smarty = CRM_Core_Smarty::singleton();
   $smarty->assign('afform', [
     'camel' => _afform_angular_module_name($name, 'camel'),
     'meta' => $meta,
     'metaJson' => json_encode($meta),
-    'layout' => $htmls[$fileName],
+    'layout' => _afform_html_filter($name, $scanner->getLayout($name)),
   ]);
   $mimeType = 'text/javascript';
   $content = $smarty->fetch('afform/AfformAngularModule.tpl');
 }
 
+/**
+ * Apply any filters to an HTML partial.
+ *
+ * @param string $formName
+ * @param string $html
+ *   Original HTML.
+ * @return string
+ *   Modified HTML.
+ */
+function _afform_html_filter($formName, $html) {
+  $fileName = '~afform/' . _afform_angular_module_name($formName, 'camel');
+  $htmls = [$fileName => $html];
+  $htmls = \Civi\Angular\ChangeSet::applyResourceFilters(Civi::service('angular')->getChangeSets(), 'partials', $htmls);
+  return $htmls[$fileName];
+}
+
 /**
  * Implements hook_civicrm_alterMenu().
  */
@@ -304,6 +459,18 @@ function afform_civicrm_alterMenu(&$items) {
   }
 }
 
+/**
+ * Clear any local/in-memory caches based on afform data.
+ */
+function _afform_clear() {
+  $container = \Civi::container();
+  $container->get('afform_scanner')->clear();
+
+  // Civi\Angular\Manager doesn't currently have a way to clear its in-memory
+  // data, so we just reset the whole object.
+  $container->set('angular', NULL);
+}
+
 /**
  * @param string $fileBaseName
  *   Ex: foo-bar