Automatically detect requirements in *.aff.html
authorTim Otten <totten@civicrm.org>
Fri, 18 Oct 2019 22:05:22 +0000 (15:05 -0700)
committerCiviCRM <info@civicrm.org>
Wed, 16 Sep 2020 02:13:19 +0000 (19:13 -0700)
PART A: EXPORT LIST

Suppose you create a form `downstreamForm.aff.html`.

The metadata for `downstreamForm` will indicate that it exports an element `<downstream-form/>`:

```php
$angularModules['downstreamForm']['exports']['el'] = ['downstream-form'];
```

PART B: AUTO-REQUIRES

Suppose that:

1. he form `downstreamForm.aff.html` uses element `<upstream-el/>`.
2. The `$angularModules['upsteamMod']['exports']['el']` defines an exported element `upstream-el`.

Then the `downstreamForm` module will automatically requires the `upstreamMod`, as in

```php
$angularModules['downstreamForm']['requires'][] = 'upstreamMod';
```

ext/afform/core/CRM/Afform/AfformScanner.php
ext/afform/core/Civi/Afform/Symbols.php [new file with mode: 0644]
ext/afform/core/afform.php

index 589602369d51787ff4451ffbe8247c089d636bf8..fe2c91f6cc914863d4d7a3fcb80b731709b95046 100644 (file)
@@ -134,7 +134,7 @@ class CRM_Afform_AfformScanner {
 
     $defaults = [
       'name' => $name,
-      'requires' => explode(',', self::DEFAULT_REQUIRES),
+      'requires' => [],
       'title' => '',
       'description' => '',
       'is_public' => FALSE,
diff --git a/ext/afform/core/Civi/Afform/Symbols.php b/ext/afform/core/Civi/Afform/Symbols.php
new file mode 100644 (file)
index 0000000..b933afb
--- /dev/null
@@ -0,0 +1,68 @@
+<?php
+namespace Civi\Afform;
+
+class Symbols {
+
+  /**
+   * @var array
+   *   Array(string $element => int $count).
+   */
+  public $elements = [];
+
+  /**
+   * @var array
+   *   Array(string $class => int $count).
+   */
+  public $classes = [];
+
+  /**
+   * @var array
+   *   Array(string $attr => int $count).
+   */
+  public $attributes = [];
+
+  /**
+   * @param string $html
+   * @return static
+   */
+  public static function scan($html) {
+    $symbols = new static();
+    $doc = new \DOMDocumentWrapper($html, 'text/html');
+    $symbols->scanNode($doc->root);
+    return $symbols;
+  }
+
+  protected function scanNode(\DOMNode $node) {
+    if ($node instanceof \DOMElement) {
+
+      self::increment($this->elements, $node->tagName);
+
+      foreach ($node->childNodes as $childNode) {
+        $this->scanNode($childNode);
+      }
+
+      foreach ($node->attributes as $attribute) {
+        $this->scanNode($attribute);
+      }
+    }
+
+    elseif ($node instanceof \DOMAttr) {
+      self::increment($this->attributes, $node->nodeName);
+
+      if ($node->nodeName === 'class') {
+        $classes = explode(' ', $node->nodeValue);
+        foreach ($classes as $class) {
+          self::increment($this->classes, $class);
+        }
+      }
+    }
+  }
+
+  private static function increment(&$arr, $key) {
+    if (!isset($arr[$key])) {
+      $arr[$key] = 0;
+    }
+    $arr[$key]++;
+  }
+
+}
index 28caaf8952e810fad97237d63052614fe322d328..605a186f184350984be9442d79da57cea3259fb0 100644 (file)
@@ -37,6 +37,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',
     []
@@ -50,8 +51,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);
 }
 
 /**
@@ -155,6 +163,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) {
@@ -164,6 +173,11 @@ function afform_civicrm_angularModules(&$angularModules) {
       'js' => ['assetBuilder://afform.js?name=' . urlencode($name)],
       'requires' => $meta['requires'],
       'basePages' => [],
+      'exports' => [
+        // Each afform is an attribute and an element.
+        'el' => [_afform_angular_module_name($name, 'dash')],
+        'attr' => [_afform_angular_module_name($name, 'dash')],
+      ],
     ];
 
     // FIXME: The HTML layout template is embedded in the JS asset.
@@ -173,6 +187,108 @@ 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 = [];
+  foreach (['attr', 'el'] as $exportType) {
+    $revMap[$exportType] = [];
+    foreach (array_keys($angularModules) as $module) {
+      if (isset($angularModules[$module]['exports'][$exportType])) {
+        foreach ($angularModules[$module]['exports'][$exportType] as $exportItem) {
+          $revMap[$exportType][$exportItem] = $module;
+        }
+      }
+    }
+  }
+  return $revMap;
+}
+
+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()