Refactor af-block directive into af-repeat and af-join
authorColeman Watts <coleman@civicrm.org>
Fri, 27 Dec 2019 19:55:49 +0000 (14:55 -0500)
committerCiviCRM <info@civicrm.org>
Wed, 16 Sep 2020 02:13:20 +0000 (19:13 -0700)
This allows mix-n-match between blocks and repeatable things, allowing
entity fieldsets to be repeated, as well as arbitrary non-repeatable blocks.

40 files changed:
ext/afform/core/CRM/Afform/ArrayHtml.php
ext/afform/core/Civi/Afform/FormDataModel.php
ext/afform/core/Civi/Api4/Action/Afform/AbstractProcessor.php
ext/afform/core/Civi/Api4/Action/Afform/Get.php
ext/afform/core/Civi/Api4/Action/Afform/Prefill.php
ext/afform/core/Civi/Api4/Action/Afform/Submit.php
ext/afform/core/Civi/Api4/Afform.php
ext/afform/core/afform.php
ext/afform/core/ang/af.ang.php
ext/afform/core/ang/af/Block.js [deleted file]
ext/afform/core/ang/af/Field.js
ext/afform/core/ang/af/Fieldset.js
ext/afform/core/ang/af/Form.js
ext/afform/core/ang/af/Join.js [new file with mode: 0644]
ext/afform/core/ang/af/Repeat.js [new file with mode: 0644]
ext/afform/core/ang/af/afBlock.html [deleted file]
ext/afform/core/ang/af/afRepeat.html [new file with mode: 0644]
ext/afform/core/ang/af/fields/CheckBox.html
ext/afform/core/ang/af/fields/Date.html
ext/afform/core/ang/af/fields/Number.html
ext/afform/core/ang/af/fields/Radio.html
ext/afform/core/ang/af/fields/RichTextEditor.html
ext/afform/core/ang/af/fields/Select.html
ext/afform/core/ang/af/fields/Text.html
ext/afform/core/ang/af/fields/TextArea.html
ext/afform/core/ang/afCore.css
ext/afform/core/ang/blockEmailDefault.aff.json
ext/afform/core/ang/blockIMDefault.aff.json
ext/afform/core/ang/blockNameIndividual.aff.html [new file with mode: 0644]
ext/afform/core/ang/blockNameIndividual.aff.json [new file with mode: 0644]
ext/afform/core/ang/blockPhoneDefault.aff.json
ext/afform/core/ang/blockWebsiteDefault.aff.json
ext/afform/core/tests/phpunit/Civi/Afform/FormDataModelTest.php
ext/afform/gui/afform_gui.php
ext/afform/gui/ang/afGuiEditor.js
ext/afform/gui/ang/afGuiEditor/canvas.html
ext/afform/gui/ang/afGuiEditor/container-menu.html
ext/afform/gui/ang/afGuiEditor/container.html
ext/afform/gui/ang/afGuiEditor/entity.html
ext/afform/mock/tests/phpunit/api/v4/AfformUsageTest.php

index 803442906877f6852b265f7fbb6ee0d4220688c9..84a4ae15d4208d006f63cfc4fe1d99bde8b9db1a 100644 (file)
@@ -379,7 +379,7 @@ class CRM_Afform_ArrayHtml {
    * @return bool
    */
   public function isNodeEditable(array $item) {
-    if ($item['#tag'] === 'af-field' || $item['#tag'] === 'af-form' || isset($item['af-fieldset']) || isset($item['af-block'])) {
+    if ($item['#tag'] === 'af-field' || $item['#tag'] === 'af-form' || isset($item['af-fieldset']) || isset($item['af-join'])) {
       return TRUE;
     }
     $editableClasses = ['af-container', 'af-text', 'af-button'];
index 3a9a811c89d3cf156bd534f7eaf2add96f469d04..b256584aa0eee9e679c00265f705a66549fb8a21 100644 (file)
@@ -2,12 +2,13 @@
 
 namespace Civi\Afform;
 
+use Civi\Api4\Afform;
+
 /**
  * Class FormDataModel
  * @package Civi\Afform
  *
- * The FormDataModel examines a form and determines the list of entities/fields
- * which are used by the form.
+ * Examines a form and determines the entities, fields & joins in use.
  */
 class FormDataModel {
 
@@ -18,50 +19,58 @@ class FormDataModel {
   protected $entities;
 
   /**
-   * Gets entity metadata and all blocks & fields from the form
-   *
-   * @param array $layout
-   *   The root element of the layout, in shallow/deep format.
-   * @return static
-   *   Parsed summary of the entities used in a given form.
+   * @var array
    */
-  public static function create($layout) {
+  protected $blocks = [];
+
+  public function __construct($layout) {
     $root = AHQ::makeRoot($layout);
-    $entities = array_column(AHQ::getTags($root, 'af-entity'), NULL, 'name');
-    foreach (array_keys($entities) as $entity) {
-      $entities[$entity]['fields'] = $entities[$entity]['blocks'] = [];
+    $this->entities = array_column(AHQ::getTags($root, 'af-entity'), NULL, 'name');
+    foreach (Afform::get()->setCheckPermissions(FALSE)->addSelect('name')->execute() as $block) {
+      $this->blocks[_afform_angular_module_name($block['name'], 'dash')] = $block;
     }
-    self::parseFields($layout, $entities);
-
-    $self = new static();
-    $self->entities = $entities;
-    return $self;
+    foreach (array_keys($this->entities) as $entity) {
+      $this->entities[$entity]['fields'] = $this->entities[$entity]['joins'] = [];
+    }
+    $this->parseFields($layout);
   }
 
   /**
    * @param array $nodes
-   * @param array $entities
-   *   A list of entities, keyed by name.
-   *     This will be updated to populate 'fields' and 'blocks'.
-   *     Ex: $entities['spouse']['type'] = 'Contact';
    * @param string $entity
+   * @param string $join
    */
-  protected static function parseFields($nodes, &$entities, $entity = NULL) {
+  protected function parseFields($nodes, $entity = NULL, $join = NULL) {
     foreach ($nodes as $node) {
       if (!is_array($node) || !isset($node['#tag'])) {
-        //nothing
+        continue;
       }
-      elseif (!empty($node['af-fieldset'])) {
-        self::parseFields($node['#children'], $entities, $node['af-fieldset']);
+      elseif (!empty($node['af-fieldset']) && !empty($node['#children'])) {
+        $this->parseFields($node['#children'], $node['af-fieldset'], $join);
       }
       elseif ($entity && $node['#tag'] === 'af-field') {
-        $entities[$entity]['fields'][$node['name']] = AHQ::getProps($node);
+        if ($join) {
+          $this->entities[$entity]['joins'][$join]['fields'][$node['name']] = AHQ::getProps($node);
+        }
+        else {
+          $this->entities[$entity]['fields'][$node['name']] = AHQ::getProps($node);
+        }
       }
-      elseif ($entity && !empty($node['af-block'])) {
-        $entities[$entity]['blocks'][$node['af-block']] = AHQ::getProps($node);
+      elseif ($entity && !empty($node['af-join'])) {
+        $this->entities[$entity]['joins'][$node['af-join']] = AHQ::getProps($node);
+        $this->parseFields($node['#children'] ?? [], $entity, $node['af-join']);
       }
       elseif (!empty($node['#children'])) {
-        self::parseFields($node['#children'], $entities, $entity);
+        $this->parseFields($node['#children'], $entity, $join);
+      }
+      // Recurse into embedded blocks
+      if (isset($this->blocks[$node['#tag']])) {
+        if (!isset($this->blocks[$node['#tag']]['layout'])) {
+          $this->blocks[$node['#tag']] = Afform::get()->setCheckPermissions(FALSE)->setSelect(['name', 'layout'])->addWhere('name', '=', $this->blocks[$node['#tag']]['name'])->execute()->first();
+        }
+        if (!empty($this->blocks[$node['#tag']]['layout'])) {
+          $this->parseFields($this->blocks[$node['#tag']]['layout'], $entity, $join);
+        }
       }
     }
   }
index abd8805806ba48cd83570edf754775d57b9881cd..416bde2d90985eaa2c8a0fbec9577e25a3d0d063 100644 (file)
@@ -45,7 +45,7 @@ abstract class AbstractProcessor extends \Civi\Api4\Generic\AbstractAction {
       }
     }
 
-    $this->_formDataModel = FormDataModel::create($this->_afform['layout']);
+    $this->_formDataModel = new FormDataModel($this->_afform['layout']);
     $this->validateArgs();
     $result->exchangeArray($this->processForm());
   }
@@ -69,4 +69,31 @@ abstract class AbstractProcessor extends \Civi\Api4\Generic\AbstractAction {
    */
   abstract protected function processForm();
 
+  /**
+   * @param $mainEntityName
+   * @param $joinEntityName
+   * @param $mainEntityId
+   * @return array
+   * @throws \API_Exception
+   */
+  protected static function getJoinWhereClause($mainEntityName, $joinEntityName, $mainEntityId) {
+    $joinMeta = \Civi::$statics[__CLASS__][__FUNCTION__][$joinEntityName] ?? NULL;
+    $params = [];
+    if (!$joinMeta) {
+      $joinMeta = civicrm_api4($joinEntityName, 'getFields', ['checkPermissions' => FALSE, 'action' => 'create', 'select' => ['name']])->column('name');
+      \Civi::$statics[__CLASS__][__FUNCTION__][$joinEntityName] = $joinMeta;
+    }
+    if (in_array('entity_id', $joinMeta)) {
+      $params[] = ['entity_id', '=', $mainEntityId];
+      if (in_array('entity_table', $joinMeta)) {
+        $params[] = ['entity_table', '=', 'civicrm_' . _civicrm_api_get_entity_name_from_camel($mainEntityName)];
+      }
+    }
+    else {
+      $mainEntityField = _civicrm_api_get_entity_name_from_camel($mainEntityName) . '_id';
+      $params[] = [$mainEntityField, '=', $mainEntityId];
+    }
+    return $params;
+  }
+
 }
index 65b56ba5f1a9aa7179ac851005dc6eb311e84265..0f2dda46bd8b88bf56df282dae11d3f859e5742a 100644 (file)
@@ -96,9 +96,9 @@ class Get extends \Civi\Api4\Generic\BasicGetAction {
         'description' => '',
         'is_public' => FALSE,
         'permission' => 'access CiviCRM',
-        'block' => 'Custom_' . $custom['name'],
+        'join' => 'Custom_' . $custom['name'],
         'extends' => 'Contact',
-        'repeatable' => TRUE,
+        'repeat' => TRUE,
         'has_base' => TRUE,
       ];
       if ($getLayout) {
index 5332066dcac76e6c1a0ab9c2bf7fd7d2cd6d8456..dd9ba99807983c2c8ac2947d3cd6499245f4f2e6 100644 (file)
@@ -49,17 +49,17 @@ class Prefill extends AbstractProcessor {
       'select' => array_keys($entity['fields']),
       'checkPermissions' => $checkPermissions,
     ]);
-    $data = $result->first();
-    if ($data) {
-      $data['blocks'] = [];
-      foreach ($entity['blocks'] ?? [] as $blockEntity => $block) {
-        $data['blocks'][$blockEntity] = (array) civicrm_api4($blockEntity, 'get', [
-          'where' => [['contact_id', '=', $data['id']]],
-          'limit' => $block['max'] ?? 0,
+    foreach ($result as $item) {
+      $data = ['fields' => $item];
+      foreach ($entity['joins'] ?? [] as $joinEntity => $join) {
+        $data['joins'][$joinEntity] = (array) civicrm_api4($joinEntity, 'get', [
+          'where' => $this->getJoinWhereClause($entity['type'], $joinEntity, $item['id']),
+          'limit' => !empty($join['af-repeat']) ? $join['max'] ?? 0 : 1,
+          'select' => array_keys($join['fields']),
           'checkPermissions' => $checkPermissions,
         ]);
       }
-      $this->_data[$entity['name']] = $data;
+      $this->_data[$entity['name']][] = $data;
     }
   }
 
@@ -68,6 +68,7 @@ class Prefill extends AbstractProcessor {
    *
    * @param $entity
    * @param $mode
+   * @throws \API_Exception
    */
   private function autoFillEntity($entity, $mode) {
     $id = NULL;
index 2f563cfe1c24a0ad5c4c3f5b2840f676f38762b8..54a36f49d132ad2aaaff3add119f8a05e041fc4d 100644 (file)
@@ -3,7 +3,6 @@
 namespace Civi\Api4\Action\Afform;
 
 use Civi\Afform\Event\AfformSubmitEvent;
-use Civi\API\Exception\NotImplementedException;
 
 /**
  * Class Submit
@@ -23,8 +22,15 @@ class Submit extends AbstractProcessor {
   protected function processForm() {
     $entityValues = [];
     foreach ($this->_formDataModel->getEntities() as $entityName => $entity) {
-      // Predetermined values override submitted values
-      $entityValues[$entity['type']][$entityName] = ($entity['af-values'] ?? []) + ($this->values[$entityName] ?? []);
+      foreach ($this->values[$entityName] ?? [] as $values) {
+        $entityValues[$entity['type']][$entityName][] = $values + ['fields' => []];
+        // Predetermined values override submitted values
+        if (!empty($entity['af-values'])) {
+          foreach ($entityValues[$entity['type']][$entityName] as $index => $vals) {
+            $entityValues[$entity['type']][$entityName][$index]['fields'] = $entity['af-values'] + $vals['fields'];
+          }
+        }
+      }
     }
 
     $event = new AfformSubmitEvent($this->_formDataModel->getEntities(), $entityValues);
@@ -44,29 +50,11 @@ class Submit extends AbstractProcessor {
    * @throws \API_Exception
    * @see afform_civicrm_config
    */
-  public function processContacts(AfformSubmitEvent $event) {
-    foreach ($event->entityValues['Contact'] ?? [] as $entityName => $contact) {
-      $blocks = $contact['blocks'] ?? [];
-      unset($contact['blocks']);
-      $saved = civicrm_api4('Contact', 'save', ['records' => [$contact]])->first();
-      foreach ($blocks as $entity => $block) {
-        $values = self::filterEmptyBlocks($entity, $block);
-        // FIXME: Replace/delete should only be done to known contacts
-        if ($values) {
-          civicrm_api4($entity, 'replace', [
-            'where' => [['contact_id', '=', $saved['id']]],
-            'records' => $values,
-          ]);
-        }
-        else {
-          try {
-            civicrm_api4($entity, 'delete', [
-              'where' => [['contact_id', '=', $saved['id']]],
-            ]);
-          } catch (\API_Exception $e) {
-            // No records to delete
-          }
-        }
+  public static function processContacts(AfformSubmitEvent $event) {
+    foreach ($event->entityValues['Contact'] ?? [] as $entityName => $contacts) {
+      foreach ($contacts as $contact) {
+        $saved = civicrm_api4('Contact', 'save', ['records' => [$contact['fields']]])->first();
+        self::saveJoins('Contact', $saved['id'], $contact['joins'] ?? []);
       }
     }
     unset($event->entityValues['Contact']);
@@ -78,23 +66,50 @@ class Submit extends AbstractProcessor {
    * @see afform_civicrm_config
    */
   public static function processGenericEntity(AfformSubmitEvent $event) {
-    foreach ($event->entityValues as $entityType => $records) {
-      civicrm_api4($entityType, 'save', [
-        'records' => $records,
-      ]);
+    foreach ($event->entityValues as $entityType => $entities) {
+      // Each record is an array of one or more items (can be > 1 if af-repeat is used)
+      foreach ($entities as $entityName => $records) {
+        foreach ($records as $record) {
+          $saved = civicrm_api4($entityType, 'save', ['records' => [$record['fields']]])->first();
+          self::saveJoins($entityType, $saved['id'], $record['joins'] ?? []);
+        }
+      }
       unset($event->entityValues[$entityType]);
     }
   }
 
+  protected static function saveJoins($mainEntityName, $entityId, $joins) {
+    foreach ($joins as $joinEntityName => $join) {
+      $values = self::filterEmptyJoins($joinEntityName, $join);
+      // FIXME: Replace/delete should only be done to known contacts
+      if ($values) {
+        civicrm_api4($joinEntityName, 'replace', [
+          'where' => self::getJoinWhereClause($mainEntityName, $joinEntityName, $entityId),
+          'records' => $values,
+        ]);
+      }
+      else {
+        try {
+          civicrm_api4($joinEntityName, 'delete', [
+            'where' => self::getJoinWhereClause($mainEntityName, $joinEntityName, $entityId),
+          ]);
+        }
+        catch (\API_Exception $e) {
+          // No records to delete
+        }
+      }
+    }
+  }
+
   /**
-   * Filter out blocks that have been left blank on the form
+   * Filter out joins that have been left blank on the form
    *
    * @param $entity
-   * @param $block
+   * @param $join
    * @return array
    */
-  private static function filterEmptyBlocks($entity, $block) {
-    return array_filter($block, function($item) use($entity) {
+  private static function filterEmptyJoins($entity, $join) {
+    return array_filter($join, function($item) use($entity) {
       switch ($entity) {
         case 'Email':
           return !empty($item['email']);
index 7dd1522f06b65fc435ece3f37bb1122d6d41a3fe..f289477b8df6c947dc6b2e03774e1cca46f013ea 100644 (file)
@@ -97,7 +97,7 @@ class Afform extends AbstractEntity {
           'name' => 'block',
         ],
         [
-          'name' => 'extends',
+          'name' => 'join',
         ],
         [
           'name' => 'title',
@@ -111,8 +111,8 @@ class Afform extends AbstractEntity {
           'data_type' => 'Boolean',
         ],
         [
-          'name' => 'repeatable',
-          'data_type' => 'Boolean',
+          'name' => 'repeat',
+          'data_type' => 'Mixed',
         ],
         [
           'name' => 'server_route',
index 3a4f8ae871c8a17e4d522899da19c7cf2cedf93f..8502faeb120af166fc03c5d89f9ac5b2e55daf0c 100644 (file)
@@ -322,12 +322,12 @@ function afform_civicrm_alterAngular($angular) {
     ->alterHtml(';\\.aff\\.html$;', function($doc, $path) {
       try {
         $module = \Civi::service('angular')->getModule(basename($path, '.aff.html'));
-        $meta = \Civi\Api4\Afform::get()->addWhere('name', '=', $module['_afform'])->addSelect('block')->setCheckPermissions(FALSE)->execute()->first();
+        $meta = \Civi\Api4\Afform::get()->addWhere('name', '=', $module['_afform'])->setSelect(['join', 'block'])->setCheckPermissions(FALSE)->execute()->first();
       }
       catch (Exception $e) {
       }
 
-      $blockEntity = $meta['block'] ?? NULL;
+      $blockEntity = $meta['join'] ?? $meta['block'] ?? NULL;
       if (!$blockEntity) {
         $entities = _afform_getMetadata($doc);
       }
@@ -335,12 +335,12 @@ function afform_civicrm_alterAngular($angular) {
       foreach (pq('af-field', $doc) as $afField) {
         /** @var DOMElement $afField */
         $entityName = pq($afField)->parents('[af-fieldset]')->attr('af-fieldset');
-        $blockName = pq($afField)->parents('[af-block]')->attr('af-block');
+        $joinName = pq($afField)->parents('[af-join]')->attr('af-join');
         if (!$blockEntity && !preg_match(';^[a-zA-Z0-9\_\-\. ]+$;', $entityName)) {
           throw new \CRM_Core_Exception("Cannot process $path: malformed entity name ($entityName)");
         }
         $entityType = $blockEntity ?? $entities[$entityName]['type'];
-        _af_fill_field_metadata($blockName ? $blockName : $entityType, $afField);
+        _af_fill_field_metadata($joinName ? $joinName : $entityType, $afField);
       }
     });
   $angular->add($fieldMetadata);
index 1949657794f3229b855b14ea6abc12bdf41cafab..d927eb8a3493c6d6d11ef1469be281cfcae01696 100644 (file)
@@ -18,8 +18,9 @@ return [
     'af-entity' => 'E',
     'af-fieldset' => 'A',
     'af-form' => 'E',
-    'af-block' => 'A',
-    'af-block-item' => 'A',
+    'af-join' => 'A',
+    'af-repeat' => 'A',
+    'af-repeat-item' => 'A',
     'af-field' => 'E',
   ],
 ];
diff --git a/ext/afform/core/ang/af/Block.js b/ext/afform/core/ang/af/Block.js
deleted file mode 100644 (file)
index 68e9a7b..0000000
+++ /dev/null
@@ -1,63 +0,0 @@
-(function(angular, $, _) {
-  // Example usage: <div af-block="Email" min="1" max="3" add-label="Add email" ><div block-email-default /></div>
-  angular.module('af')
-    .directive('afBlock', function() {
-      return {
-        restrict: 'A',
-        require: ['^^afFieldset'],
-        scope: {
-          blockName: '@afBlock',
-          min: '=',
-          max: '=',
-          addLabel: '@',
-          addIcon: '@'
-        },
-        transclude: true,
-        templateUrl: '~/af/afBlock.html',
-        link: function($scope, $el, $attr, ctrls) {
-          var ts = $scope.ts = CRM.ts('afform');
-          $scope.afFieldset = ctrls[0];
-        },
-        controller: function($scope) {
-          this.getItems = $scope.getItems = function() {
-            var data = $scope.afFieldset.getData();
-            data.blocks = data.blocks || {};
-            var block = (data.blocks[$scope.blockName] = data.blocks[$scope.blockName] || []);
-            while ($scope.min && block.length < $scope.min) {
-              block.push({});
-            }
-            return block;
-          };
-
-          $scope.addItem = function() {
-            $scope.getItems().push({});
-          };
-
-          $scope.removeItem = function(index) {
-            $scope.getItems().splice(index, 1);
-          };
-
-          $scope.canAdd = function() {
-            return !$scope.max || $scope.getItems().length < $scope.max;
-          };
-
-          $scope.canRemove = function() {
-            return !$scope.min || $scope.getItems().length > $scope.min;
-          };
-        }
-      };
-    })
-    .directive('afBlockItem', function() {
-      return {
-        restrict: 'A',
-        scope: {
-          item: '=afBlockItem'
-        },
-        controller: function($scope) {
-          this.getData = function() {
-            return $scope.item;
-          };
-        }
-      };
-    });
-})(angular, CRM.$, CRM._);
index e55b13c8a42f89b198c66ec2efb835fef5e216b0..5bf7c9b8db6782ad0554dcc8a107ae87d4090277 100644 (file)
@@ -1,9 +1,10 @@
 (function(angular, $, _) {
+  var id = 0;
   // Example usage: <div af-fieldset="myModel"><af-field name="do_not_email" /></div>
   angular.module('af').directive('afField', function() {
     return {
       restrict: 'E',
-      require: ['^^afFieldset', '?^afBlockItem'],
+      require: ['^^afForm', '^^afFieldset', '?^^afJoin', '?^^afRepeatItem'],
       templateUrl: '~/af/afField.html',
       scope: {
         fieldName: '@name',
       },
       link: function($scope, $el, $attr, ctrls) {
         var ts = $scope.ts = CRM.ts('afform'),
-          afFieldset = ctrls[0],
-          blockItem = ctrls[1];
-        $scope.fieldId = afFieldset.getDefn().modelName + '-' + $scope.fieldName;
-        $scope.getData = blockItem ? blockItem.getData : afFieldset.getData;
+          closestController = $($el).closest('[af-fieldset],[af-join],[af-repeat-item]'),
+          afForm = ctrls[0];
+        $scope.dataProvider = closestController.is('[af-repeat-item]') ? ctrls[3] : ctrls[2] || ctrls[1];
+        $scope.fieldId = afForm.getFormMeta().name + '-' + $scope.fieldName + '-' + id++;
 
         $el.addClass('af-field-type-' + _.kebabCase($scope.defn.input_type));
 
index 69dde11b15768779366c2dbf0668c66ea2995648..a62483cc85f01ee2aa11c90de0825aa4767cc19f 100644 (file)
@@ -3,23 +3,30 @@
   angular.module('af').directive('afFieldset', function() {
     return {
       restrict: 'A',
-      require: '^afForm',
-      scope: {
+      require: ['afFieldset', '^afForm'],
+      bindToController: {
         modelName: '@afFieldset'
       },
-      link: function($scope, $el, $attr, afFormCtrl) {
-        $scope.afFormCtrl = afFormCtrl;
+      link: function($scope, $el, $attr, ctrls) {
+        var self = ctrls[0];
+        self.afFormCtrl = ctrls[1];
       },
       controller: function($scope){
-        this.getDefn = function getDefn() {
-          return $scope.afFormCtrl.getEntity($scope.modelName);
-          // return $scope.modelDefn;
+        this.getDefn = function() {
+          return this.afFormCtrl.getEntity(this.modelName);
         };
-        this.getData = function getData() {
-          return $scope.afFormCtrl.getData($scope.modelName);
+        this.getData = function() {
+          return this.afFormCtrl.getData(this.modelName);
         };
         this.getName = function() {
-          return $scope.modelName;
+          return this.modelName;
+        };
+        this.getFieldData = function() {
+          var data = this.getData();
+          if (!data.length) {
+            data.push({fields: {}});
+          }
+          return data[0].fields;
         };
       }
     };
index fcf699df03f3231977ad07cd80cecde0cd045a46..06f3b9d18342b40908e3d2f4b23125d0238f20a6 100644 (file)
@@ -20,7 +20,7 @@
 
         this.registerEntity = function registerEntity(entity) {
           schema[entity.modelName] = entity;
-          data[entity.modelName] = entity.data || {};
+          data[entity.modelName] = [];
         };
         this.getEntity = function getEntity(name) {
           return schema[name];
diff --git a/ext/afform/core/ang/af/Join.js b/ext/afform/core/ang/af/Join.js
new file mode 100644 (file)
index 0000000..35d6b35
--- /dev/null
@@ -0,0 +1,47 @@
+(function(angular, $, _) {
+  // Example usage: <div af-join="Email" min="1" max="3" add-label="Add email" ><div join-email-default /></div>
+  angular.module('af')
+    .directive('afJoin', function() {
+      return {
+        restrict: 'A',
+        require: ['afJoin', '^^afFieldset', '?^^afRepeatItem'],
+        bindToController: {
+          entity: '@afJoin',
+        },
+        link: function($scope, $el, $attr, ctrls) {
+          var self = ctrls[0];
+          self.afFieldset = ctrls[1];
+          self.repeatItem = ctrls[2];
+        },
+        controller: function($scope) {
+          var self = this;
+          this.getData = function() {
+            var data, fieldsetData;
+            if (self.repeatItem) {
+              data = self.repeatItem.item;
+            } else {
+              fieldsetData = self.afFieldset.getData();
+              if (!fieldsetData.length) {
+                fieldsetData.push({fields: {}, joins: {}});
+              }
+              data = fieldsetData[0];
+            }
+            if (!data.joins) {
+              data.joins = {};
+            }
+            if (!data.joins[self.entity]) {
+              data.joins[self.entity] = [];
+            }
+            return data.joins[self.entity];
+          };
+          this.getFieldData = function() {
+            var data = this.getData();
+            if (!data.length) {
+              data.push({});
+            }
+            return data[0];
+          };
+        }
+      };
+    });
+})(angular, CRM.$, CRM._);
diff --git a/ext/afform/core/ang/af/Repeat.js b/ext/afform/core/ang/af/Repeat.js
new file mode 100644 (file)
index 0000000..52337c9
--- /dev/null
@@ -0,0 +1,70 @@
+(function(angular, $, _) {
+  // Example usage: <div af-repeat="Email" min="1" max="3" add-label="Add email" ><div repeat-email-default /></div>
+  angular.module('af')
+    .directive('afRepeat', function() {
+      return {
+        restrict: 'A',
+        require: ['?afFieldset', '?afJoin'],
+        transclude: true,
+        scope: {
+          min: '=',
+          max: '=',
+          addLabel: '@afRepeat',
+          addIcon: '@'
+        },
+        templateUrl: '~/af/afRepeat.html',
+        link: function($scope, $el, $attr, ctrls) {
+          $scope.afFieldset = ctrls[0];
+          $scope.afJoin = ctrls[1];
+        },
+        controller: function($scope) {
+          this.getItems = $scope.getItems = function() {
+            var data = $scope.afJoin ? $scope.afJoin.getData() : $scope.afFieldset.getData();
+            while ($scope.min && data.length < $scope.min) {
+              data.push(getRepeatType() === 'join' ? {} : {fields: {}, joins: {}});
+            }
+            return data;
+          };
+
+          function getRepeatType() {
+            return $scope.afJoin ? 'join' : 'fieldset';
+          }
+          this.getRepeatType = getRepeatType;
+
+          $scope.addItem = function() {
+            $scope.getItems().push(getRepeatType() === 'join' ? {} : {fields: {}});
+          };
+
+          $scope.removeItem = function(index) {
+            $scope.getItems().splice(index, 1);
+          };
+
+          $scope.canAdd = function() {
+            return !$scope.max || $scope.getItems().length < $scope.max;
+          };
+
+          $scope.canRemove = function() {
+            return !$scope.min || $scope.getItems().length > $scope.min;
+          };
+        }
+      };
+    })
+    .directive('afRepeatItem', function() {
+      return {
+        restrict: 'A',
+        require: ['afRepeatItem', '^^afRepeat'],
+        bindToController: {
+          item: '=afRepeatItem'
+        },
+        link: function($scope, $el, $attr, ctrls) {
+          var self = ctrls[0];
+          self.afRepeat = ctrls[1];
+        },
+        controller: function($scope) {
+          this.getFieldData = function() {
+            return this.afRepeat.getRepeatType() === 'join' ? this.item : this.item.fields;
+          };
+        }
+      };
+    });
+})(angular, CRM.$, CRM._);
diff --git a/ext/afform/core/ang/af/afBlock.html b/ext/afform/core/ang/af/afBlock.html
deleted file mode 100644 (file)
index 463f383..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-<div af-block-item="item" ng-repeat="item in getItems()">
-  <ng-transclude />
-  <button crm-icon="fa-ban" class="btn btn-xs af-block-remove-btn" ng-if="canRemove()" ng-click="removeItem($index)"></button>
-</div>
-<button crm-icon="{{ addIcon || 'fa-plus' }}" class="btn btn-sm af-block-add-btn" ng-if="canAdd()" ng-click="addItem()">{{ addLabel }}</button>
diff --git a/ext/afform/core/ang/af/afRepeat.html b/ext/afform/core/ang/af/afRepeat.html
new file mode 100644 (file)
index 0000000..98346d3
--- /dev/null
@@ -0,0 +1,5 @@
+<div af-repeat-item="item" ng-repeat="item in getItems()">
+  <ng-transclude />
+  <button crm-icon="fa-ban" class="btn btn-xs af-repeat-remove-btn" ng-if="canRemove()" ng-click="removeItem($index)"></button>
+</div>
+<button crm-icon="{{ addIcon || 'fa-plus' }}" class="btn btn-sm af-repeat-add-btn" ng-if="canAdd()" ng-click="addItem()">{{ addLabel }}</button>
index fbdab6b0b219f73a482b21e75cd9307829c5b7b3..be20b9efe578699ec2b7db7d62b731aef69e6d48 100644 (file)
@@ -1,7 +1,7 @@
 <ul class="crm-checkbox-list" id="{{ fieldId }}" ng-if="defn.options">
   <li ng-repeat="opt in defn.options" >
-    <input type="checkbox" checklist-model="getData()[name]" id="{{ fieldId + opt.key }}" checklist-value="opt.key" />
+    <input type="checkbox" checklist-model="dataProvider.getFieldData()[fieldName]" id="{{ fieldId + opt.key }}" checklist-value="opt.key" />
     <label for="{{ fieldId + opt.key }}">{{ opt.label }}</label>
   </li>
 </ul>
-<input type="checkbox" ng-if="!defn.options" id="{{ fieldId }}" ng-model="getData()[fieldName]" ng-true-value="'1'" ng-false-value="'0'" />
+<input type="checkbox" ng-if="!defn.options" id="{{ fieldId }}" ng-model="dataProvider.getFieldData()[fieldName]" ng-true-value="'1'" ng-false-value="'0'" />
index 8e46274415affc297afb3e2e001eb43974b9650a..5e7d7c21d7aa70dfd4e8ec78515b2fa8c11dfe5c 100644 (file)
@@ -1 +1 @@
-<input crm-ui-datepicker="defn.input_attrs" id="{{ fieldId }}" ng-model="getData()[fieldName]" />
+<input crm-ui-datepicker="defn.input_attrs" id="{{ fieldId }}" ng-model="dataProvider.getFieldData()[fieldName]" />
index 2028c402c56b8a0acc22ae3cee48688a45f6efee..111549c786546156d7c10ec05e84c4b92c7bba88 100644 (file)
@@ -1 +1 @@
-<input class="crm-form-text" type="number" id="{{ fieldId }}" ng-model="getData()[fieldName]" placeholder="{{ defn.input_attrs.placeholder }}" />
+<input class="crm-form-text" type="number" id="{{ fieldId }}" ng-model="dataProvider.getFieldData()[fieldName]" placeholder="{{ defn.input_attrs.placeholder }}" />
index b3e9917b60988400e375b72af5a07fdc549759bb..f4ee1fae4289753f099c690813d47b7e060e5b8f 100644 (file)
@@ -1,4 +1,4 @@
 <label ng-repeat="opt in getOptions()" >
-  <input class="crm-form-radio" type="radio" ng-model="getData()[fieldName]" value="{{ opt.key }}" />
+  <input class="crm-form-radio" type="radio" ng-model="dataProvider.getFieldData()[fieldName]" value="{{ opt.key }}" />
   {{ opt.label }}
 </label>
index cedb7221047aa5089f83e5fee6a09f70d87c0980..e2dbc6e9d1d3abdc687c87ea3c93568eb4e30ee5 100644 (file)
@@ -1 +1 @@
-<textarea crm-ui-richtext id="{{ fieldId }}" ng-model="getData()[fieldName]" ></textarea>
+<textarea crm-ui-richtext id="{{ fieldId }}" ng-model="dataProvider.getFieldData()[fieldName]" ></textarea>
index a89e8464ecf31878e15cbf19db51f16ce5fbbba0..a3cb1e2abd7b480b34c306ad7571167e147ef129 100644 (file)
@@ -1 +1 @@
-<input crm-ui-select="{data: select2Options, multiple: defn.input_attrs.multiple, placeholder: defn.input_attrs.placeholder}" id="{{ fieldId }}" ng-model="getData()[fieldName]" />
+<input crm-ui-select="{data: select2Options, multiple: defn.input_attrs.multiple, placeholder: defn.input_attrs.placeholder}" id="{{ fieldId }}" ng-model="dataProvider.getFieldData()[fieldName]" />
index 619196a073242555578127f60d0589a736fab2f2..008b3441c693525ee859a1eede5d70db0dc73194 100644 (file)
@@ -1 +1 @@
-<input class="crm-form-text" type="text" id="{{ fieldId }}" ng-model="getData()[fieldName]" placeholder="{{ defn.input_attrs.placeholder }}" />
+<input class="crm-form-text" type="text" id="{{ fieldId }}" ng-model="dataProvider.getFieldData()[fieldName]" placeholder="{{ defn.input_attrs.placeholder }}" />
index f19d4c9771bbe5e50331e96039e1e1ef4af4ad1d..66a2c5a37509f4b9bd0f73af6c0e34499a7e1a8b 100644 (file)
@@ -1 +1 @@
-<textarea class="crm-form-textarea" id="{{ fieldId }}" ng-model="getData()[fieldName]" ></textarea>
+<textarea class="crm-form-textarea" id="{{ fieldId }}" ng-model="dataProvider.getFieldData()[fieldName]" ></textarea>
index 209c8420abfa366fca8a63d17f2ebca03af4e932..59070b0df50e1858e179b29bbdc8a49f28262e2b 100644 (file)
@@ -18,10 +18,10 @@ a.af-api4-action-idle {
     vertical-align: bottom;
 }
 
-[af-block-item] {
+[af-repeat-item] {
   position: relative;
 }
-#bootstrap-theme [af-block-item] .af-block-remove-btn {
+#bootstrap-theme [af-repeat-item] .af-repeat-remove-btn {
   min-width: 30px;
   position: absolute;
   top: 0;
index 12d6107473b576f39450297beee6f0b703cbbb8a..107756e1c15ba2d449f5077090a0621946f59229 100644 (file)
@@ -1,6 +1,6 @@
 {
   "title": "Email Block (default)",
-  "block": "Email",
-  "extends": "Contact",
-  "repeatable": true
+  "block": "Contact",
+  "join": "Email",
+  "repeat": true
 }
index 776f64f28583a763e54e26f7a3e5a68b43a28c59..261df8a830cc9e5d8ad33c7111350d855c811624 100644 (file)
@@ -1,6 +1,6 @@
 {
   "title": "IM Block (default)",
-  "block": "IM",
-  "extends": "Contact",
-  "repeatable": true
+  "block": "Contact",
+  "join": "IM",
+  "repeat": true
 }
diff --git a/ext/afform/core/ang/blockNameIndividual.aff.html b/ext/afform/core/ang/blockNameIndividual.aff.html
new file mode 100644 (file)
index 0000000..1142cd9
--- /dev/null
@@ -0,0 +1,5 @@
+<div class="af-container af-layout-inline">
+  <af-field name="first_name" />
+  <af-field name="middle_name" />
+  <af-field name="last_name" />
+</div>
diff --git a/ext/afform/core/ang/blockNameIndividual.aff.json b/ext/afform/core/ang/blockNameIndividual.aff.json
new file mode 100644 (file)
index 0000000..9a328ca
--- /dev/null
@@ -0,0 +1,4 @@
+{
+  "title": "Individual Name (default)",
+  "block": "Contact"
+}
index 00fb70916c7af98447b4cf4b82e9c1404ef370ea..9419dbdb17fe807ff0a1548e243aa8e52d4e18e8 100644 (file)
@@ -1,6 +1,6 @@
 {
   "title": "Phone Block (default)",
-  "block": "Phone",
-  "extends": "Contact",
-  "repeatable": true
+  "block": "Contact",
+  "join": "Phone",
+  "repeat": true
 }
index c6530dcf4ec8de5605acd5dca12a80f309ef75c0..71b1e68a78a2fd04d126c250aca2f377e497aa8d 100644 (file)
@@ -1,6 +1,6 @@
 {
   "title": "Website Block (default)",
-  "block": "Website",
-  "extends": "Contact",
-  "repeatable": true
+  "block": "Contact",
+  "join": "Website",
+  "repeat": true
 }
index 76aafd70b352b8d59b628bf442365f43b30c69f1..43a4a557440e613faa7cb55fffb4618c5e20ffd6 100644 (file)
@@ -48,7 +48,7 @@ class FormDataModelTest extends \PHPUnit\Framework\TestCase implements HeadlessI
             'propA' => ['name' => 'propA'],
             'propB' => ['name' => 'propB', 'defn' => ['title' => 'Whiz']],
           ],
-          'blocks' => [],
+          'joins' => [],
         ],
       ],
     ];
@@ -60,13 +60,13 @@ class FormDataModelTest extends \PHPUnit\Framework\TestCase implements HeadlessI
           'type' => 'Foo',
           'name' => 'foobar',
           'fields' => [],
-          'blocks' => [],
+          'joins' => [],
         ],
         'whiz_bang' => [
           'type' => 'Whiz',
           'name' => 'whiz_bang',
           'fields' => [],
-          'blocks' => [],
+          'joins' => [],
         ],
       ],
     ];
@@ -81,7 +81,7 @@ class FormDataModelTest extends \PHPUnit\Framework\TestCase implements HeadlessI
    */
   public function testGetEntities($html, $expectEntities) {
     $parser = new \CRM_Afform_ArrayHtml();
-    $fdm = FormDataModel::create($parser->convertHtmlToArray($html));
+    $fdm = new FormDataModel($parser->convertHtmlToArray($html));
     $this->assertEquals($expectEntities, $fdm->getEntities());
   }
 
index ad7028e743af610268d08836860637d0b934f88f..db98953764d812e57584ecbcc075a970b23a8472 100644 (file)
@@ -186,12 +186,14 @@ function afform_gui_civicrm_buildAsset($asset, $params, &$mimeType, &$content) {
   $blockData = \Civi\Api4\Afform::get()
     ->setCheckPermissions(FALSE)
     ->addWhere('block', 'IS NOT NULL')
-    ->setSelect(['name', 'title', 'block', 'extends', 'layout', 'repeatable'])
+    ->setSelect(['name', 'title', 'block', 'join', 'layout', 'repeat'])
     ->setFormatWhitespace(TRUE)
     ->setLayoutFormat('shallow')
     ->execute();
   foreach ($blockData as $block) {
-    $entityWhitelist[] = $block['block'];
+    if (!empty($block['join']) && !in_array($block['join'], $entityWhitelist)) {
+      $entityWhitelist[] = $block['join'];
+    }
     $data['blocks'][_afform_angular_module_name($block['name'], 'dash')] = $block;
   }
 
@@ -246,7 +248,7 @@ function afform_gui_civicrm_buildAsset($asset, $params, &$mimeType, &$content) {
     'where' => [['input_type', 'IS NOT NULL']],
   ];
 
-  // Get fields for main entities + block entities
+  // Get fields for main entities + joined entities
   foreach (array_unique($entityWhitelist) as $entityName) {
     $data['fields'][$entityName] = (array) civicrm_api4($entityName, 'getFields', $getFieldParams, 'name');
 
index 1b5340b11d2670ba0349632a98f731c9b23559c0..2297fcbafa158f769523c0fdc36dd5cb1a8a3203 100644 (file)
         $scope.controls = {};
         $scope.fieldList = [];
         $scope.blockList = [];
+        $scope.blockTitles = [];
         $scope.elementList = [];
         $scope.elementTitles = [];
 
 
         function buildBlockList(search) {
           $scope.blockList.length = 0;
+          $scope.blockTitles.length = 0;
           _.each($scope.editor.meta.blocks, function(block, directive) {
-            if (!search || _.contains(block.name.toLowerCase(), search) || _.contains(block.title.toLowerCase(), search)) {
-              $scope.blockList.push({
-                "#tag": "div",
-                "af-block": block.block,
-                "add-label": ts('Add'),
-                min: '1',
-                "#children": [
-                  {"#tag": directive}
-                ]
-              });
+            if (!search || _.contains(directive, search) || _.contains(block.name.toLowerCase(), search) || _.contains(block.title.toLowerCase(), search)) {
+              var item = {"#tag": block.join ? "div" : directive};
+              if (block.join) {
+                item['af-join'] = block.join;
+                item['#children'] = [{"#tag": directive}];
+              }
+              if (block.repeat) {
+                item['af-repeat'] = ts('Add');
+                item.min = '1';
+                if (typeof block.repeat === 'number') {
+                  item.max = '' + block.repeat;
+                }
+              }
+              $scope.blockList.push(item);
+              $scope.blockTitles.push(block.title);
             }
           });
         }
           return check($scope.editor.scope.layout['#children'], {'#tag': 'af-field', name: fieldName});
         };
 
-        $scope.blockInUse = function(blockType) {
-          return check($scope.editor.scope.layout['#children'], {'af-block': blockType});
+        $scope.blockInUse = function(block) {
+          if (block['af-join']) {
+            return check($scope.editor.scope.layout['#children'], {'af-join': block['af-join']});
+          }
+          var fieldsInBlock = _.pluck(findRecursive($scope.editor.meta.blocks[block['#tag']].layout, {'#tag': 'af-field'}), 'name');
+          return check($scope.editor.scope.layout['#children'], function(item) {
+            return item['#tag'] === 'af-field' && _.includes(fieldsInBlock, item.name);
+          });
         };
 
-        // Recursively check for a matching object in a multi-level collection
+        // Check for a matching item for this entity
+        // Recursively checks the form layout, including block directives
         function check(group, criteria, found) {
           if (!found) {
             found = {};
               return false;
             }
             if (_.isPlainObject(item)) {
+              // Recurse through everything but skip fieldsets for other entities
               if ((!item['af-fieldset'] || (item['af-fieldset'] === $scope.entity.name)) && item['#children']) {
                 check(item['#children'], criteria, found);
               }
+              // Recurse into block directives
+              else if (item['#tag'] && item['#tag'] in $scope.editor.meta.blocks) {
+                check($scope.editor.meta.blocks[item['#tag']].layout, criteria, found);
+              }
             }
           });
           return found.match;
       templateUrl: '~/afGuiEditor/container.html',
       scope: {
         node: '=afGuiContainer',
-        block: '=',
+        join: '=',
         entityName: '='
       },
       require: ['^^afGuiEditor', '?^^afGuiContainer'],
       link: function($scope, element, attrs, ctrls) {
+        var ts = $scope.ts = CRM.ts();
         $scope.editor = ctrls[0];
         $scope.parentContainer = ctrls[1];
-      },
-      controller: function($scope) {
-        var container = $scope.container = this;
-        var ts = $scope.ts = CRM.ts();
-        this.node = $scope.node;
-
-        this.getNodeType = function(node) {
-          if (!node) {
-            return null;
-          }
-          if (node['#tag'] === 'af-field') {
-            return 'field';
-          }
-          if (node['af-fieldset']) {
-            return 'fieldset';
-          }
-          if (node['af-block']) {
-            return 'block';
-          }
-          var classes = splitClass(node['class']),
-            types = ['af-container', 'af-text', 'af-button', 'af-markup'],
-            type = _.intersection(types, classes);
-          return type.length ? type[0].replace('af-', '') : null;
-        };
 
         $scope.addElement = function(type, props) {
           var classes = type.split('.');
           }
         };
 
-        this.removeElement = function(element) {
-          removeRecursive($scope.getSetChildren(), {$$hashKey: element.$$hashKey});
-        };
-
-        this.getEntityName = function() {
-          return $scope.entityName.split('-block-')[0];
-        };
-
-        // Returns the primary entity type for this container e.g. "Contact"
-        this.getMainEntityType = function() {
-          return $scope.editor && $scope.editor.getEntity(container.getEntityName()).type;
-        };
-
-        // Returns the entity type for fields within this conainer (block entity type if this is a block, else the primary entity type)
-        this.getFieldEntityType = function() {
-          var blockType = $scope.entityName.split('-block-');
-          return blockType[1] || ($scope.editor && $scope.editor.getEntity(blockType[0]).type);
-        };
-
         $scope.isSelectedFieldset = function(entityName) {
           return entityName === $scope.editor.getselectedEntityName();
         };
         };
 
         // Block settings
-        var selectedBlock = $scope.selectedBlock = {
-          name: null,
-          layout: null,
-          override: false,
-          options: [],
-        };
+        var block = {};
+        $scope.block = null;
 
         $scope.getSetChildren = function(val) {
-          var collection = selectedBlock.layout || ($scope.node && $scope.node['#children']);
+          var collection = block.layout || ($scope.node && $scope.node['#children']);
           return arguments.length ? (collection = val) : collection;
         };
 
-        function getBlockOptions() {
-          var blockOptions = [];
-          _.each($scope.editor.meta.blocks, function(block, id) {
-            if (block.block === $scope.container.getFieldEntityType()) {
-              blockOptions.push({
-                id: id,
-                text: block.title + (selectedBlock.name === id && selectedBlock.override ? ' ' + ts('(overridden)') : ''),
-              });
-            }
-          });
-          return blockOptions;
-        }
-
-        if ($scope.block) {
+        $scope.isRepeatable = function() {
+          return $scope.node['af-fieldset'] || (block.directive && $scope.editor.meta.blocks[block.directive].repeat) || $scope.join;
+        };
 
-          $scope.isRepeatable = function() {
-            return "fixme";
-          };
+        $scope.toggleRepeat = function() {
+          if ('af-repeat' in $scope.node) {
+            delete $scope.node.max;
+            delete $scope.node.min;
+            delete $scope.node['af-repeat'];
+            delete $scope.node['add-icon'];
+          } else {
+            $scope.node.min = '1';
+            $scope.node['af-repeat'] = ts('Add');
+          }
+        };
 
-          $scope.toggleRepeat = function() {
-            if ($scope.node.min === '1' && $scope.node.max === '1') {
-              delete $scope.node.max;
+        $scope.getSetMin = function(val) {
+          if (arguments.length) {
+            if ($scope.node.max && val > parseInt($scope.node.max, 10)) {
+              $scope.node.max = '' + val;
+            }
+            if (!val) {
               delete $scope.node.min;
-              $scope.node['add-label'] = ts('Add');
-            } else {
-              $scope.node.min = $scope.node.max = '1';
-              delete $scope.node['add-label'];
             }
-          };
-
-          $scope.getSetMin = function(val) {
-            if (arguments.length) {
-              if ($scope.node.max && val >= parseInt($scope.node.max, 10)) {
-                $scope.node.max = '' + (1 + val);
-              }
-              if (!val) {
-                delete $scope.node.min;
-              }
-              else {
-                $scope.node.min = '' + val;
-              }
+            else {
+              $scope.node.min = '' + val;
             }
-            return $scope.node.min ? parseInt($scope.node.min, 10) : null;
-          };
+          }
+          return $scope.node.min ? parseInt($scope.node.min, 10) : null;
+        };
 
-          $scope.getSetMax = function(val) {
-            if (arguments.length) {
-              if (!$scope.node.max && $scope.node.min === '1' && val === 1) {
-                val++;
-              }
-              if ($scope.node.min && val && val <= parseInt($scope.node.min, 10)) {
-                $scope.node.min = '' + (val - 1);
-              }
-              if (typeof val !== 'number') {
-                delete $scope.node.max;
-              }
-              else {
-                $scope.node.max = '' + val;
-              }
+        $scope.getSetMax = function(val) {
+          if (arguments.length) {
+            if ($scope.node.min && val && val < parseInt($scope.node.min, 10)) {
+              $scope.node.min = '' + val;
             }
-            return $scope.node.max ? parseInt($scope.node.max, 10) : null;
-          };
+            if (typeof val !== 'number') {
+              delete $scope.node.max;
+            }
+            else {
+              $scope.node.max = '' + val;
+            }
+          }
+          return $scope.node.max ? parseInt($scope.node.max, 10) : null;
+        };
+
+        $scope.pickAddIcon = function() {
+          openIconPicker($scope.node, 'add-icon');
+        };
+
+        function getBlockNode() {
+          return !$scope.join ? $scope.node : ($scope.node['#children'] && $scope.node['#children'].length === 1 ? $scope.node['#children'][0] : null);
+        }
+
+        function setBlockDirective(directive) {
+          if ($scope.join) {
+            $scope.node['#children'] = [{'#tag': directive}];
+          } else {
+            delete $scope.node['#children'];
+            delete $scope.node['class'];
+            $scope.node['#tag'] = directive;
+          }
+        }
+
+        function overrideBlockContents(layout) {
+          $scope.node['#children'] = layout || [];
+          if (!$scope.join) {
+            $scope.node['#tag'] = 'div';
+            $scope.node['class'] = 'af-container';
+          }
+          block.override = true;
+          block.layout = null;
+        }
+
+        $scope.layouts = {
+          'af-layout-rows': ts('Contents display as rows'),
+          'af-layout-cols': ts('Contents are evenly-spaced columns'),
+          'af-layout-inline': ts('Contents are arranged inline')
+        };
 
-          $scope.pickAddIcon = function() {
-            openIconPicker($scope.node, 'add-icon');
+        $scope.getLayout = function() {
+          if (!$scope.node) {
+            return '';
+          }
+          return _.intersection(splitClass($scope.node['class']), _.keys($scope.layouts))[0] || 'af-layout-rows';
+        };
+
+        $scope.setLayout = function(val) {
+          var classes = ['af-container'];
+          if (val !== 'af-layout-rows') {
+            classes.push(val);
+          }
+          modifyClasses($scope.node, _.keys($scope.layouts), classes);
+        };
+
+        if (($scope.node['#tag'] in $scope.editor.meta.blocks) || $scope.join) {
+
+          block = $scope.block = {
+            directive: null,
+            layout: null,
+            override: false,
+            options: [],
           };
 
-          $scope.$watch('selectedBlock.name', function (name, oldVal) {
-            if (name && name !== oldVal) {
-              selectedBlock.layout = _.cloneDeep($scope.editor.meta.blocks[name].layout);
-              $scope.node['#children'] = [{'#tag': name}];
-              selectedBlock.override = false;
-              selectedBlock.options = getBlockOptions();
+          _.each($scope.editor.meta.blocks, function(blockInfo, directive) {
+            if (directive === $scope.node['#tag'] || blockInfo.join === $scope.container.getFieldEntityType()) {
+              block.options.push({
+                id: directive,
+                text: blockInfo.title
+              });
             }
-            else if (!name && oldVal) {
-              if (selectedBlock.layout) {
-                $scope.node['#children'] = selectedBlock.layout;
-              }
-              selectedBlock.override = false;
-              selectedBlock.layout = null;
-              selectedBlock.options = getBlockOptions();
+          });
+
+          $scope.$watch('block.directive', function (directive, oldVal) {
+            if (directive && directive !== oldVal) {
+              block.layout = _.cloneDeep($scope.editor.meta.blocks[directive].layout);
+              setBlockDirective(directive);
+              block.override = false;
+            }
+            else if (!directive && oldVal) {
+              overrideBlockContents(block.layout);
+              block.override = false;
             }
           });
 
-          $scope.$watch('selectedBlock.layout', function (layout, oldVal) {
-            if (selectedBlock.name && !selectedBlock.override && layout && layout !== oldVal && !angular.equals(layout, $scope.editor.meta.blocks[selectedBlock.name].layout)) {
-              $scope.node['#children'] = selectedBlock.layout;
-              selectedBlock.override = true;
-              selectedBlock.layout = null;
-              selectedBlock.options = getBlockOptions();
+          $scope.$watch('block.layout', function (layout, oldVal) {
+            if (block.directive && !block.override && layout && layout !== oldVal && !angular.equals(layout, $scope.editor.meta.blocks[block.directive].layout)) {
+              overrideBlockContents(block.layout);
             }
           }, true);
 
           $scope.$watch("node['#children']", function (children) {
-            if (!selectedBlock.name && children && children.length === 1 && children[0]['#tag'] in $scope.editor.meta.blocks) {
-              selectedBlock.name = children[0]['#tag'];
-              selectedBlock.override = false;
-            } else if (selectedBlock.name && selectedBlock.override && !selectedBlock.layout && children && angular.equals(children, $scope.editor.meta.blocks[selectedBlock.name].layout)) {
-              selectedBlock.layout = _.cloneDeep($scope.editor.meta.blocks[selectedBlock.name].layout);
-              $scope.node['#children'] = [{'#tag': selectedBlock.name}];
-              selectedBlock.override = false;
+            if (getBlockNode() && getBlockNode()['#tag'] in $scope.editor.meta.blocks) {
+              block.directive = getBlockNode()['#tag'];
+              block.override = false;
+            } else if (block.directive && block.override && !block.layout && children && angular.equals(children, $scope.editor.meta.blocks[block.directive].layout)) {
+              block.layout = _.cloneDeep($scope.editor.meta.blocks[block.directive].layout);
+              $scope.node['#children'] = [{'#tag': block.directive}];
+              block.override = false;
             }
-            selectedBlock.options = getBlockOptions();
           }, true);
         }
-        else {
-          $scope.layouts = {
-            'af-layout-rows': ts('Contents display as rows'),
-            'af-layout-cols': ts('Contents are evenly-spaced columns'),
-            'af-layout-inline': ts('Contents are arranged inline')
-          };
+      },
+      controller: function($scope) {
+        var container = $scope.container = this;
+        this.node = $scope.node;
 
-          $scope.getLayout = function() {
-            if (!$scope.node) {
-              return '';
-            }
-            return _.intersection(splitClass($scope.node['class']), _.keys($scope.layouts))[0] || 'af-layout-rows';
-          };
+        this.getNodeType = function(node) {
+          if (!node) {
+            return null;
+          }
+          if (node['#tag'] === 'af-field') {
+            return 'field';
+          }
+          if (node['af-fieldset']) {
+            return 'fieldset';
+          }
+          if (node['af-join']) {
+            return 'join';
+          }
+          if (node['#tag'] && node['#tag'] in $scope.editor.meta.blocks) {
+            return 'container';
+          }
+          var classes = splitClass(node['class']),
+            types = ['af-container', 'af-text', 'af-button', 'af-markup'],
+            type = _.intersection(types, classes);
+          return type.length ? type[0].replace('af-', '') : null;
+        };
+
+        this.removeElement = function(element) {
+          removeRecursive($scope.getSetChildren(), {$$hashKey: element.$$hashKey});
+        };
+
+        this.getEntityName = function() {
+          return $scope.entityName.split('-join-')[0];
+        };
+
+        // Returns the primary entity type for this container e.g. "Contact"
+        this.getMainEntityType = function() {
+          return $scope.editor && $scope.editor.getEntity(container.getEntityName()).type;
+        };
+
+        // Returns the entity type for fields within this conainer (join entity type if this is a join, else the primary entity type)
+        this.getFieldEntityType = function() {
+          var joinType = $scope.entityName.split('-join-');
+          return joinType[1] || ($scope.editor && $scope.editor.getEntity(joinType[0]).type);
+        };
 
-          $scope.setLayout = function(val) {
-            var classes = ['af-container'];
-            if (val !== 'af-layout-rows') {
-              classes.push(val);
-            }
-            modifyClasses($scope.node, _.keys($scope.layouts), classes);
-          };
-        }
       }
     };
   });
index 85b611631c7c2e4e781c65d34bd8005ce5a58081..5dbad937dcabe53a1b7f0e453216918add2ee0a1 100644 (file)
@@ -13,6 +13,6 @@
     </form>
   </div>
   <div class="panel-body">
-    <div af-gui-container="layout" entity-name="" />
+    <div ng-if="layout" af-gui-container="layout" entity-name="" />
   </div>
 </div>
index 2d0b54a38100a3fd762d039b45cb2f47d5689189..6c9b62176c37832a86caa7aa1803ee3640983e02 100644 (file)
@@ -3,7 +3,7 @@
 <li><a href ng-click="addElement('div.af-markup', {'#markup': false})">{{ ts('Add rich content') }}</a></li>
 <li><a href ng-click="addElement('button.af-button.btn-primary', {'crm-icon': 'fa-check', 'ng-click': 'afform.submit()'})">{{ ts('Add button') }}</a></li>
 <li role="separator" class="divider"></li>
-<li>
+<li ng-if="tags[node['#tag']]">
   <div class="af-gui-field-select-in-dropdown form-inline" ng-click="$event.stopPropagation()">
     {{ ts('Element:') }}
     <select class="form-control" ng-model="node['#tag']" title="{{ ts('Container type') }}">
     </select>
   </div>
 </li>
-<li ng-if="block && isRepeatable()" ng-click="$event.stopPropagation()">
+<li ng-if="isRepeatable()" ng-click="$event.stopPropagation()">
   <div class="af-gui-field-select-in-dropdown form-inline">
     <label ng-click="toggleRepeat()">
-      <i class="crm-i fa-{{ node.min === '1' && node.max === '1' ? '' : 'check-' }}square-o"></i>
+      <i class="crm-i fa-{{ node['af-repeat'] || node['af-repeat'] === '' ? 'check-' : '' }}square-o"></i>
       {{ ts('Repeat') }}
     </label>
-    <span ng-style="{visibility: node.min === '1' && node.max === '1' ? 'hidden' : 'visible'}">
+    <span ng-style="{visibility: node['af-repeat'] || node['af-repeat'] === '' ? 'visible' : 'hidden'}">
       <input type="number" class="form-control" ng-model="getSetMin" ng-model-options="{getterSetter: true}" placeholder="{{ ts('min') }}" min="0" step="1" />
-      - <input type="number" class="form-control" ng-model="getSetMax" ng-model-options="{getterSetter: true}" placeholder="{{ ts('max') }}" min="1" step="1" />
+      - <input type="number" class="form-control" ng-model="getSetMax" ng-model-options="{getterSetter: true}" placeholder="{{ ts('max') }}" min="2" step="1" />
     </span>
   </div>
 </li>
index b77dcf17d73fe314892a81e2fb60a1e2d3d10221..d620e89232346a298e35197dbedc1b09102e684e 100644 (file)
@@ -1,11 +1,11 @@
 <div class="af-gui-bar" ng-if="node['#tag'] !== 'af-form'" ng-click="selectEntity()" >
   <div class="form-inline" af-gui-menu>
     <span ng-if="container.getNodeType(node) == 'fieldset'">{{ editor.getEntity(entityName).label }}</span>
-    <span ng-if="block">{{ ts('%1 block:', {1: block}) }}</span>
+    <span ng-if="block">{{ join ? ts(join) + ':' : ts('Block:') }}</span>
     <span ng-if="!block">{{ tags[node['#tag']].toLowerCase() }}</span>
-    <select ng-if="block" ng-model="selectedBlock.name">
+    <select ng-if="block" ng-model="block.directive">
       <option value="">{{ ts('Custom') }}</option>
-      <option value="{{ option.id }}" ng-repeat="option in selectedBlock.options track by option.id">{{ option.text }}</option>
+      <option value="{{ option.id }}" ng-repeat="option in block.options track by option.id">{{ option.text }}</option>
     </select>
     <button type="button" class="btn btn-default btn-xs dropdown-toggle af-gui-add-element-button pull-right" data-toggle="dropdown" title="{{ ts('Add Element') }}">
       <span><i class="crm-i fa-plus"></i></span>
@@ -26,7 +26,7 @@
     <div ng-switch="container.getNodeType(item)">
       <div ng-switch-when="fieldset" af-gui-container="item" style="{{ item.style }}" class="af-gui-container af-gui-fieldset af-gui-container-type-{{ item['#tag'] }}" ng-class="{'af-entity-selected': isSelectedFieldset(item['af-fieldset'])}" entity-name="item['af-fieldset']" data-entity="{{ item['af-fieldset'] }}" />
       <div ng-switch-when="container" af-gui-container="item" style="{{ item.style }}" class="af-gui-container af-gui-container-type-{{ item['#tag'] }}" entity-name="entityName" data-entity="{{ entityName }}" />
-      <div ng-switch-when="block" af-gui-container="item"  style="{{ item.style }}" class="af-gui-container af-gui-block" block="item['af-block']" entity-name="entityName + '-block-' + item['af-block']" data-entity="{{ entityName + '-block-' + item['af-block'] }}" />
+      <div ng-switch-when="join" af-gui-container="item"  style="{{ item.style }}" class="af-gui-container" join="item['af-join']" entity-name="entityName + '-join-' + item['af-join']" data-entity="{{ entityName + '-join-' + item['af-join'] }}" />
       <div ng-switch-when="field" af-gui-field="item" />
       <div ng-switch-when="text" af-gui-text="item" class="af-gui-element af-gui-text" />
       <div ng-switch-when="markup" af-gui-markup="item" class="af-gui-markup" />
     </div>
   </div>
 </div>
-<div ng-if="block && !(node.min === '1' && node.max === '1')" class="af-gui-button">
+<div ng-if="node['af-repeat'] || node['af-repeat'] === ''" class="af-gui-button">
   <button class="btn btn-xs btn-primary disabled">
     <span class="crm-editable-enabled" ng-click="pickAddIcon()" >
       <i class="crm-i {{ node['add-icon'] || 'fa-plus' }}"></i>
     </span>
-    <span af-gui-editable ng-model="node['add-label']">{{ node['add-label'] }}</span>
+    <span af-gui-editable ng-model="node['af-repeat']">{{ node['af-repeat'] }}</span>
   </button>
 </div>
index 874fa119a44864bf396edf2ad202469fef753029..dcc760aa0cebcaad50a6d8a1bf8f615a73903d28 100644 (file)
@@ -23,8 +23,8 @@
       <div ng-if="blockList.length">
         <label>{{ ts('Blocks') }}</label>
         <div ui-sortable="{update: buildPaletteLists, items: '&gt; div:not(.disabled)', connectWith: '[data-entity=' + entity.name + '] &gt; [ui-sortable]'}" ng-model="blockList">
-          <div ng-repeat="block in blockList" ng-class="{disabled: blockInUse(block['af-block'])}">
-            {{ editor.meta.blocks[block['#children'][0]['#tag']].title }}
+          <div ng-repeat="block in blockList" ng-class="{disabled: blockInUse(block)}">
+            {{ blockTitles[$index] }}
           </div>
         </div>
       </div>
index 14823d2b4272d42c29d204f32d7e83b41691a853..b79d9d532facaf7d3cf94b974768d8c89e7f69c3 100644 (file)
@@ -53,12 +53,12 @@ EOHTML;
       ->setArgs([])
       ->execute()
       ->indexBy('name');
-    $this->assertEquals('Logged In', $prefill['me']['values']['first_name']);
-    $this->assertRegExp('/^User/', $prefill['me']['values']['last_name']);
+    $this->assertEquals('Logged In', $prefill['me']['values'][0]['fields']['first_name']);
+    $this->assertRegExp('/^User/', $prefill['me']['values'][0]['fields']['last_name']);
 
     $me = $prefill['me']['values'];
-    $me['first_name'] = 'Firsty';
-    $me['last_name'] = 'Lasty';
+    $me[0]['fields']['first_name'] = 'Firsty';
+    $me[0]['fields']['last_name'] = 'Lasty';
 
     Civi\Api4\Afform::submit()
       ->setName($this->formName)