SearchKit - server-side rendering
authorColeman Watts <coleman@civicrm.org>
Wed, 13 Oct 2021 02:19:45 +0000 (22:19 -0400)
committerColeman Watts <coleman@civicrm.org>
Tue, 26 Oct 2021 19:54:14 +0000 (15:54 -0400)
Moves logic for resolving field values from the client to the server-side,
which gives more control over formatting, links, and greater consistency
of spreadsheet output.

21 files changed:
Civi/Api4/Query/SqlFunction.php
ext/search_kit/Civi/Api4/Action/SearchDisplay/AbstractRunAction.php
ext/search_kit/Civi/Api4/Action/SearchDisplay/Download.php
ext/search_kit/ang/crmSearchAdmin/resultsTable/crmSearchAdminResultsTable.component.js
ext/search_kit/ang/crmSearchAdmin/searchListing/afforms.html
ext/search_kit/ang/crmSearchAdmin/searchListing/buttons.html
ext/search_kit/ang/crmSearchAdmin/searchListing/crmSearchAdminSearchListing.component.js
ext/search_kit/ang/crmSearchAdmin/searchListing/displays.html
ext/search_kit/ang/crmSearchAdmin/searchListing/tags.html
ext/search_kit/ang/crmSearchDisplay/colType/buttons.html
ext/search_kit/ang/crmSearchDisplay/colType/field.html
ext/search_kit/ang/crmSearchDisplay/colType/include.html
ext/search_kit/ang/crmSearchDisplay/colType/links.html
ext/search_kit/ang/crmSearchDisplay/colType/menu.html
ext/search_kit/ang/crmSearchDisplay/traits/searchDisplayBaseTrait.service.js
ext/search_kit/ang/crmSearchDisplayGrid/crmSearchDisplayGridItems.html
ext/search_kit/ang/crmSearchDisplayList/crmSearchDisplayListItems.html
ext/search_kit/ang/crmSearchDisplayTable/crmSearchDisplayTableBody.html
ext/search_kit/ang/crmSearchTasks/traits/searchDisplayTasksTrait.service.js
ext/search_kit/tests/phpunit/api/v4/SearchDisplay/SearchDownloadTest.php
ext/search_kit/tests/phpunit/api/v4/SearchDisplay/SearchRunTest.php

index ea09a42d5d8888ac4cc756407ab9e1c31610e8df..638021102660304415fa9ade5c2af623e7bd9080 100644 (file)
@@ -195,6 +195,16 @@ abstract class SqlFunction extends SqlExpression {
     return static::$category;
   }
 
+  /**
+   * All functions return 'SqlFunction' as their type.
+   *
+   * To get the function name @see SqlFunction::getName()
+   * @return string
+   */
+  public function getType(): string {
+    return 'SqlFunction';
+  }
+
   /**
    * @return string
    */
index 75269cefddfcc9ab8d810af2648c8791632dd07f..a9e48d58c1129b4846ef91b3c3ab6e36df41706e 100644 (file)
@@ -65,6 +65,11 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction {
    */
   private $_afform;
 
+  /**
+   * @var array
+   */
+  private $_selectClause;
+
   /**
    * @param \Civi\Api4\Generic\Result $result
    * @throws UnauthorizedException
@@ -108,61 +113,178 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction {
   abstract protected function processResult(\Civi\Api4\Generic\Result $result);
 
   /**
-   * Transform each value returned by the API into 'raw' and 'view' properties
+   * Transforms each row into an array of raw data and an array of formatted columns
+   *
    * @param \Civi\Api4\Generic\Result $result
-   * @return array
+   * @return array{data: array, columns: array}[]
    */
   protected function formatResult(\Civi\Api4\Generic\Result $result): array {
-    $select = [];
-    foreach ($this->savedSearch['api_params']['select'] as $selectExpr) {
-      $expr = SqlExpression::convert($selectExpr, TRUE);
-      $item = [
-        'fields' => [],
-        'dataType' => $expr->getDataType(),
-      ];
-      foreach ($expr->getFields() as $field) {
-        $item['fields'][] = $this->getField($field);
-      }
-      if (!isset($item['dataType']) && $item['fields']) {
-        $item['dataType'] = $item['fields'][0]['data_type'];
+    $rows = [];
+    foreach ($result as $index => $row) {
+      $data = $columns = [];
+      foreach ($this->getSelectClause() as $key => $item) {
+        $data[$key] = $this->getValue($key, $row, $index);
       }
-      $select[$expr->getAlias()] = $item;
-    }
-    $formatted = [];
-    foreach ($result as $index => $data) {
-      $row = [];
-      foreach ($select as $key => $item) {
-        $row[$key] = $this->getValue($key, $data, $item['dataType'], $index);
+      foreach ($this->display['settings']['columns'] as $column) {
+        $columns[] = $this->formatColumn($column, $data);
       }
-      $formatted[] = $row;
+      $rows[] = [
+        'data' => $data,
+        'columns' => $columns,
+      ];
     }
-    return $formatted;
+    return $rows;
   }
 
   /**
-   * @param $key
-   * @param $data
-   * @param $dataType
-   * @param $index
-   * @return array
+   * @param string $key
+   * @param array $data
+   * @param int $rowIndex
+   * @return mixed
    */
-  private function getValue($key, $data, $dataType, $index) {
+  private function getValue($key, $data, $rowIndex) {
     // Get value from api result unless this is a pseudo-field which gets a calculated value
     switch ($key) {
       case 'result_row_num':
-        $raw = $index + 1 + ($this->savedSearch['api_params']['offset'] ?? 0);
-        break;
+        return $rowIndex + 1 + ($this->savedSearch['api_params']['offset'] ?? 0);
 
       case 'user_contact_id':
-        $raw = \CRM_Core_Session::getLoggedInContactID();
-        break;
+        return \CRM_Core_Session::getLoggedInContactID();
 
       default:
-        $raw = $data[$key] ?? NULL;
+        return $data[$key] ?? NULL;
+    }
+  }
+
+  /**
+   * @param $column
+   * @param $data
+   * @return array{val: mixed, links: array, edit: array, label: string, title: string, image: array, cssClass: string}
+   */
+  private function formatColumn($column, $data) {
+    $column += ['rewrite' => NULL, 'label' => NULL];
+    $out = $cssClass = [];
+    switch ($column['type']) {
+      case 'field':
+        if (isset($column['image']) && is_array($column['image'])) {
+          $out['img'] = $this->formatImage($column, $data);
+          $out['val'] = $this->replaceTokens($column['image']['alt'] ?? NULL, $data, 'view');
+        }
+        elseif ($column['rewrite']) {
+          $out['val'] = $this->replaceTokens($column['rewrite'], $data, 'view');
+        }
+        else {
+          $out['val'] = $this->formatViewValue($column['key'], $data[$column['key']] ?? NULL);
+        }
+        if ($this->hasValue($column['label']) && (!empty($column['forceLabel']) || $this->hasValue($out['val']))) {
+          $out['label'] = $this->replaceTokens($column['label'], $data, 'view');
+        }
+        if (isset($column['title']) && strlen($column['title'])) {
+          $out['title'] = $this->replaceTokens($column['title'], $data, 'view');
+        }
+        if (!empty($column['link']['path'])) {
+          $out['links'] = $this->formatFieldLinks($column, $data, $out['val']);
+        }
+        elseif (!empty($column['editable']) && !$column['rewrite']) {
+          $out['edit'] = $this->formatEditableColumn($column, $data);
+        }
+        break;
+
+      case 'links':
+      case 'buttons':
+      case 'menu':
+        $out = $this->formatLinksColumn($column, $data);
+        break;
+    }
+    if (!empty($column['alignment'])) {
+      $cssClass[] = $column['alignment'];
+    }
+    if ($cssClass) {
+      $out['cssClass'] = implode(' ', $cssClass);
+    }
+    return $out;
+  }
+
+  /**
+   * Format a field value as links
+   * @param $column
+   * @param $data
+   * @param $value
+   * @return array{text: string, url: string, target: string}[]
+   */
+  private function formatFieldLinks($column, $data, $value): array {
+    $links = [];
+    if (!empty($column['image'])) {
+      $value = [''];
+    }
+    foreach ((array) $value as $index => $val) {
+      $path = $this->replaceTokens($column['link']['path'], $data, 'url', $index);
+      if ($path) {
+        $link = [
+          'text' => $val,
+          'url' => $this->getUrl($path),
+        ];
+        if (!empty($column['link']['target'])) {
+          $link['target'] = $column['link']['target'];
+        }
+        $links[] = $link;
+      }
     }
+    return $links;
+  }
+
+  /**
+   * Format links for a menu/buttons/links column
+   * @param $column
+   * @param $data
+   * @return array{text: string, url: string, target: string, style: string, icon: string}[]
+   */
+  private function formatLinksColumn($column, $data): array {
+    $out = ['links' => []];
+    if (isset($column['text'])) {
+      $out['text'] = $this->replaceTokens($column['text'], $data, 'view');
+    }
+    foreach ($column['links'] as $item) {
+      $path = $this->replaceTokens($item['path'], $data, 'url');
+      if ($path) {
+        $link = [
+          'text' => $this->replaceTokens($item['text'] ?? '', $data, 'view'),
+          'url' => $this->getUrl($path),
+        ];
+        foreach (['target', 'style', 'icon'] as $prop) {
+          if (!empty($item[$prop])) {
+            $link[$prop] = $item[$prop];
+          }
+        }
+        $out['links'][] = $link;
+      }
+    }
+    return $out;
+  }
+
+  /**
+   * @param string $path
+   * @return string
+   */
+  private function getUrl(string $path) {
+    if ($path[0] === '/' || strpos($path, 'http://') || strpos($path, 'https://')) {
+      return $path;
+    }
+    // Use absolute urls when downloading spreadsheet
+    $absolute = $this->getActionName() === 'download';
+    return \CRM_Utils_System::url($path, NULL, $absolute, NULL, FALSE);
+  }
+
+  private function formatEditableColumn($column, $data) {
+
+  }
+
+  private function formatImage($column, $data) {
+    $tokenExpr = $column['rewrite'] ?: '[' . $column['key'] . ']';
     return [
-      'raw' => $raw,
-      'view' => $this->formatViewValue($dataType, $raw),
+      'url' => $this->replaceTokens($tokenExpr, $data, 'url'),
+      'height' => $column['image']['height'] ?? NULL,
+      'width' => $column['image']['width'] ?? NULL,
     ];
   }
 
@@ -179,19 +301,82 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction {
     return $this->_selectQuery->getField($fieldName, FALSE);
   }
 
+  /**
+   * Returns the select clause enhanced with metadata
+   *
+   * @return array
+   */
+  protected function getSelectClause() {
+    if (!isset($this->_selectClause)) {
+      $this->_selectClause = [];
+      foreach ($this->savedSearch['api_params']['select'] as $selectExpr) {
+        $expr = SqlExpression::convert($selectExpr, TRUE);
+        $item = [
+          'fields' => [],
+          'type' => $expr->getType(),
+          'dataType' => $expr->getDataType(),
+        ];
+        foreach ($expr->getFields() as $fieldName) {
+          $fieldMeta = $this->getField($fieldName);
+          if ($fieldMeta) {
+            $item['fields'][] = $fieldMeta;
+          }
+        }
+        if (!isset($item['dataType']) && $item['fields']) {
+          $item['dataType'] = $item['fields'][0]['data_type'];
+        }
+        $this->_selectClause[$expr->getAlias()] = $item;
+      }
+    }
+    return $this->_selectClause;
+  }
+
+  /**
+   * @param string $key
+   * @return array{fields: array, dataType: string}|NULL
+   */
+  protected function getSelectExpression($key) {
+    return $this->getSelectClause()[$key] ?? NULL;
+  }
+
+  /**
+   * @param string $tokenExpr
+   * @param array $data
+   * @param string $format view|raw|url
+   * @param int $index
+   * @return string
+   */
+  private function replaceTokens($tokenExpr, $data, $format, $index = 0) {
+    foreach ($this->getTokens($tokenExpr) as $token) {
+      $val = $data[$token] ?? NULL;
+      if (isset($val) && $format === 'view') {
+        $val = $this->formatViewValue($token, $val);
+      }
+      $replacement = is_array($val) ? $val[$index] ?? '' : $val;
+      // A missing token value in a url invalidates it
+      if ($format === 'url' && (!isset($replacement) || $replacement === '')) {
+        return NULL;
+      }
+      $tokenExpr = str_replace('[' . $token . ']', $replacement, $tokenExpr);
+    }
+    return $tokenExpr;
+  }
+
   /**
    * Format raw field value according to data type
-   * @param $dataType
+   * @param string $key
    * @param mixed $rawValue
    * @return array|string
    */
-  protected function formatViewValue($dataType, $rawValue) {
+  protected function formatViewValue($key, $rawValue) {
     if (is_array($rawValue)) {
-      return array_map(function($val) use ($dataType) {
-        return $this->formatViewValue($dataType, $val);
+      return array_map(function($val) use ($key) {
+        return $this->formatViewValue($key, $val);
       }, $rawValue);
     }
 
+    $dataType = $this->getSelectExpression($key)['dataType'] ?? NULL;
+
     $formatted = $rawValue;
 
     switch ($dataType) {
@@ -348,9 +533,10 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction {
     $defaultSort = $this->display['settings']['sort'] ?? [];
     $currentSort = $this->sort;
 
-    // Validate that requested sort fields are part of the SELECT
+    // Verify requested sort corresponds to sortable columns
     foreach ($this->sort as $item) {
-      if (!in_array($item[0], $this->getSelectAliases())) {
+      $column = array_column($this->display['settings']['columns'], NULL, 'key')[$item[0]] ?? NULL;
+      if (!$column || (isset($column['sortable']) && !$column['sortable'])) {
         $currentSort = NULL;
       }
     }
@@ -396,19 +582,28 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction {
       }
     }
     // Add fields referenced via token
-    $tokens = [];
-    preg_match_all('/\\[([^]]+)\\]/', $possibleTokens, $tokens);
+    $tokens = $this->getTokens($possibleTokens);
     // Only add fields not already in SELECT clause
-    $additions = array_diff(array_merge($additions, $tokens[1]), $existing);
+    $additions = array_diff(array_merge($additions, $tokens), $existing);
     // Tokens for aggregated columns start with 'GROUP_CONCAT_'
     foreach ($additions as $index => $alias) {
       if (strpos($alias, 'GROUP_CONCAT_') === 0) {
         $additions[$index] = 'GROUP_CONCAT(' . $this->getJoinFromAlias(explode('_', $alias, 3)[2]) . ') AS ' . $alias;
       }
     }
+    $this->_selectClause = NULL;
     $apiParams['select'] = array_unique(array_merge($apiParams['select'], $additions));
   }
 
+  /**
+   * @param string $str
+   */
+  private function getTokens($str) {
+    $tokens = [];
+    preg_match_all('/\\[([^]]+)\\]/', $str, $tokens);
+    return array_unique($tokens[1]);
+  }
+
   /**
    * Given an alias like Contact_Email_01_location_type_id
    * this will return Contact_Email_01.location_type_id
index 1756e9dc62bf168d0aae7ee03840f98c6d911210..432648ff34ce9ea3880a955e6d14ddedba99ed6d 100644 (file)
@@ -71,10 +71,10 @@ class Download extends AbstractRunAction {
     $rows = $this->formatResult($apiResult);
 
     $columns = [];
-    foreach ($this->display['settings']['columns'] as $col) {
+    foreach ($this->display['settings']['columns'] as $index => $col) {
       $col += ['type' => NULL, 'label' => '', 'rewrite' => FALSE];
       if ($col['type'] === 'field' && !empty($col['key'])) {
-        $columns[] = $col;
+        $columns[$index] = $col;
       }
     }
 
@@ -83,12 +83,9 @@ class Download extends AbstractRunAction {
 
     switch ($this->format) {
       case 'array':
-        $result[] = $columns;
+        $result[] = array_column($columns, 'label');
         foreach ($rows as $data) {
-          $row = [];
-          foreach ($columns as $col) {
-            $row[] = $this->formatColumnValue($col, $data);
-          }
+          $row = array_column(array_intersect_key($data['columns'], $columns), 'val');
           $result[] = $row;
         }
         return;
@@ -119,9 +116,11 @@ class Download extends AbstractRunAction {
     $csv->insertOne(array_column($columns, 'label'));
 
     foreach ($rows as $data) {
-      $row = [];
-      foreach ($columns as $col) {
-        $row[] = $this->formatColumnValue($col, $data);
+      $row = array_column(array_intersect_key($data['columns'], $columns), 'val');
+      foreach ($row as &$val) {
+        if (is_array($val)) {
+          $val = implode(', ', $val);
+        }
       }
       $csv->insertOne($row);
     }
@@ -141,13 +140,14 @@ class Download extends AbstractRunAction {
     $sheet = $document->getActiveSheet();
 
     // Header row
-    foreach ($columns as $index => $col) {
+    foreach (array_values($columns) as $index => $col) {
       $sheet->setCellValueByColumnAndRow($index + 1, 1, $col['label']);
     }
 
     foreach ($rows as $rowNum => $data) {
+      $colNum = 1;
       foreach ($columns as $index => $col) {
-        $sheet->setCellValueByColumnAndRow($index + 1, $rowNum + 2, $this->formatColumnValue($col, $data));
+        $sheet->setCellValueByColumnAndRow($colNum++, $rowNum + 2, $this->formatColumnValue($col, $data['columns'][$index]));
       }
     }
 
@@ -160,16 +160,11 @@ class Download extends AbstractRunAction {
    * Returns final formatted column value
    *
    * @param array $col
-   * @param array $data
+   * @param array $value
    * @return string
    */
-  protected function formatColumnValue(array $col, array $data) {
-    $val = $col['rewrite'] ?: $data[$col['key']]['view'] ?? '';
-    if ($col['rewrite']) {
-      foreach ($data as $k => $v) {
-        $val = str_replace("[$k]", $v['view'], $val);
-      }
-    }
+  protected function formatColumnValue(array $col, array $value) {
+    $val = $value['val'] ?? '';
     return is_array($val) ? implode(', ', $val) : $val;
   }
 
index f00dc3a831cc69286a061dd1a97c5cbb2d3c148c..b74b81933be9c8dde27d05e8d0c4388341c776b8 100644 (file)
@@ -77,6 +77,9 @@
         };
         ctrl.settings = ctrl.display.settings;
         setLabel();
+        ctrl.results = null;
+        ctrl.rowCount = null;
+        ctrl.page = 1;
       }
 
       function setLabel() {
@@ -86,7 +89,6 @@
       this.$onInit = function() {
         buildSettings();
         this.initializeDisplay($scope, $element);
-        $scope.$watch('$ctrl.search.api_entity', buildSettings);
         $scope.$watch('$ctrl.search.api_params', buildSettings, true);
         $scope.$watch('$ctrl.search.label', setLabel);
       };
index 856b6d10d1e0ffaa37dec6a1f6a7ba5b8993f91f..211d50a69c91d0c8cdadf038898d00273776bb80 100644 (file)
@@ -1,22 +1,22 @@
-<div class="btn-group" ng-if=":: row.display_name.raw">
+<div class="btn-group" ng-if=":: row.data.display_name">
   <button type="button" ng-click="$ctrl.loadAfforms(); row.openAfformMenu = true;" class="btn btn-xs dropdown-toggle btn-primary-outline" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
     {{ $ctrl.afforms ? (row.afform_count === 1 ? ts('1 Form') : ts('%1 Forms', {1: row.afform_count})) : ts('Forms...') }}
     <span class="caret"></span>
   </button>
   <ul class="dropdown-menu" ng-if=":: row.openAfformMenu">
-    <li ng-repeat="display_name in row.display_name.raw" ng-if="::$ctrl.afformAdminEnabled">
-      <a target="_blank" href="{{:: $ctrl.afformPath + '#/create/search/' + row.name.raw + '.' + display_name }}">
-        <i class="fa fa-plus"></i> {{:: ts('Create form for %1', {1: row.display_label.raw[$index]}) }}
+    <li ng-repeat="display_name in row.data.display_name" ng-if="::$ctrl.afformAdminEnabled">
+      <a target="_blank" href="{{:: $ctrl.afformPath + '#/create/search/' + row.data.name + '.' + display_name }}">
+        <i class="fa fa-plus"></i> {{:: ts('Create form for %1', {1: row.data.display_label[$index]}) }}
       </a>
     </li>
     <li class="divider" role="separator" ng-if="::$ctrl.afformAdminEnabled"></li>
     <li ng-if="!row.afform_count" class="disabled">
       <a href>
         <i ng-if="!$ctrl.afforms" class="crm-i fa-spinner fa-spin"></i>
-        <em ng-if="$ctrl.afforms && !$ctrl.afforms[row.name.raw]">{{:: ts('None Found') }}</em>
+        <em ng-if="$ctrl.afforms && !$ctrl.afforms[row.data.name]">{{:: ts('None Found') }}</em>
       </a>
     </li>
-    <li ng-if="$ctrl.afforms" ng-repeat="afform in $ctrl.afforms[row.name.raw]" title="{{:: $ctrl.afformAdminEnabled ? ts('Edit form') : '' }}">
+    <li ng-if="$ctrl.afforms" ng-repeat="afform in $ctrl.afforms[row.data.name]" title="{{:: $ctrl.afformAdminEnabled ? ts('Edit form') : '' }}">
       <a target="_blank" ng-href="{{:: afform.link }}">
         <i class="crm-i {{:: $ctrl.afformAdminEnabled ? 'fa-pencil-square-o' : 'fa-list-alt' }}"></i>
         {{:: afform.title }}
index 1f86009c68a98a20d82e88c4dd05397eeaf0bfb8..1317ab41d22adf08438864300a494354bc1a1c96 100644 (file)
@@ -1,7 +1,7 @@
-<a class="btn btn-xs btn-default" href="#/edit/{{:: row.id.raw }}" ng-if="row.permissionToEdit">
+<a class="btn btn-xs btn-default" href="#/edit/{{:: row.data.id }}" ng-if="row.permissionToEdit">
   {{:: ts('Edit') }}
 </a>
-<a class="btn btn-xs btn-default" href="#/create/{{:: row.api_entity.raw + '?params=' + $ctrl.encode(row.api_params.raw) }}">
+<a class="btn btn-xs btn-default" href="#/create/{{:: row.data.api_entity + '?params=' + $ctrl.encode(row.data.api_params) }}">
   {{:: ts('Clone') }}
 </a>
 <a href class="btn btn-xs btn-danger" ng-click="$ctrl.confirmDelete(row)">
index b689022133799778e4733e675a1f090bb0499663..19dc5a6567366b1677162ef0a4ab3b2db0ccb84e 100644 (file)
@@ -60,9 +60,9 @@
 
       this.onPostRun.push(function(result) {
         _.each(result, function(row) {
-          row.permissionToEdit = CRM.checkPerm('all CiviCRM permissions and ACLs') || !_.includes(row.display_acl_bypass.raw, true);
+          row.permissionToEdit = CRM.checkPerm('all CiviCRM permissions and ACLs') || !_.includes(row.data.display_acl_bypass, true);
           // Saves rendering cycles to not show an empty menu of search displays
-          if (!row.display_name.raw) {
+          if (!row.data.display_name) {
             row.openDisplayMenu = false;
           }
         });
         function getConfirmationMsg() {
           var msg = '<h4>' + _.escape(ts('Permanently delete this saved search?')) + '</h4>' +
             '<ul>';
-          if (search.display_label.view && search.display_label.view.length === 1) {
+          if (search.data.display_label && search.data.display_label.length === 1) {
             msg += '<li>' + _.escape(ts('Includes 1 display which will also be deleted.')) + '</li>';
-          } else if (search.display_label.view && search.display_label.view.length > 1) {
-            msg += '<li>' + _.escape(ts('Includes %1 displays which will also be deleted.', {1: search.display_label.view.length})) + '</li>';
+          } else if (search.data.display_label && search.data.display_label.length > 1) {
+            msg += '<li>' + _.escape(ts('Includes %1 displays which will also be deleted.', {1: search.data.display_label.length})) + '</li>';
           }
-          _.each(search.groups.view, function(smartGroup) {
+          _.each(search.data.groups, function(smartGroup) {
             msg += '<li class="crm-error"><i class="crm-i fa-exclamation-triangle"></i> ' + _.escape(ts('Smart group "%1" will also be deleted.', {1: smartGroup})) + '</li>';
           });
           if (search.afform_count) {
-            _.each(ctrl.afforms[search.name.raw], function(afform) {
+            _.each(ctrl.afforms[search.data.name], function(afform) {
               msg += '<li class="crm-error"><i class="crm-i fa-exclamation-triangle"></i> ' + _.escape(ts('Form "%1" will also be deleted because it contains an embedded display from this search.', {1: afform.title})) + '</li>';
             });
           }
@@ -94,7 +94,7 @@
         }
 
         var dialog = CRM.confirm({
-          title: ts('Delete %1', {1: search.label.view}),
+          title: ts('Delete %1', {1: search.data.label}),
           message: getConfirmationMsg(),
         }).on('crmConfirm:yes', function() {
           $scope.$apply(function() {
 
       this.deleteSearch = function(search) {
         crmStatus({start: ts('Deleting...'), success: ts('Search Deleted')},
-          crmApi4('SavedSearch', 'delete', {where: [['id', '=', search.id.raw]]}).then(function() {
+          crmApi4('SavedSearch', 'delete', {where: [['id', '=', search.data.id]]}).then(function() {
             ctrl.rowCount = null;
             ctrl.runSearch();
           })
 
       function updateAfformCounts() {
         _.each(ctrl.results, function(row) {
-          row.afform_count = ctrl.afforms && ctrl.afforms[row.name.raw] && ctrl.afforms[row.name.raw].length || 0;
+          row.afform_count = ctrl.afforms && ctrl.afforms[row.data.name] && ctrl.afforms[row.data.name].length || 0;
         });
       }
 
index c16c66eb9b3849d014cf4b25ab459bc3faf41d02..3960174eecc098ecb2dea629427c344c7d44494d 100644 (file)
@@ -1,15 +1,15 @@
 <div class="btn-group">
-  <button type="button" disabled ng-if="!row.display_name.raw" class="btn btn-xs dropdown-toggle btn-primary-outline">
+  <button type="button" disabled ng-if="!row.data.display_name" class="btn btn-xs dropdown-toggle btn-primary-outline">
     {{:: ts('0 Displays') }}
   </button>
-  <button type="button" ng-if=":: row.display_name.raw" ng-click="row.openDisplayMenu = true" class="btn btn-xs dropdown-toggle btn-primary-outline" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
-    {{:: row.display_name.raw.length === 1 ? ts('1 Display') : ts('%1 Displays', {1: row.display_name.raw.length}) }} <span class="caret"></span>
+  <button type="button" ng-if=":: row.data.display_name" ng-click="row.openDisplayMenu = true" class="btn btn-xs dropdown-toggle btn-primary-outline" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+    {{:: row.data.display_name.length === 1 ? ts('1 Display') : ts('%1 Displays', {1: row.data.display_name.length}) }} <span class="caret"></span>
   </button>
   <ul class="dropdown-menu" ng-if=":: row.openDisplayMenu">
-    <li ng-repeat="display_name in row.display_name.raw" ng-class="{disabled: row.display_acl_bypass.raw[$index]}" title="{{:: row.display_acl_bypass.raw[$index] ? ts('Display has permissions disabled') : ts('View display') }}">
-      <a ng-href="{{:: row.display_acl_bypass.raw[$index] ? '' : $ctrl.searchDisplayPath + '#/display/' + row.name.raw + '/' + display_name }}" target="_blank">
+    <li ng-repeat="display_name in row.data.display_name" ng-class="{disabled: row.data.display_acl_bypass[$index]}" title="{{:: row.data.display_acl_bypass[$index] ? ts('Display has permissions disabled') : ts('View display') }}">
+      <a ng-href="{{:: row.data.display_acl_bypass[$index] ? '' : $ctrl.searchDisplayPath + '#/display/' + row.data.name + '/' + display_name }}" target="_blank">
         <i class="fa {{:: row.display_icon.rw[$index] }}"></i>
-        {{:: row.display_label.raw[$index] }}
+        {{:: row.data.display_label[$index] }}
       </a>
     </li>
   </ul>
index 3be6368a6043090c337c24e1ccf57817bdb61fba..6151ee6117f46d508869d35fccd8e494c0fa5891 100644 (file)
@@ -1 +1 @@
-<crm-search-admin-tags tag-ids="row.tag_id.raw" saved-search-id="row.id.raw" class="btn-group btn-group-xs"></crm-search-admin-tags>
+<crm-search-admin-tags tag-ids="row.data.tag_id" saved-search-id="row.data.id" class="btn-group btn-group-xs"></crm-search-admin-tags>
index 1c178db16d86b86f2a1d093671ef8021af3e02fb..970ff34489154356901279df453d98ac805cce98 100644 (file)
@@ -1,6 +1,6 @@
-<span ng-repeat="item in col.links">
-  <a class="btn {{:: col.size }} btn-{{:: item.style }}" target="{{:: item.target }}" href="{{:: $ctrl.getUrl(item.path, row) }}">
-    <i ng-if=":: item.icon" class="crm-i {{:: item.icon }}"></i>
-    {{:: $ctrl.replaceTokens(item.text, row) }}
+<span ng-repeat="link in colData.links">
+  <a class="btn {{:: $ctrl.settings.columns[colIndex].size }} btn-{{:: link.style }}" target="{{:: link.target }}" href="{{:: link.url }}">
+    <i ng-if=":: link.icon" class="crm-i {{:: link.icon }}"></i>
+    {{:: link.text }}
   </a>
 </span>
index daeb5eb4df1b56e216afa6cac851fa67e6c6340d..faab99654f06c375d12549655dc5778ca5a05467 100644 (file)
@@ -1,17 +1,17 @@
-<crm-search-display-editable row="row" col="col" on-success="$ctrl.runSearch(row)" cancel="$ctrl.editing = null;" ng-if="col.editable && $ctrl.editing && $ctrl.editing[0] === rowIndex && $ctrl.editing[1] === col.key"></crm-search-display-editable>
-<span ng-if="::!col.link && !col.image" ng-class="{'crm-editable-enabled': col.editable && !$ctrl.editing && row[col.editable.id]}" ng-click="col.editable && !$ctrl.editing && ($ctrl.editing = [rowIndex, col.key])">
-  {{:: $ctrl.formatFieldValue(row, col) }}
+<crm-search-display-editable row="row" col="$ctrl.settings.columns[colIndex]" on-success="$ctrl.runSearch(row)" cancel="$ctrl.editing = null;" ng-if="col.editable && $ctrl.editing && $ctrl.editing[0] === rowIndex && $ctrl.editing[1] === col.key"></crm-search-display-editable>
+<span ng-if="::!colData.links && !colData.img" ng-class="{'crm-editable-enabled': colData.edit && !$ctrl.editing}" ng-click="col.edit && !$ctrl.editing && ($ctrl.editing = [rowIndex, col.key])">
+  {{:: $ctrl.formatFieldValue(colData) }}
 </span>
-<span ng-if="::col.link">
-  <span ng-repeat="link in $ctrl.getLinks(row, col)">
-    <a target="{{:: col.link.target }}" href="{{:: link.url }}">
-      <span ng-if=":: col.image && $ctrl.formatFieldValue(row, col).length">
-        <img ng-src="{{:: $ctrl.formatFieldValue(row, col) }}" alt="{{:: $ctrl.replaceTokens(col.image.alt, row) }}" height="{{:: col.image.height }}" width="{{:: col.image.width }}"/>
+<span ng-if="::colData.links">
+  <span ng-repeat="link in colData.links">
+    <a target="{{:: link.target }}" href="{{:: link.url }}">
+      <span ng-if=":: colData.img">
+        <img ng-src="{{:: colData.img.src }}" alt="{{:: colData.val }}" height="{{:: colData.img.height }}" width="{{:: colData.img.width }}"/>
       </span>
-      {{:: link.value }}</a><span ng-if="!$last">,
+      {{:: link.text }}</a><span ng-if="!$last">,
     </span>
   </span>
 </span>
-<span ng-if=":: !col.link && col.image && $ctrl.formatFieldValue(row, col).length">
-  <img ng-src="{{:: $ctrl.formatFieldValue(row, col) }}" alt="{{:: $ctrl.replaceTokens(col.image.alt, row, $ctrl.settings.columns) }}" height="{{:: col.image.height }}" width="{{:: col.image.width }}"/>
+<span ng-if=":: !colData.links && colData.img">
+  <img ng-src="{{:: colData.img.src }}" alt="{{:: colData.val }}" height="{{:: colData.img.height }}" width="{{:: colData.img.width }}"/>
 </span>
index 46ea8299425973721d1f95768fa46724dd242622..5253a1aa25967ac0915fdbc2310efa10c1aff1e4 100644 (file)
@@ -1 +1 @@
-<div ng-include="col.path"></div>
+<div ng-include="$ctrl.settings.columns[colIndex].path"></div>
index 5d59a7960d5b082e4216a33f7f4c9bbabaa846b6..c9e8af5a283b4e47c971b86364d707cb5d5553dc 100644 (file)
@@ -1,6 +1,6 @@
-<span ng-repeat="item in col.links">
-  <a class="text-{{:: item.style }}" target="{{:: item.target }}" href="{{:: $ctrl.getUrl(item.path, row) }}">
-    <i ng-if=":: item.icon" class="crm-i {{:: item.icon }}"></i>
-    {{:: $ctrl.replaceTokens(item.text, row) }}
+<span ng-repeat="link in colData.links">
+  <a class="text-{{:: link.style }}" target="{{:: link.target }}" href="{{:: link.url }}">
+    <i ng-if=":: link.icon" class="crm-i {{:: link.icon }}"></i>
+    {{:: link.text }}
   </a>
 </span>
index fdc4b398b6b2060f320f2f221f73dcb7e16652f1..d84d3c774fe760ca179185a10f5f242bc2c2a2e5 100644 (file)
@@ -1,13 +1,13 @@
 <div class="btn-group">
-  <button type="button" class="dropdown-toggle {{:: col.size }} btn-{{:: col.style }}" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" ng-click="col.open = true">
-    <i ng-if=":: col.icon" class="crm-i {{:: col.icon }}"></i>
-    {{:: $ctrl.replaceTokens(col.text, row) }}
+  <button type="button" class="dropdown-toggle {{:: $ctrl.settings.columns[colIndex].size }} btn-{{:: $ctrl.settings.columns[colIndex].style }}" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" ng-click="colData.open = true">
+    <i ng-if=":: $ctrl.settings.columns[colIndex].icon" class="crm-i {{:: $ctrl.settings.columns[colIndex].icon }}"></i>
+    {{:: colData.text }}
   </button>
-  <ul class="dropdown-menu {{ col.alignment === 'text-right' ? 'dropdown-menu-right' : '' }}" ng-if=":: col.open">
-    <li ng-repeat="item in col.links" class="bg-{{:: item.style }}">
-      <a href="{{:: $ctrl.getUrl(item.path, row) }}" target="{{:: item.target }}">
-        <i ng-if=":: item.icon" class="crm-i {{:: item.icon }}"></i>
-        {{:: $ctrl.replaceTokens(item.text, row) }}
+  <ul class="dropdown-menu {{ $ctrl.settings.columns[colIndex].alignment === 'text-right' ? 'dropdown-menu-right' : '' }}" ng-if=":: colData.open">
+    <li ng-repeat="link in colData.links" class="bg-{{:: link.style }}">
+      <a href="{{:: link.url }}" target="{{:: link.target }}">
+        <i ng-if=":: link.icon" class="crm-i {{:: link.icon }}"></i>
+        {{:: link.text }}
       </a>
     </li>
   </ul>
index 89752172d5d14bca15fb985c0116e37c026e4b78..ef6adc989fb985bc7a83c705514276d1765a3edc 100644 (file)
@@ -4,54 +4,7 @@
   // Trait provides base methods and properties common to all search display types
   angular.module('crmSearchDisplay').factory('searchDisplayBaseTrait', function(crmApi4) {
     var ts = CRM.ts('org.civicrm.search_kit'),
-      runCount = 0,
-      seed = Date.now();
-
-    // Replace tokens keyed to rowData.
-    // Pass view=true to replace with view value, otherwise raw value is used.
-    function replaceTokens(str, rowData, view, index) {
-      if (!str) {
-        return '';
-      }
-      _.each(rowData, function(value, key) {
-        if (str.indexOf('[' + key + ']') >= 0) {
-          var val = view ? value.view : value.raw,
-            replacement = angular.isArray(val) ? val[index || 0] : val;
-          str = str.replace(new RegExp(_.escapeRegExp('[' + key + ']', 'g')), replacement);
-        }
-      });
-      return str;
-    }
-
-    function getUrl(link, rowData, index) {
-      var url = replaceTokens(link, rowData, false, index);
-      if (url.slice(0, 1) !== '/' && url.slice(0, 4) !== 'http') {
-        url = CRM.url(url);
-      }
-      return url;
-    }
-
-    // Returns display value for a single column in a row
-    function formatDisplayValue(rowData, key, columns) {
-      var column = _.findWhere(columns, {key: key}),
-        displayValue = column.rewrite ? replaceTokens(column.rewrite, rowData, columns) : getValue(rowData[key], 'view');
-      return angular.isArray(displayValue) ? displayValue.join(', ') : displayValue;
-    }
-
-    // Returns value and url for a column formatted as link(s)
-    function formatLinks(rowData, key, columns) {
-      var column = _.findWhere(columns, {key: key}),
-        value = column.image ? '' : getValue(rowData[key], 'view'),
-        values = angular.isArray(value) ? value : [value],
-        links = [];
-      _.each(values, function(value, index) {
-        links.push({
-          value: value,
-          url: getUrl(column.link.path, rowData, index)
-        });
-      });
-      return links;
-    }
+      runCount = 0;
 
     // Get value from column data, specify either 'raw' or 'view'
     function getValue(data, ret) {
@@ -63,7 +16,6 @@
     return {
       page: 1,
       rowCount: null,
-      getUrl: getUrl,
       // Arrays may contain callback functions for various events
       onChangeFilters: [],
       onPreRun: [],
           });
         });
       },
-      replaceTokens: function(value, row) {
-        return replaceTokens(value, row, this.settings.columns);
-      },
-      getLinks: function(rowData, col) {
-        rowData._links = rowData._links || {};
-        if (!(col.key in rowData._links)) {
-          rowData._links[col.key] = formatLinks(rowData, col.key, this.settings.columns);
-        }
-        return rowData._links[col.key];
-      },
-      formatFieldValue: function(rowData, col) {
-        return formatDisplayValue(rowData, col.key, this.settings.columns);
+      formatFieldValue: function(colData) {
+        return angular.isArray(colData.val) ? colData.val.join(', ') : colData.val;
       }
     };
   });
index bb908bfbb605bcc6476923b824233ba6b5447082..a413ad1cf606a44dfb81ff33fcba49dd23fbadb1 100644 (file)
@@ -1,8 +1,8 @@
 <div ng-repeat="(rowIndex, row) in $ctrl.results">
-  <div ng-repeat="col in $ctrl.settings.columns" title="{{:: $ctrl.replaceTokens(col.title, row) }}" class="{{:: col.break ? '' : 'crm-inline-block' }}">
-    <label ng-if=":: col.label && (col.type !== 'field' || col.forceLabel || row[col.key])">
-      {{:: $ctrl.replaceTokens(col.label, row) }}
+  <div ng-repeat="(colIndex, colData) in row.columns" title="{{:: colData.title }}" class="{{:: colData.cssClass }} {{:: $ctrl.settings.columns[colIndex].break ? '' : 'crm-inline-block' }}">
+    <label ng-if=":: colData.label">
+      {{:: colData.label }}
     </label>
-    <span ng-include="'~/crmSearchDisplay/colType/' + col.type + '.html'"></span>
+    <span ng-include="'~/crmSearchDisplay/colType/' + $ctrl.settings.columns[colIndex].type + '.html'"></span>
   </div>
 </div>
index 50d063707413701b0bee840c3c347b21f020d26a..c618794ee5917ad4ffacf025708eb971c356b112 100644 (file)
@@ -1,8 +1,8 @@
 <li ng-repeat="(rowIndex, row) in $ctrl.results">
-  <div ng-repeat="col in $ctrl.settings.columns" title="{{:: $ctrl.replaceTokens(col.title, row) }}" class="{{:: col.break ? '' : 'crm-inline-block' }}">
-    <label ng-if=":: col.label && (col.type !== 'field' || col.forceLabel || row[col.key])">
-      {{:: $ctrl.replaceTokens(col.label, row) }}
+  <div ng-repeat="(colIndex, colData) in row.columns" title="{{:: colData.title }}" class="{{:: colData.cssClass }} {{:: $ctrl.settings.columns[colIndex].break ? '' : 'crm-inline-block' }}">
+    <label ng-if=":: colData.label">
+      {{:: colData.label }}
     </label>
-    <span ng-include="'~/crmSearchDisplay/colType/' + col.type + '.html'"></span>
+    <span ng-include="'~/crmSearchDisplay/colType/' + $ctrl.settings.columns[colIndex].type + '.html'"></span>
   </div>
 </li>
index af14b31abe17e68b970c8a233aa27779c8665a27..64a0a2af462343a2fc128e374de55a25e1e6b8c0 100644 (file)
@@ -2,7 +2,7 @@
   <td ng-if=":: $ctrl.settings.actions">
     <input type="checkbox" ng-checked="$ctrl.isRowSelected(row)" ng-click="$ctrl.selectRow(row)" ng-disabled="!(!$ctrl.loadingAllRows && row.id)">
   </td>
-  <td ng-repeat="col in $ctrl.settings.columns" ng-include="'~/crmSearchDisplay/colType/' + col.type + '.html'" title="{{:: $ctrl.replaceTokens(col.title, row) }}" class="{{:: col.alignment }}">
+  <td ng-repeat="(colIndex, colData) in row.columns" ng-include="'~/crmSearchDisplay/colType/' + $ctrl.settings.columns[colIndex].type + '.html'" title="{{:: colData.title }}" class="{{:: colData.cssClass }}">
   </td>
 </tr>
 <tr ng-if="$ctrl.rowCount === 0">
index b78a189cf94a024bb59ce550b7a8c3a69cade662..a233b127d0caff26e9ba4cf5e30991a0f4e0a782 100644 (file)
@@ -37,9 +37,9 @@
 
       // Toggle row selection
       selectRow: function(row) {
-        var index = this.selectedRows.indexOf(row.id.raw);
+        var index = this.selectedRows.indexOf(row.data.id);
         if (index < 0) {
-          this.selectedRows.push(row.id.raw);
+          this.selectedRows.push(row.data.id);
           this.allRowsSelected = (this.rowCount === this.selectedRows.length);
         } else {
           this.allRowsSelected = false;
@@ -49,7 +49,7 @@
 
       // @return bool
       isRowSelected: function(row) {
-        return this.allRowsSelected || _.includes(this.selectedRows, row.id.raw);
+        return this.allRowsSelected || _.includes(this.selectedRows, row.data.id);
       },
 
       refreshAfterTask: function() {
@@ -69,8 +69,8 @@
       onPostRun: [function(results, status, editedRow) {
         if (editedRow && status === 'success') {
           // If edited row disappears (because edits cause it to not meet search criteria), deselect it
-          var index = this.selectedRows.indexOf(editedRow.id.raw);
-          if (index > -1 && !_.findWhere(results, {id: editedRow.id.raw})) {
+          var index = this.selectedRows.indexOf(editedRow.data.id);
+          if (index > -1 && !_.findWhere(results, {id: editedRow.data.id})) {
             this.selectedRows.splice(index, 1);
           }
         }
index bd642f4b57dabf8dc27bf180197107238ce099aa..df3c3ffffa90fb79c4d2d0ad75cb330cf7fb5b77 100644 (file)
@@ -70,7 +70,7 @@ class SearchDownloadTest extends \PHPUnit\Framework\TestCase implements Headless
     $download = (array) civicrm_api4('SearchDisplay', 'download', $params);
     $header = array_shift($download);
 
-    $this->assertEquals('First Last', $header[0]['label']);
+    $this->assertEquals('First Last', $header[0]);
 
     foreach ($download as $rowNum => $data) {
       $this->assertEquals($sampleData[$rowNum]['first_name'] . ' ' . $lastName, $data[0]);
index 20158ed149ceb4464a6e1a97c74b8a0b4234d017..b1cb6cc0bb0da3dc4a9fa6bcede0e77a83f6c7d7 100644 (file)
@@ -87,6 +87,12 @@ class SearchRunTest extends \PHPUnit\Framework\TestCase implements HeadlessInter
               'dataType' => 'String',
               'type' => 'field',
             ],
+            [
+              'key' => 'is_deceased',
+              'label' => 'Deceased',
+              'dataType' => 'Boolean',
+              'type' => 'field',
+            ],
           ],
           'sort' => [
             ['id', 'ASC'],
@@ -103,19 +109,19 @@ class SearchRunTest extends \PHPUnit\Framework\TestCase implements HeadlessInter
     $params['filters']['first_name'] = ['One', 'Two'];
     $result = civicrm_api4('SearchDisplay', 'run', $params);
     $this->assertCount(2, $result);
-    $this->assertEquals('One', $result[0]['first_name']['raw']);
-    $this->assertEquals('Two', $result[1]['first_name']['raw']);
+    $this->assertEquals('One', $result[0]['data']['first_name']);
+    $this->assertEquals('Two', $result[1]['data']['first_name']);
 
     // Raw value should be boolean, view value should be string
-    $this->assertEquals(FALSE, $result[0]['is_deceased']['raw']);
-    $this->assertEquals(ts('No'), $result[0]['is_deceased']['view']);
+    $this->assertEquals(FALSE, $result[0]['data']['is_deceased']);
+    $this->assertEquals(ts('No'), $result[0]['columns'][4]['val']);
 
-    $params['filters'] = ['last_name' => $lastName, 'id' => ['>' => $result[0]['id']['raw'], '<=' => $result[1]['id']['raw'] + 1]];
+    $params['filters'] = ['last_name' => $lastName, 'id' => ['>' => $result[0]['data']['id'], '<=' => $result[1]['data']['id'] + 1]];
     $params['sort'] = [['first_name', 'ASC']];
     $result = civicrm_api4('SearchDisplay', 'run', $params);
     $this->assertCount(2, $result);
-    $this->assertEquals('Three', $result[0]['first_name']['raw']);
-    $this->assertEquals('Two', $result[1]['first_name']['raw']);
+    $this->assertEquals('Three', $result[0]['data']['first_name']);
+    $this->assertEquals('Two', $result[1]['data']['first_name']);
 
     $params['filters'] = ['last_name' => $lastName, 'contact_sub_type:label' => ['Tester', 'Bot']];
     $result = civicrm_api4('SearchDisplay', 'run', $params);
@@ -186,9 +192,9 @@ class SearchRunTest extends \PHPUnit\Framework\TestCase implements HeadlessInter
 
     $result = civicrm_api4('SearchDisplay', 'run', $params);
     $this->assertCount(2, $result);
-    $this->assertNotEmpty($result->first()['display_name']['raw']);
+    $this->assertNotEmpty($result->first()['data']['display_name']);
     // Assert that display name was added to the search due to the link token
-    $this->assertNotEmpty($result->first()['sort_name']['raw']);
+    $this->assertNotEmpty($result->first()['data']['sort_name']);
 
     // These items are not part of the search, but will be added via links
     $this->assertArrayNotHasKey('contact_type', $result->first());
@@ -205,9 +211,9 @@ class SearchRunTest extends \PHPUnit\Framework\TestCase implements HeadlessInter
       ],
     ];
     $result = civicrm_api4('SearchDisplay', 'run', $params);
-    $this->assertEquals('Individual', $result->first()['contact_type']['raw']);
-    $this->assertEquals('Unit test', $result->first()['source']['raw']);
-    $this->assertEquals($lastName, $result->first()['last_name']['raw']);
+    $this->assertEquals('Individual', $result->first()['data']['contact_type']);
+    $this->assertEquals('Unit test', $result->first()['data']['source']);
+    $this->assertEquals($lastName, $result->first()['data']['last_name']);
   }
 
   /**
@@ -304,14 +310,14 @@ class SearchRunTest extends \PHPUnit\Framework\TestCase implements HeadlessInter
     $this->cleanupCachedPermissions();
     $result = civicrm_api4('SearchDisplay', 'run', $params);
     $this->assertCount(1, $result);
-    $this->assertEquals($sampleData['Two'], $result[0]['id']['raw']);
+    $this->assertEquals($sampleData['Two'], $result[0]['data']['id']);
 
     $hooks->setHook('civicrm_aclWhereClause', [$this, 'aclWhereGreaterThan']);
     $this->cleanupCachedPermissions();
     $result = civicrm_api4('SearchDisplay', 'run', $params);
     $this->assertCount(2, $result);
-    $this->assertEquals($sampleData['Three'], $result[0]['id']['raw']);
-    $this->assertEquals($sampleData['Four'], $result[1]['id']['raw']);
+    $this->assertEquals($sampleData['Three'], $result[0]['data']['id']);
+    $this->assertEquals($sampleData['Four'], $result[1]['data']['id']);
   }
 
   public function testWithACLBypass() {