Merge pull request #21228 from colemanw/afformFixTitle
authorSeamus Lee <seamuslee001@gmail.com>
Mon, 23 Aug 2021 23:44:16 +0000 (09:44 +1000)
committerGitHub <noreply@github.com>
Mon, 23 Aug 2021 23:44:16 +0000 (09:44 +1000)
Afform - fix contact source field & field defaults

39 files changed:
CRM/Contact/Page/View/Note.php
CRM/Contribute/BAO/Contribution.php
CRM/Contribute/Form/AdditionalInfo.php
CRM/Core/BAO/Note.php
CRM/Event/BAO/Participant.php
CRM/Note/Form/Note.php
api/v3/Note.php
ext/afform/admin/Civi/Api4/Action/Afform/LoadAdminData.php
ext/afform/admin/ang/afGuiEditor/afGuiEditor.component.js
ext/afform/admin/ang/afGuiEditor/afGuiEntity.component.js
ext/afform/admin/ang/afGuiEditor/elements/afGuiContainer.component.js
ext/afform/core/CRM/Afform/AfformScanner.php
ext/afform/core/CRM/Afform/Upgrader.php [new file with mode: 0644]
ext/afform/core/CRM/Afform/Upgrader/Base.php [new file with mode: 0644]
ext/afform/core/Civi/Afform/AfformMetadataInjector.php
ext/afform/core/Civi/Api4/Action/Afform/Get.php
ext/afform/core/Civi/Api4/Afform.php
ext/afform/core/Civi/Api4/Utils/AfformSaveTrait.php
ext/afform/core/ang/afblockContactAddress.aff.html [moved from ext/afform/core/ang/afjoinAddressDefault.aff.html with 100% similarity]
ext/afform/core/ang/afblockContactAddress.aff.json [new file with mode: 0644]
ext/afform/core/ang/afblockContactEmail.aff.html [moved from ext/afform/core/ang/afjoinEmailDefault.aff.html with 100% similarity]
ext/afform/core/ang/afblockContactEmail.aff.json [new file with mode: 0644]
ext/afform/core/ang/afblockContactIM.aff.html [moved from ext/afform/core/ang/afjoinIMDefault.aff.html with 100% similarity]
ext/afform/core/ang/afblockContactIM.aff.json [new file with mode: 0644]
ext/afform/core/ang/afblockContactPhone.aff.html [moved from ext/afform/core/ang/afjoinPhoneDefault.aff.html with 100% similarity]
ext/afform/core/ang/afblockContactPhone.aff.json [new file with mode: 0644]
ext/afform/core/ang/afblockContactWebsite.aff.html [moved from ext/afform/core/ang/afjoinWebsiteDefault.aff.html with 100% similarity]
ext/afform/core/ang/afblockContactWebsite.aff.json [new file with mode: 0644]
ext/afform/core/ang/afblockNameHousehold.aff.json
ext/afform/core/ang/afblockNameIndividual.aff.json
ext/afform/core/ang/afblockNameOrganization.aff.json
ext/afform/core/ang/afjoinAddressDefault.aff.json [deleted file]
ext/afform/core/ang/afjoinEmailDefault.aff.json [deleted file]
ext/afform/core/ang/afjoinIMDefault.aff.json [deleted file]
ext/afform/core/ang/afjoinPhoneDefault.aff.json [deleted file]
ext/afform/core/ang/afjoinWebsiteDefault.aff.json [deleted file]
ext/afform/mock/tests/phpunit/api/v4/AfformContactUsageTest.php
ext/afform/mock/tests/phpunit/api/v4/AfformCustomFieldUsageTest.php
tests/phpunit/api/v4/Entity/NoteTest.php [new file with mode: 0644]

index f7ffb06e7afa7d2733225b0ec73c83bf0550e8bb..64e8101128948eda129703e042c2cdb63b0b75bc 100644 (file)
  */
 class CRM_Contact_Page_View_Note extends CRM_Core_Page {
 
-  /**
-   * The action links for notes that we need to display for the browse screen
-   *
-   * @var array
-   */
-  public static $_links = NULL;
-
-  /**
-   * The action links for comments that we need to display for the browse screen
-   *
-   * @var array
-   */
-  public static $_commentLinks = NULL;
-
   /**
    * Notes found running the browse function
    * @var array
@@ -158,7 +144,7 @@ class CRM_Contact_Page_View_Note extends CRM_Core_Page {
     $session->pushUserContext($url);
 
     if (CRM_Utils_Request::retrieve('confirmed', 'Boolean')) {
-      CRM_Core_BAO_Note::del($this->_id);
+      $this->delete();
       CRM_Utils_System::redirect($url);
     }
 
@@ -233,82 +219,74 @@ class CRM_Contact_Page_View_Note extends CRM_Core_Page {
   }
 
   /**
-   * Delete the note object from the db.
+   * Delete the note object from the db and set a status msg.
    */
   public function delete() {
-    CRM_Core_BAO_Note::del($this->_id);
+    CRM_Core_BAO_Note::deleteRecord(['id' => $this->_id]);
+    $status = ts('Selected Note has been deleted successfully.');
+    CRM_Core_Session::setStatus($status, ts('Deleted'), 'success');
   }
 
   /**
    * Get action links.
    *
-   * @return array
-   *   (reference) of action links
+   * @return array[]
    */
-  public static function &links() {
-    if (!(self::$_links)) {
-      $deleteExtra = ts('Are you sure you want to delete this note?');
-
-      self::$_links = [
-        CRM_Core_Action::VIEW => [
-          'name' => ts('View'),
-          'url' => 'civicrm/contact/view/note',
-          'qs' => 'action=view&reset=1&cid=%%cid%%&id=%%id%%&selectedChild=note',
-          'title' => ts('View Note'),
-        ],
-        CRM_Core_Action::UPDATE => [
-          'name' => ts('Edit'),
-          'url' => 'civicrm/contact/view/note',
-          'qs' => 'action=update&reset=1&cid=%%cid%%&id=%%id%%&selectedChild=note',
-          'title' => ts('Edit Note'),
-        ],
-        CRM_Core_Action::ADD => [
-          'name' => ts('Comment'),
-          'url' => 'civicrm/contact/view/note',
-          'qs' => 'action=add&reset=1&cid=%%cid%%&parentId=%%id%%&selectedChild=note',
-          'title' => ts('Add Comment'),
-        ],
-        CRM_Core_Action::DELETE => [
-          'name' => ts('Delete'),
-          'url' => 'civicrm/contact/view/note',
-          'qs' => 'action=delete&reset=1&cid=%%cid%%&id=%%id%%&selectedChild=note',
-          'title' => ts('Delete Note'),
-        ],
-      ];
-    }
-    return self::$_links;
+  public static function links() {
+    return [
+      CRM_Core_Action::VIEW => [
+        'name' => ts('View'),
+        'url' => 'civicrm/contact/view/note',
+        'qs' => 'action=view&reset=1&cid=%%cid%%&id=%%id%%&selectedChild=note',
+        'title' => ts('View Note'),
+      ],
+      CRM_Core_Action::UPDATE => [
+        'name' => ts('Edit'),
+        'url' => 'civicrm/contact/view/note',
+        'qs' => 'action=update&reset=1&cid=%%cid%%&id=%%id%%&selectedChild=note',
+        'title' => ts('Edit Note'),
+      ],
+      CRM_Core_Action::ADD => [
+        'name' => ts('Comment'),
+        'url' => 'civicrm/contact/view/note',
+        'qs' => 'action=add&reset=1&cid=%%cid%%&parentId=%%id%%&selectedChild=note',
+        'title' => ts('Add Comment'),
+      ],
+      CRM_Core_Action::DELETE => [
+        'name' => ts('Delete'),
+        'url' => 'civicrm/contact/view/note',
+        'qs' => 'action=delete&reset=1&cid=%%cid%%&id=%%id%%&selectedChild=note',
+        'title' => ts('Delete Note'),
+      ],
+    ];
   }
 
   /**
    * Get action links for comments.
    *
-   * @return array
-   *   (reference) of action links
+   * @return array[]
    */
-  public static function &commentLinks() {
-    if (!(self::$_commentLinks)) {
-      self::$_commentLinks = [
-        CRM_Core_Action::VIEW => [
-          'name' => ts('View'),
-          'url' => 'civicrm/contact/view/note',
-          'qs' => 'action=view&reset=1&cid=%%cid%%&id={id}&selectedChild=note',
-          'title' => ts('View Comment'),
-        ],
-        CRM_Core_Action::UPDATE => [
-          'name' => ts('Edit'),
-          'url' => 'civicrm/contact/view/note',
-          'qs' => 'action=update&reset=1&cid=%%cid%%&id={id}&parentId=%%pid%%&selectedChild=note',
-          'title' => ts('Edit Comment'),
-        ],
-        CRM_Core_Action::DELETE => [
-          'name' => ts('Delete'),
-          'url' => 'civicrm/contact/view/note',
-          'qs' => 'action=delete&reset=1&cid=%%cid%%&id={id}&selectedChild=note',
-          'title' => ts('Delete Comment'),
-        ],
-      ];
-    }
-    return self::$_commentLinks;
+  public static function commentLinks() {
+    return [
+      CRM_Core_Action::VIEW => [
+        'name' => ts('View'),
+        'url' => 'civicrm/contact/view/note',
+        'qs' => 'action=view&reset=1&cid=%%cid%%&id={id}&selectedChild=note',
+        'title' => ts('View Comment'),
+      ],
+      CRM_Core_Action::UPDATE => [
+        'name' => ts('Edit'),
+        'url' => 'civicrm/contact/view/note',
+        'qs' => 'action=update&reset=1&cid=%%cid%%&id={id}&parentId=%%pid%%&selectedChild=note',
+        'title' => ts('Edit Comment'),
+      ],
+      CRM_Core_Action::DELETE => [
+        'name' => ts('Delete'),
+        'url' => 'civicrm/contact/view/note',
+        'qs' => 'action=delete&reset=1&cid=%%cid%%&id={id}&selectedChild=note',
+        'title' => ts('Delete Comment'),
+      ],
+    ];
   }
 
 }
index 840878bc6cf020c5729ea31c5942a3d764382296..b420f885a947ea580ef00a54c77d088f8d549d2d 100644 (file)
@@ -1518,7 +1518,7 @@ INNER JOIN  civicrm_contact contact ON ( contact.id = c.contact_id )
     $note = CRM_Core_BAO_Note::getNote($id, 'civicrm_contribution');
     $noteId = key($note);
     if ($noteId) {
-      CRM_Core_BAO_Note::del($noteId, FALSE);
+      CRM_Core_BAO_Note::deleteRecord(['id' => $noteId]);
     }
 
     $dao = new CRM_Contribute_DAO_Contribution();
index fdbaaade148f3b6c8a83f8fb3e238bde86f48af6..c1ce9bfd7e736b8d7905945cf4a7bd6d8842107a 100644 (file)
@@ -231,7 +231,9 @@ class CRM_Contribute_Form_AdditionalInfo {
    */
   public static function processNote($params, $contactID, $contributionID, $contributionNoteID = NULL) {
     if (CRM_Utils_System::isNull($params['note']) && $contributionNoteID) {
-      CRM_Core_BAO_Note::del($contributionNoteID);
+      CRM_Core_BAO_Note::deleteRecord(['id' => $contributionNoteID]);
+      $status = ts('Selected Note has been deleted successfully.');
+      CRM_Core_Session::setStatus($status, ts('Deleted'), 'success');
       return;
     }
     //process note
index 85cc99bd2b3a6666a42b870b8365db0fed649308..3f6080a7a682568e344b20b814470ed3dbf9a84b 100644 (file)
@@ -18,7 +18,7 @@
 /**
  * BAO object for crm_note table.
  */
-class CRM_Core_BAO_Note extends CRM_Core_DAO_Note {
+class CRM_Core_BAO_Note extends CRM_Core_DAO_Note implements \Civi\Test\HookInterface {
   use CRM_Core_DynamicFKAccessTrait;
 
   /**
@@ -270,53 +270,34 @@ class CRM_Core_BAO_Note extends CRM_Core_DAO_Note {
     return $notes;
   }
 
+  /**
+   * Event fired prior to modifying a Note.
+   * @param \Civi\Core\Event\PreEvent $event
+   */
+  public static function self_hook_civicrm_pre(\Civi\Core\Event\PreEvent $event) {
+    if ($event->action === 'delete' && $event->id) {
+      // When deleting a note, also delete child notes
+      // This causes recursion as this hook is called again while deleting child notes,
+      // So the children of children, etc. will also be deleted.
+      foreach (self::getDescendentIds($event->id) as $child) {
+        self::deleteRecord(['id' => $child]);
+      }
+    }
+  }
+
   /**
    * Delete the notes.
    *
    * @param int $id
-   *   Note id.
-   * @param bool $showStatus
-   *   Do we need to set status or not.
    *
-   * @return int|null
-   *   no of deleted notes on success, null otherwise
+   * @deprecated
+   * @return int
    */
-  public static function del($id, $showStatus = TRUE) {
-    $return = NULL;
-    $recent = array($id);
-    $note = new CRM_Core_DAO_Note();
-    $note->id = $id;
-    $note->find();
-    $note->fetch();
-    if ($note->entity_table == 'civicrm_note') {
-      $status = ts('Selected Comment has been deleted successfully.');
-    }
-    else {
-      $status = ts('Selected Note has been deleted successfully.');
-    }
-
-    // Delete all descendents of this Note
-    foreach (self::getDescendentIds($id) as $childId) {
-      $childNote = new CRM_Core_DAO_Note();
-      $childNote->id = $childId;
-      $childNote->delete();
-      $recent[] = $childId;
-    }
-
-    $return = $note->delete();
-    if ($showStatus) {
-      CRM_Core_Session::setStatus($status, ts('Deleted'), 'success');
-    }
+  public static function del($id) {
+    // CRM_Core_Error::deprecatedFunctionWarning('deleteRecord');
+    self::deleteRecord(['id' => $id]);
 
-    // delete the recently created Note
-    foreach ($recent as $recentId) {
-      $noteRecent = array(
-        'id' => $recentId,
-        'type' => 'Note',
-      );
-      CRM_Utils_Recent::del($noteRecent);
-    }
-    return $return;
+    return 1;
   }
 
   /**
@@ -509,26 +490,21 @@ ORDER BY  modified_date desc";
   }
 
   /**
-   * Given a note id, get a list of the ids of all notes that are descendents of that note
+   * Get direct children of given parentId note
    *
    * @param int $parentId
-   *   Id of the given note.
-   * @param array $ids
-   *   (reference) one-dimensional array to store found descendent ids.
    *
    * @return array
-   *   One-dimensional array containing ids of all desendent notes
+   *   One-dimensional array containing ids of child notes
    */
-  public static function getDescendentIds($parentId, &$ids = []) {
-    // get direct children of given parentId note
+  public static function getDescendentIds($parentId) {
+    $ids = [];
     $note = new CRM_Core_DAO_Note();
     $note->entity_table = 'civicrm_note';
     $note->entity_id = $parentId;
     $note->find();
     while ($note->fetch()) {
-      // foreach child, add to ids list, and recurse
       $ids[] = $note->id;
-      self::getDescendentIds($note->id, $ids);
     }
     return $ids;
   }
@@ -561,7 +537,7 @@ WHERE participant.contact_id = %1 AND  note.entity_table = 'civicrm_participant'
 
     $contactNoteId = CRM_Core_DAO::executeQuery($contactQuery, $params);
     while ($contactNoteId->fetch()) {
-      self::del($contactNoteId->id, FALSE);
+      self::deleteRecord(['id' => $contactNoteId->id]);
     }
   }
 
index 4d73cf25660d475746d7c3b4f47b1a840f96dd73..5edcb9ea7a71bfa2d1b00154c81c6ba384e4a26d 100644 (file)
@@ -242,7 +242,7 @@ class CRM_Event_BAO_Participant extends CRM_Event_DAO_Participant {
         CRM_Core_BAO_Note::add($noteParams, $noteIDs);
       }
       elseif ($noteId && $hasNoteField) {
-        CRM_Core_BAO_Note::del($noteId, FALSE);
+        CRM_Core_BAO_Note::deleteRecord(['id' => $noteId]);
       }
     }
 
@@ -867,7 +867,7 @@ WHERE  civicrm_participant.id = {$participantId}
     $note = CRM_Core_BAO_Note::getNote($id, 'civicrm_participant');
     $noteId = key($note);
     if ($noteId) {
-      CRM_Core_BAO_Note::del($noteId, FALSE);
+      CRM_Core_BAO_Note::deleteRecord(['id' => $noteId]);
     }
 
     $participant->delete();
index 2104fd32459d42232ee1f2b30f9c55a12fbac19d..c0ec7fe0d53304778b55fd0007b8863f39cb647a 100644 (file)
@@ -173,7 +173,9 @@ class CRM_Note_Form_Note extends CRM_Core_Form {
     }
 
     if ($this->_action & CRM_Core_Action::DELETE) {
-      CRM_Core_BAO_Note::del($this->_id);
+      CRM_Core_BAO_Note::deleteRecord(['id' => $this->_id]);
+      $status = ts('Selected Note has been deleted successfully.');
+      CRM_Core_Session::setStatus($status, ts('Deleted'), 'success');
       return;
     }
 
index a361620c943e67166e83b85b5c5af1711471f885..e1866d1196c34ea9e22424675d3a1fd803a7b838 100644 (file)
@@ -57,8 +57,8 @@ function _civicrm_api3_note_create_spec(&$params) {
  * @return array
  */
 function civicrm_api3_note_delete($params) {
-  $result = new CRM_Core_BAO_Note();
-  return $result->del($params['id']) ? civicrm_api3_create_success() : civicrm_api3_create_error('Error while deleting Note');
+  $result = CRM_Core_BAO_Note::deleteRecord($params);
+  return $result ? civicrm_api3_create_success() : civicrm_api3_create_error('Error while deleting Note');
 }
 
 /**
index 6caa45095252fdbcfc471f6d5d562101e2b9a7ce..fa2c2f535a8c7e0d050c303b743ac3d4b9631c3c 100644 (file)
@@ -62,7 +62,7 @@ class LoadAdminData extends \Civi\Api4\Generic\AbstractAction {
         case 'block':
           $info['definition'] = $this->definition + [
             'title' => '',
-            'block' => $this->entity,
+            'entity_type' => $this->entity,
             'layout' => [],
           ];
           break;
@@ -123,8 +123,8 @@ class LoadAdminData extends \Civi\Api4\Generic\AbstractAction {
           if ($embeddedForm['type'] === 'block') {
             $info['blocks'][$blockTag] = $embeddedForm;
           }
-          if (!empty($embeddedForm['join'])) {
-            $entities = array_unique(array_merge($entities, [$embeddedForm['join']]));
+          if (!empty($embeddedForm['join_entity'])) {
+            $entities = array_unique(array_merge($entities, [$embeddedForm['join_entity']]));
           }
           $scanBlocks($embeddedForm['layout']);
         }
@@ -152,7 +152,7 @@ class LoadAdminData extends \Civi\Api4\Generic\AbstractAction {
     }
 
     if ($info['definition']['type'] === 'block') {
-      $blockEntity = $info['definition']['join'] ?? $info['definition']['block'];
+      $blockEntity = $info['definition']['join_entity'] ?? $info['definition']['entity_type'];
       if ($blockEntity !== '*') {
         $entities[] = $blockEntity;
       }
@@ -191,7 +191,7 @@ class LoadAdminData extends \Civi\Api4\Generic\AbstractAction {
       if (!$newForm) {
         $scanBlocks($info['definition']['layout']);
       }
-      $this->loadAvailableBlocks($entities, $info, [['join', 'IS NULL']]);
+      $this->loadAvailableBlocks($entities, $info, [['join_entity', 'IS NULL']]);
     }
 
     // Optimization - since contact fields are a combination of these three,
@@ -237,10 +237,10 @@ class LoadAdminData extends \Civi\Api4\Generic\AbstractAction {
     }
     if ($entities) {
       $blockInfo = Afform::get($this->checkPermissions)
-        ->addSelect('name', 'title', 'block', 'join', 'directive_name', 'repeat')
+        ->addSelect('name', 'title', 'entity_type', 'join_entity', 'directive_name', 'repeat')
         ->setWhere($where)
         ->addWhere('type', '=', 'block')
-        ->addWhere('block', 'IN', $entities)
+        ->addWhere('entity_type', 'IN', $entities)
         ->addWhere('directive_name', 'NOT IN', array_keys($info['blocks']))
         ->execute();
       $info['blocks'] = array_merge(array_values($info['blocks']), (array) $blockInfo);
index 275b2827e6d095c68dba906e62a92d0076fef6d3..a44f6eeb81f4497dc14ce655781dbf117859ece7 100644 (file)
@@ -75,7 +75,7 @@
 
         else if (editor.getFormType() === 'block') {
           editor.layout['#children'] = editor.afform.layout;
-          editor.blockEntity = editor.afform.join || editor.afform.block;
+          editor.blockEntity = editor.afform.join_entity || editor.afform.entity_type;
           $scope.entities[editor.blockEntity] = backfillEntityDefaults({
             type: editor.blockEntity,
             name: editor.blockEntity,
index 6786029542b0d100031e72e5a6e340fc5b116a11..2e6b72c3c5e8de183b6f34fb258b11360b467596 100644 (file)
         $scope.blockTitles.length = 0;
         _.each(afGui.meta.blocks, function(block, directive) {
           if ((!search || _.contains(directive, search) || _.contains(block.name.toLowerCase(), search) || _.contains(block.title.toLowerCase(), search)) &&
-            (block.block === '*' || block.block === ctrl.entity.type || (ctrl.entity.type === 'Contact' && block.block === ctrl.entity.data.contact_type)) &&
+            (block.entity_type === '*' || block.entity_type === ctrl.entity.type || (ctrl.entity.type === 'Contact' && block.entity_type === ctrl.entity.data.contact_type)) &&
             block.name !== ctrl.editor.getAfform().name
           ) {
-            var item = {"#tag": block.join ? "div" : directive};
-            if (block.join) {
-              item['af-join'] = block.join;
+            var item = {"#tag": block.join_entity ? "div" : directive};
+            if (block.join_entity) {
+              item['af-join'] = block.join_entity;
               item['#children'] = [{"#tag": directive}];
             }
             if (block.repeat) {
index 3cc79a086d6a2086a0174467a34913473fd974aa..539d6b96c2e8165a0d0e61af8c2ca69e9472bcb2 100644 (file)
         };
 
         _.each(afGui.meta.blocks, function(blockInfo, directive) {
-          if (directive === ctrl.node['#tag'] || (blockInfo.join && blockInfo.join === ctrl.getFieldEntityType())) {
+          if (directive === ctrl.node['#tag'] || (blockInfo.join_entity && blockInfo.join_entity === ctrl.getFieldEntityType())) {
             block.options.push({
               id: directive,
               text: blockInfo.title
index c7e30aa4d88e76e7b9d81a0735ad4cc96aa91a21..f7a7125f4beef75901dc525ee87d41a9aa5535f8 100644 (file)
@@ -231,7 +231,7 @@ class CRM_Afform_AfformScanner {
    * @return mixed|string
    *   Ex: '/var/www/sites/default/files/civicrm/afform'.
    */
-  private function getSiteLocalPath() {
+  public function getSiteLocalPath() {
     // TODO Allow a setting override.
     // return Civi::paths()->getPath(Civi::settings()->get('afformPath'));
     return Civi::paths()->getPath('[civicrm.files]/ang');
diff --git a/ext/afform/core/CRM/Afform/Upgrader.php b/ext/afform/core/CRM/Afform/Upgrader.php
new file mode 100644 (file)
index 0000000..2786478
--- /dev/null
@@ -0,0 +1,57 @@
+<?php
+use CRM_Afform_ExtensionUtil as E;
+
+/**
+ * Collection of upgrade steps.
+ */
+class CRM_Afform_Upgrader extends CRM_Afform_Upgrader_Base {
+
+  /**
+   * Update names of blocks and joins
+   *
+   * @return TRUE on success
+   * @throws Exception
+   */
+  public function upgrade_1000() {
+    $this->ctx->log->info('Applying update 1000');
+    $scanner = new CRM_Afform_AfformScanner();
+    $localDir = $scanner->getSiteLocalPath();
+
+    // Update form markup with new block directive names
+    $replacements = [
+      'afjoin-address-default>' => 'afblock-contact-address>',
+      'afjoin-email-default>' => 'afblock-contact-email>',
+      'afjoin-i-m-default>' => 'afblock-contact-i-m>',
+      'afjoin-phone-default>' => 'afblock-contact-phone>',
+      'afjoin-website-default>' => 'afblock-contact-website>',
+      'afjoin-custom-' => 'afblock-custom-',
+    ];
+    foreach (glob("$localDir/*." . $scanner::LAYOUT_FILE) as $fileName) {
+      $html = file_get_contents($fileName);
+      $html = str_replace(array_keys($replacements), array_values($replacements), $html);
+      file_put_contents($fileName, $html);
+    }
+
+    // Update form metadata with new block property names
+    $replacements = [
+      'join' => 'join_entity',
+      'block' => 'entity_type',
+    ];
+    foreach (glob("$localDir/*." . $scanner::METADATA_FILE) as $fileName) {
+      $meta = json_decode(file_get_contents($fileName), TRUE);
+      foreach ($replacements as $oldKey => $newKey) {
+        if (isset($meta[$oldKey])) {
+          $meta[$newKey] = $meta[$oldKey];
+          unset($meta[$oldKey]);
+        }
+      }
+      if (!empty($meta['entity_type'])) {
+        $meta['type'] = 'block';
+      }
+      file_put_contents($fileName, json_encode($meta, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
+    }
+
+    return TRUE;
+  }
+
+}
diff --git a/ext/afform/core/CRM/Afform/Upgrader/Base.php b/ext/afform/core/CRM/Afform/Upgrader/Base.php
new file mode 100644 (file)
index 0000000..207520d
--- /dev/null
@@ -0,0 +1,396 @@
+<?php
+
+// AUTO-GENERATED FILE -- Civix may overwrite any changes made to this file
+use CRM_Afform_ExtensionUtil as E;
+
+/**
+ * Base class which provides helpers to execute upgrade logic
+ */
+class CRM_Afform_Upgrader_Base {
+
+  /**
+   * @var CRM_Afform_Upgrader_Base
+   */
+  public static $instance;
+
+  /**
+   * @var CRM_Queue_TaskContext
+   */
+  protected $ctx;
+
+  /**
+   * @var string
+   *   eg 'com.example.myextension'
+   */
+  protected $extensionName;
+
+  /**
+   * @var string
+   *   full path to the extension's source tree
+   */
+  protected $extensionDir;
+
+  /**
+   * @var array
+   *   sorted numerically
+   */
+  private $revisions;
+
+  /**
+   * @var bool
+   *   Flag to clean up extension revision data in civicrm_setting
+   */
+  private $revisionStorageIsDeprecated = FALSE;
+
+  /**
+   * Obtain a reference to the active upgrade handler.
+   */
+  public static function instance() {
+    if (!self::$instance) {
+      self::$instance = new CRM_Afform_Upgrader(
+        'org.civicrm.afform',
+        E::path()
+      );
+    }
+    return self::$instance;
+  }
+
+  /**
+   * Adapter that lets you add normal (non-static) member functions to the queue.
+   *
+   * Note: Each upgrader instance should only be associated with one
+   * task-context; otherwise, this will be non-reentrant.
+   *
+   * ```
+   * CRM_Afform_Upgrader_Base::_queueAdapter($ctx, 'methodName', 'arg1', 'arg2');
+   * ```
+   */
+  public static function _queueAdapter() {
+    $instance = self::instance();
+    $args = func_get_args();
+    $instance->ctx = array_shift($args);
+    $instance->queue = $instance->ctx->queue;
+    $method = array_shift($args);
+    return call_user_func_array([$instance, $method], $args);
+  }
+
+  /**
+   * CRM_Afform_Upgrader_Base constructor.
+   *
+   * @param $extensionName
+   * @param $extensionDir
+   */
+  public function __construct($extensionName, $extensionDir) {
+    $this->extensionName = $extensionName;
+    $this->extensionDir = $extensionDir;
+  }
+
+  // ******** Task helpers ********
+
+  /**
+   * Run a CustomData file.
+   *
+   * @param string $relativePath
+   *   the CustomData XML file path (relative to this extension's dir)
+   * @return bool
+   */
+  public function executeCustomDataFile($relativePath) {
+    $xml_file = $this->extensionDir . '/' . $relativePath;
+    return $this->executeCustomDataFileByAbsPath($xml_file);
+  }
+
+  /**
+   * Run a CustomData file
+   *
+   * @param string $xml_file
+   *   the CustomData XML file path (absolute path)
+   *
+   * @return bool
+   */
+  protected function executeCustomDataFileByAbsPath($xml_file) {
+    $import = new CRM_Utils_Migrate_Import();
+    $import->run($xml_file);
+    return TRUE;
+  }
+
+  /**
+   * Run a SQL file.
+   *
+   * @param string $relativePath
+   *   the SQL file path (relative to this extension's dir)
+   *
+   * @return bool
+   */
+  public function executeSqlFile($relativePath) {
+    CRM_Utils_File::sourceSQLFile(
+      CIVICRM_DSN,
+      $this->extensionDir . DIRECTORY_SEPARATOR . $relativePath
+    );
+    return TRUE;
+  }
+
+  /**
+   * Run the sql commands in the specified file.
+   *
+   * @param string $tplFile
+   *   The SQL file path (relative to this extension's dir).
+   *   Ex: "sql/mydata.mysql.tpl".
+   *
+   * @return bool
+   * @throws \CRM_Core_Exception
+   */
+  public function executeSqlTemplate($tplFile) {
+    // Assign multilingual variable to Smarty.
+    $upgrade = new CRM_Upgrade_Form();
+
+    $tplFile = CRM_Utils_File::isAbsolute($tplFile) ? $tplFile : $this->extensionDir . DIRECTORY_SEPARATOR . $tplFile;
+    $smarty = CRM_Core_Smarty::singleton();
+    $smarty->assign('domainID', CRM_Core_Config::domainID());
+    CRM_Utils_File::sourceSQLFile(
+      CIVICRM_DSN, $smarty->fetch($tplFile), NULL, TRUE
+    );
+    return TRUE;
+  }
+
+  /**
+   * Run one SQL query.
+   *
+   * This is just a wrapper for CRM_Core_DAO::executeSql, but it
+   * provides syntactic sugar for queueing several tasks that
+   * run different queries
+   *
+   * @return bool
+   */
+  public function executeSql($query, $params = []) {
+    // FIXME verify that we raise an exception on error
+    CRM_Core_DAO::executeQuery($query, $params);
+    return TRUE;
+  }
+
+  /**
+   * Syntactic sugar for enqueuing a task which calls a function in this class.
+   *
+   * The task is weighted so that it is processed
+   * as part of the currently-pending revision.
+   *
+   * After passing the $funcName, you can also pass parameters that will go to
+   * the function. Note that all params must be serializable.
+   */
+  public function addTask($title) {
+    $args = func_get_args();
+    $title = array_shift($args);
+    $task = new CRM_Queue_Task(
+      [get_class($this), '_queueAdapter'],
+      $args,
+      $title
+    );
+    return $this->queue->createItem($task, ['weight' => -1]);
+  }
+
+  // ******** Revision-tracking helpers ********
+
+  /**
+   * Determine if there are any pending revisions.
+   *
+   * @return bool
+   */
+  public function hasPendingRevisions() {
+    $revisions = $this->getRevisions();
+    $currentRevision = $this->getCurrentRevision();
+
+    if (empty($revisions)) {
+      return FALSE;
+    }
+    if (empty($currentRevision)) {
+      return TRUE;
+    }
+
+    return ($currentRevision < max($revisions));
+  }
+
+  /**
+   * Add any pending revisions to the queue.
+   *
+   * @param CRM_Queue_Queue $queue
+   */
+  public function enqueuePendingRevisions(CRM_Queue_Queue $queue) {
+    $this->queue = $queue;
+
+    $currentRevision = $this->getCurrentRevision();
+    foreach ($this->getRevisions() as $revision) {
+      if ($revision > $currentRevision) {
+        $title = E::ts('Upgrade %1 to revision %2', [
+          1 => $this->extensionName,
+          2 => $revision,
+        ]);
+
+        // note: don't use addTask() because it sets weight=-1
+
+        $task = new CRM_Queue_Task(
+          [get_class($this), '_queueAdapter'],
+          ['upgrade_' . $revision],
+          $title
+        );
+        $this->queue->createItem($task);
+
+        $task = new CRM_Queue_Task(
+          [get_class($this), '_queueAdapter'],
+          ['setCurrentRevision', $revision],
+          $title
+        );
+        $this->queue->createItem($task);
+      }
+    }
+  }
+
+  /**
+   * Get a list of revisions.
+   *
+   * @return array
+   *   revisionNumbers sorted numerically
+   */
+  public function getRevisions() {
+    if (!is_array($this->revisions)) {
+      $this->revisions = [];
+
+      $clazz = new ReflectionClass(get_class($this));
+      $methods = $clazz->getMethods();
+      foreach ($methods as $method) {
+        if (preg_match('/^upgrade_(.*)/', $method->name, $matches)) {
+          $this->revisions[] = $matches[1];
+        }
+      }
+      sort($this->revisions, SORT_NUMERIC);
+    }
+
+    return $this->revisions;
+  }
+
+  public function getCurrentRevision() {
+    $revision = CRM_Core_BAO_Extension::getSchemaVersion($this->extensionName);
+    if (!$revision) {
+      $revision = $this->getCurrentRevisionDeprecated();
+    }
+    return $revision;
+  }
+
+  private function getCurrentRevisionDeprecated() {
+    $key = $this->extensionName . ':version';
+    if ($revision = \Civi::settings()->get($key)) {
+      $this->revisionStorageIsDeprecated = TRUE;
+    }
+    return $revision;
+  }
+
+  public function setCurrentRevision($revision) {
+    CRM_Core_BAO_Extension::setSchemaVersion($this->extensionName, $revision);
+    // clean up legacy schema version store (CRM-19252)
+    $this->deleteDeprecatedRevision();
+    return TRUE;
+  }
+
+  private function deleteDeprecatedRevision() {
+    if ($this->revisionStorageIsDeprecated) {
+      $setting = new CRM_Core_BAO_Setting();
+      $setting->name = $this->extensionName . ':version';
+      $setting->delete();
+      CRM_Core_Error::debug_log_message("Migrated extension schema revision ID for {$this->extensionName} from civicrm_setting (deprecated) to civicrm_extension.\n");
+    }
+  }
+
+  // ******** Hook delegates ********
+
+  /**
+   * @see https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_install
+   */
+  public function onInstall() {
+    $files = glob($this->extensionDir . '/sql/*_install.sql');
+    if (is_array($files)) {
+      foreach ($files as $file) {
+        CRM_Utils_File::sourceSQLFile(CIVICRM_DSN, $file);
+      }
+    }
+    $files = glob($this->extensionDir . '/sql/*_install.mysql.tpl');
+    if (is_array($files)) {
+      foreach ($files as $file) {
+        $this->executeSqlTemplate($file);
+      }
+    }
+    $files = glob($this->extensionDir . '/xml/*_install.xml');
+    if (is_array($files)) {
+      foreach ($files as $file) {
+        $this->executeCustomDataFileByAbsPath($file);
+      }
+    }
+    if (is_callable([$this, 'install'])) {
+      $this->install();
+    }
+  }
+
+  /**
+   * @see https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_postInstall
+   */
+  public function onPostInstall() {
+    $revisions = $this->getRevisions();
+    if (!empty($revisions)) {
+      $this->setCurrentRevision(max($revisions));
+    }
+    if (is_callable([$this, 'postInstall'])) {
+      $this->postInstall();
+    }
+  }
+
+  /**
+   * @see https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_uninstall
+   */
+  public function onUninstall() {
+    $files = glob($this->extensionDir . '/sql/*_uninstall.mysql.tpl');
+    if (is_array($files)) {
+      foreach ($files as $file) {
+        $this->executeSqlTemplate($file);
+      }
+    }
+    if (is_callable([$this, 'uninstall'])) {
+      $this->uninstall();
+    }
+    $files = glob($this->extensionDir . '/sql/*_uninstall.sql');
+    if (is_array($files)) {
+      foreach ($files as $file) {
+        CRM_Utils_File::sourceSQLFile(CIVICRM_DSN, $file);
+      }
+    }
+  }
+
+  /**
+   * @see https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_enable
+   */
+  public function onEnable() {
+    // stub for possible future use
+    if (is_callable([$this, 'enable'])) {
+      $this->enable();
+    }
+  }
+
+  /**
+   * @see https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_disable
+   */
+  public function onDisable() {
+    // stub for possible future use
+    if (is_callable([$this, 'disable'])) {
+      $this->disable();
+    }
+  }
+
+  public function onUpgrade($op, CRM_Queue_Queue $queue = NULL) {
+    switch ($op) {
+      case 'check':
+        return [$this->hasPendingRevisions()];
+
+      case 'enqueue':
+        return $this->enqueuePendingRevisions($queue);
+
+      default:
+    }
+  }
+
+}
index 418e6cb8abbfe904bb5b64f2227b2b01e5b2ab21..fca28b8694e64061d16fb2fb11237abffc72ec2c 100644 (file)
@@ -28,12 +28,12 @@ class AfformMetadataInjector {
       ->alterHtml(';\\.aff\\.html$;', function($doc, $path) {
         try {
           $module = \Civi::service('angular')->getModule(basename($path, '.aff.html'));
-          $meta = \Civi\Api4\Afform::get(FALSE)->addWhere('name', '=', $module['_afform'])->setSelect(['join', 'block'])->execute()->first();
+          $meta = \Civi\Api4\Afform::get(FALSE)->addWhere('name', '=', $module['_afform'])->setSelect(['join_entity', 'entity_type'])->execute()->first();
         }
         catch (\Exception $e) {
         }
 
-        $blockEntity = $meta['join'] ?? $meta['block'] ?? NULL;
+        $blockEntity = $meta['join_entity'] ?? $meta['entity_type'] ?? NULL;
         if (!$blockEntity) {
           $entities = self::getFormEntities($doc);
         }
index 277eaa197469676723497edad2a7f4015bec014a..2d4fa6e86a1ddc9b7672881f94390dc08634ac09 100644 (file)
@@ -43,7 +43,7 @@ class Get extends \Civi\Api4\Generic\BasicGetAction {
       ];
       foreach ($toGet as $key => $get) {
         if (!in_array($info[$key], $get)) {
-          continue;
+          continue 2;
         }
       }
       $record = $scanner->getMeta($name);
@@ -90,14 +90,14 @@ class Get extends \Civi\Api4\Generic\BasicGetAction {
   protected function getAutoGenerated(&$names, $toGet, $getLayout) {
     $values = $groupNames = [];
     foreach ($toGet['name'] ?? [] as $name) {
-      if (strpos($name, 'afjoinCustom_') === 0 && strlen($name) > 13) {
-        $groupNames[] = substr($name, 13);
+      if (strpos($name, 'afblockCustom_') === 0 && strlen($name) > 13) {
+        $groupNames[] = substr($name, 14);
       }
     }
     // Early return if this api call is fetching afforms by name and those names are not custom-related
     if ((!empty($toGet['name']) && !$groupNames)
-      || (!empty($toGet['module_name']) && !strstr(implode(' ', $toGet['module_name']), 'afjoinCustom'))
-      || (!empty($toGet['directive_name']) && !strstr(implode(' ', $toGet['directive_name']), 'afjoin-custom'))
+      || (!empty($toGet['module_name']) && !strstr(implode(' ', $toGet['module_name']), 'afblockCustom'))
+      || (!empty($toGet['directive_name']) && !strstr(implode(' ', $toGet['directive_name']), 'afblock-custom'))
     ) {
       return $values;
     }
@@ -119,7 +119,7 @@ class Get extends \Civi\Api4\Generic\BasicGetAction {
       );
     }
     foreach ($customApi->execute() as $custom) {
-      $name = 'afjoinCustom_' . $custom['name'];
+      $name = 'afblockCustom_' . $custom['name'];
       if (!in_array($name, $names)) {
         $names[] = $name;
       }
@@ -127,14 +127,14 @@ class Get extends \Civi\Api4\Generic\BasicGetAction {
         'name' => $name,
         'type' => 'block',
         'requires' => [],
-        'title' => E::ts('%1 block (default)', [1 => $custom['title']]),
+        'title' => E::ts('%1 block', [1 => $custom['title']]),
         'description' => '',
         'is_dashlet' => FALSE,
         'is_public' => FALSE,
         'is_token' => FALSE,
         'permission' => 'access CiviCRM',
-        'join' => 'Custom_' . $custom['name'],
-        'block' => $custom['extends'],
+        'join_entity' => 'Custom_' . $custom['name'],
+        'entity_type' => $custom['extends'],
         'repeat' => $custom['max_multiple'] ?: TRUE,
         'has_base' => TRUE,
       ];
index f0450a091b80f13ec36a9b44eaa79dca270ae60b..6c6f435f08d7355611ae464d4a4b862f28a2d238 100644 (file)
@@ -135,10 +135,12 @@ class Afform extends Generic\AbstractEntity {
           'data_type' => 'Array',
         ],
         [
-          'name' => 'block',
+          'name' => 'entity_type',
+          'description' => 'Block used for this entity type',
         ],
         [
-          'name' => 'join',
+          'name' => 'join_entity',
+          'description' => 'Used for blocks that join a sub-entity (e.g. Emails for a Contact)',
         ],
         [
           'name' => 'title',
@@ -183,6 +185,7 @@ class Afform extends Generic\AbstractEntity {
         [
           'name' => 'layout',
           'data_type' => 'Array',
+          'description' => 'HTML form layout; format is controlled by layoutFormat param',
         ],
       ];
       // Calculated fields returned by get action
index bee09ea6261107bb4b1a1428fcd0cbc30159c9f8..2f4099460a7eb06ce85f3ed895391f801b8aa2b3 100644 (file)
@@ -16,7 +16,7 @@ trait AfformSaveTrait {
 
     // If no name given, create a unique name based on the title
     if (empty($item['name'])) {
-      $prefix = !empty($item['join']) ? "afjoin-{$item['join']}" : (!empty($item['block']) ? ('afblock-' . str_replace('*', 'all', $item['block'])) : 'afform');
+      $prefix = 'af' . ($item['type'] ?? '');
       $item['name'] = _afform_angular_module_name($prefix . '-' . \CRM_Utils_String::munge($item['title'], '-'));
       $suffix = '';
       while (
diff --git a/ext/afform/core/ang/afblockContactAddress.aff.json b/ext/afform/core/ang/afblockContactAddress.aff.json
new file mode 100644 (file)
index 0000000..41a2849
--- /dev/null
@@ -0,0 +1,7 @@
+{
+  "title": "Contact Address(es)",
+  "type": "block",
+  "entity_type": "Contact",
+  "join_entity": "Address",
+  "repeat": true
+}
diff --git a/ext/afform/core/ang/afblockContactEmail.aff.json b/ext/afform/core/ang/afblockContactEmail.aff.json
new file mode 100644 (file)
index 0000000..0b8a749
--- /dev/null
@@ -0,0 +1,7 @@
+{
+  "title": "Contact Email(s)",
+  "type": "block",
+  "entity_type": "Contact",
+  "join_entity": "Email",
+  "repeat": true
+}
diff --git a/ext/afform/core/ang/afblockContactIM.aff.json b/ext/afform/core/ang/afblockContactIM.aff.json
new file mode 100644 (file)
index 0000000..0d7ea0f
--- /dev/null
@@ -0,0 +1,7 @@
+{
+  "title": "Contact IM(s)",
+  "type": "block",
+  "entity_type": "Contact",
+  "join_entity": "IM",
+  "repeat": true
+}
diff --git a/ext/afform/core/ang/afblockContactPhone.aff.json b/ext/afform/core/ang/afblockContactPhone.aff.json
new file mode 100644 (file)
index 0000000..fa7cad2
--- /dev/null
@@ -0,0 +1,7 @@
+{
+  "title": "Contact Phone(s)",
+  "type": "block",
+  "entity_type": "Contact",
+  "join_entity": "Phone",
+  "repeat": true
+}
diff --git a/ext/afform/core/ang/afblockContactWebsite.aff.json b/ext/afform/core/ang/afblockContactWebsite.aff.json
new file mode 100644 (file)
index 0000000..3cea837
--- /dev/null
@@ -0,0 +1,7 @@
+{
+  "title": "Contact Website(s)",
+  "type": "block",
+  "entity_type": "Contact",
+  "join_entity": "Website",
+  "repeat": true
+}
index c4eee679c6908382e065a01f6b5800c6414e77a4..dd665a10ccc4b78d236783438201a63091a7c600 100644 (file)
@@ -1,5 +1,5 @@
 {
-  "title": "Household Name (default)",
+  "title": "Household Name",
   "type": "block",
-  "block": "Household"
+  "entity_type": "Household"
 }
index 51c4596ea683f1cee3ba6bb36f7b6c4ad4797495..1cafdc4be66e1bfef04fe461936c2f32034b60da 100644 (file)
@@ -1,5 +1,5 @@
 {
-  "title": "Individual Name (default)",
+  "title": "Individual Name",
   "type": "block",
-  "block": "Individual"
+  "entity_type": "Individual"
 }
index e3ac17c246f458ae91c12223c210d78f368d4599..ca44304b5d4fe2a93439801eed886007131da246 100644 (file)
@@ -1,5 +1,5 @@
 {
-  "title": "Organization Name (default)",
+  "title": "Organization Name",
   "type": "block",
-  "block": "Organization"
+  "entity_type": "Organization"
 }
diff --git a/ext/afform/core/ang/afjoinAddressDefault.aff.json b/ext/afform/core/ang/afjoinAddressDefault.aff.json
deleted file mode 100644 (file)
index 2777577..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-{
-  "title": "Address Block (default)",
-  "type": "block",
-  "block": "Contact",
-  "join": "Address",
-  "repeat": true
-}
diff --git a/ext/afform/core/ang/afjoinEmailDefault.aff.json b/ext/afform/core/ang/afjoinEmailDefault.aff.json
deleted file mode 100644 (file)
index 7c50c57..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-{
-  "title": "Email (default)",
-  "type": "block",
-  "block": "Contact",
-  "join": "Email",
-  "repeat": true
-}
diff --git a/ext/afform/core/ang/afjoinIMDefault.aff.json b/ext/afform/core/ang/afjoinIMDefault.aff.json
deleted file mode 100644 (file)
index 3ec9129..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-{
-  "title": "IM (default)",
-  "type": "block",
-  "block": "Contact",
-  "join": "IM",
-  "repeat": true
-}
diff --git a/ext/afform/core/ang/afjoinPhoneDefault.aff.json b/ext/afform/core/ang/afjoinPhoneDefault.aff.json
deleted file mode 100644 (file)
index 821d288..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-{
-  "title": "Phone (default)",
-  "type": "block",
-  "block": "Contact",
-  "join": "Phone",
-  "repeat": true
-}
diff --git a/ext/afform/core/ang/afjoinWebsiteDefault.aff.json b/ext/afform/core/ang/afjoinWebsiteDefault.aff.json
deleted file mode 100644 (file)
index b39dd9c..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-{
-  "title": "Website (default)",
-  "type": "block",
-  "block": "Contact",
-  "join": "Website",
-  "repeat": true
-}
index 45222a343d6495d44cf1f1862513558629918db5..ab9dfde7c0a70619bbb15d4999cdd3ec658f1da1 100644 (file)
@@ -39,7 +39,7 @@ EOHTML;
     <legend class="af-text">Individual 1</legend>
     <afblock-name-individual></afblock-name-individual>
     <div af-join="Email" min="1" af-repeat="Add">
-      <afjoin-email-default></afjoin-email-default>
+      <afblock-contact-email></afblock-contact-email>
     </div>
     <af-field name="employer_id" defn="{input_type: 'Select', input_attrs: {}}" />
   </fieldset>
@@ -49,7 +49,7 @@ EOHTML;
       <af-field name="organization_name" />
     </div>
     <div af-join="Email">
-      <afjoin-email-default></afjoin-email-default>
+      <afblock-contact-email></afblock-contact-email>
     </div>
   </fieldset>
   <button class="af-button btn-primary" crm-icon="fa-check" ng-click="afform.submit()">Submit</button>
index 4249d393b0574bbe61169d364f7cf00058803fb4..5ad2aa7255b41bb9e6c7c50b0bb420ebfc2e570e 100644 (file)
@@ -17,7 +17,7 @@ class api_v4_AfformCustomFieldUsageTest extends api_v4_AfformUsageTestCase {
     <legend class="af-text">Individual 1</legend>
     <afblock-name-individual></afblock-name-individual>
     <div af-join="Custom_MyThings" af-repeat="Add" max="2">
-      <afjoin-custom-my-things></afjoin-custom-my-things>
+      <afblock-custom-my-things></afblock-custom-my-things>
     </div>
   </fieldset>
   <button class="af-button btn-primary" crm-icon="fa-check" ng-click="afform.submit()">Submit</button>
@@ -31,7 +31,7 @@ EOHTML;
    * which can be submitted multiple times
    */
   public function testMultiRecordCustomBlock(): void {
-    $customGroup = \Civi\Api4\CustomGroup::create(FALSE)
+    \Civi\Api4\CustomGroup::create(FALSE)
       ->addValue('name', 'MyThings')
       ->addValue('title', 'My Things')
       ->addValue('style', 'Tab with table')
@@ -49,11 +49,12 @@ EOHTML;
 
     // Creating a custom group should automatically create an afform block
     $block = \Civi\Api4\Afform::get()
-      ->addWhere('name', '=', 'afjoinCustom_MyThings')
+      ->addWhere('name', '=', 'afblockCustom_MyThings')
       ->setLayoutFormat('shallow')
       ->setFormatWhitespace(TRUE)
-      ->execute()->first();
+      ->execute()->single();
     $this->assertEquals(2, $block['repeat']);
+    $this->assertEquals('afblock-custom-my-things', $block['directive_name']);
     $this->assertEquals('my_text', $block['layout'][0]['name']);
     $this->assertEquals('my_friend', $block['layout'][1]['name']);
 
diff --git a/tests/phpunit/api/v4/Entity/NoteTest.php b/tests/phpunit/api/v4/Entity/NoteTest.php
new file mode 100644 (file)
index 0000000..89eef7b
--- /dev/null
@@ -0,0 +1,77 @@
+<?php
+
+/*
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC. All rights reserved.                        |
+ |                                                                    |
+ | This work is published under the GNU AGPLv3 license with some      |
+ | permitted exceptions and without any warranty. For full license    |
+ | and copyright information, see https://civicrm.org/licensing       |
+ +--------------------------------------------------------------------+
+ */
+
+/**
+ *
+ * @package CRM
+ * @copyright CiviCRM LLC https://civicrm.org/licensing
+ */
+
+namespace api\v4\Entity;
+
+use api\v4\UnitTestCase;
+use Civi\Api4\Note;
+use Civi\Test\TransactionalInterface;
+
+/**
+ * @group headless
+ */
+class NoteTest extends UnitTestCase implements TransactionalInterface {
+
+  public function testDeleteWithChildren() {
+    $c1 = $this->createEntity(['type' => 'Individual']);
+
+    $text = uniqid(__FUNCTION__, TRUE);
+
+    // Create 2 top-level notes.
+    $notes = Note::save(FALSE)
+      ->setRecords([['note' => $text], ['note' => $text]])
+      ->setDefaults([
+        'entity_id' => $c1['id'],
+        'entity_table' => 'civicrm_contact',
+      ])->execute();
+
+    // Add 2 children of the first note.
+    $children = Note::save(FALSE)
+      ->setRecords([['note' => $text], ['note' => $text]])
+      ->setDefaults([
+        'entity_id' => $notes->first()['id'],
+        'entity_table' => 'civicrm_note',
+      ])->execute();
+
+    // Add 2 children of the first child.
+    $grandChildren = Note::save(FALSE)
+      ->setRecords([['note' => $text], ['note' => $text]])
+      ->setDefaults([
+        'entity_id' => $children->first()['id'],
+        'entity_table' => 'civicrm_note',
+      ])->execute();
+
+    // We just created 2 top-level notes and 4 children. Ensure we have a total of 6.
+    $existing = Note::get(FALSE)
+      ->addWhere('note', '=', $text)
+      ->execute();
+    $this->assertCount(6, $existing);
+
+    // Delete parent
+    Note::delete(FALSE)
+      ->addWhere('id', '=', $notes->first()['id'])
+      ->execute();
+
+    // Should have deleted 1 parent + 4 child-notes, for a new total of 1 remaining.
+    $existing = Note::get(FALSE)
+      ->addWhere('note', '=', $text)
+      ->execute();
+    $this->assertCount(1, $existing);
+  }
+
+}