SearchKit - Use server-side preprocessing for editable fields
authorColeman Watts <coleman@civicrm.org>
Sat, 23 Oct 2021 22:02:44 +0000 (18:02 -0400)
committerColeman Watts <coleman@civicrm.org>
Tue, 26 Oct 2021 19:55:28 +0000 (15:55 -0400)
Civi/Api4/Generic/AbstractAction.php
Civi/Api4/Query/Api4SelectQuery.php
ext/search_kit/Civi/Api4/Action/SearchDisplay/AbstractRunAction.php
ext/search_kit/Civi/Search/Admin.php
ext/search_kit/ang/crmSearchAdmin/crmSearchAdminDisplay.component.js
ext/search_kit/ang/crmSearchAdmin/displays/colType/field.html
ext/search_kit/ang/crmSearchDisplay/colType/field.html
ext/search_kit/ang/crmSearchDisplay/crmSearchDisplayEditable.component.js

index 08daf23954f7bd9336a4249e019380ad35ea0113..a531cddc72d4ae640b21fc449a4167519f71152e 100644 (file)
@@ -430,9 +430,6 @@ abstract class AbstractAction implements \ArrayAccess {
   public function entityFields() {
     if (!$this->_entityFields) {
       $allowedTypes = ['Field', 'Filter', 'Extra'];
-      if (method_exists($this, 'getCustomGroup')) {
-        $allowedTypes[] = 'Custom';
-      }
       $getFields = \Civi\API\Request::create($this->getEntityName(), 'getFields', [
         'version' => 4,
         'checkPermissions' => FALSE,
index ff1590c32e7eacd57b25f783804fdf60339121d3..aee952cc6b7ecd32ce6aabf063f0b5d6820ea778 100644 (file)
@@ -722,12 +722,14 @@ class Api4SelectQuery {
       $joinEntityFields = $joinEntityGet->entityFields();
       foreach ($joinEntityFields as $field) {
         $field['sql_name'] = '`' . $alias . '`.`' . $field['column_name'] . '`';
+        $field['explicit_join'] = $alias;
         $this->addSpecField($alias . '.' . $field['name'], $field);
       }
       $tableName = CoreUtil::getTableName($entity);
       // Save join info to be retrieved by $this->getExplicitJoin()
       $this->explicitJoins[$alias] = [
         'entity' => $entity,
+        'alias' => $alias,
         'table' => $tableName,
         'bridge' => NULL,
       ];
@@ -938,6 +940,7 @@ class Api4SelectQuery {
       // For INNER joins, these fields get a sql alias pointing to the bridge entity,
       // but an api alias pretending they belong to the join entity.
       $field['sql_name'] = '`' . ($side === 'LEFT' ? $alias : $bridgeAlias) . '`.`' . $field['column_name'] . '`';
+      $field['explicit_join'] = $alias;
       $this->addSpecField($alias . '.' . $name, $field);
       if ($field['type'] === 'Field') {
         $fakeFields[$field['column_name']] = '`' . $bridgeAlias . '`.`' . $field['column_name'] . '`';
@@ -1094,6 +1097,8 @@ class Api4SelectQuery {
           else {
             $fieldArray['sql_name'] = '`' . $baseTableAlias . '`.`' . $link->getBaseColumn() . '`';
           }
+          $fieldArray['implicit_join'] = $link->getBaseColumn();
+          $fieldArray['explicit_join'] = $explicitJoin ? $explicitJoin['alias'] : NULL;
           // Custom fields will already have the group name prefixed
           $fieldName = $isCustom ? explode('.', $fieldArray['name'])[1] : $fieldArray['name'];
           $this->addSpecField($joinTreeNode[$joinName]['#path'] . $fieldName, $fieldArray);
@@ -1252,7 +1257,10 @@ class Api4SelectQuery {
       $this->apiFieldSpec[$path] = FALSE;
       return;
     }
-    $this->apiFieldSpec[$path] = $field;
+    $this->apiFieldSpec[$path] = $field + [
+      'implicit_join' => NULL,
+      'explicit_join' => NULL,
+    ];
   }
 
   /**
index a9e48d58c1129b4846ef91b3c3ab6e36df41706e..d807e3447d617e23e272b8dcf4653ebf5125bfd2 100644 (file)
@@ -275,10 +275,64 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction {
     return \CRM_Utils_System::url($path, NULL, $absolute, NULL, FALSE);
   }
 
+  /**
+   * @param $column
+   * @param $data
+   * @return array{entity: string, input_type: string, data_type: string, options: bool, serialize: bool, fk_entity: string, value_key: string, record: array, value: mixed}|null
+   */
   private function formatEditableColumn($column, $data) {
+    $editable = $this->getEditableInfo($column['key']);
+    if (!empty($data[$editable['id_path']])) {
+      $editable['record'] = [
+        $editable['id_key'] => $data[$editable['id_path']],
+      ];
+      $editable['value'] = $data[$editable['value_path']];
+      \CRM_Utils_Array::remove($editable, 'id_key', 'id_path', 'value_path');
+      return $editable;
+    }
+    return NULL;
+  }
 
+  /**
+   * @param $key
+   * @return array{entity: string, input_type: string, data_type: string, options: bool, serialize: bool, fk_entity: string, value_key: string, value_path: string, id_key: string, id_path: string}|null
+   */
+  private function getEditableInfo($key) {
+    [$key] = explode(':', $key);
+    $field = $this->getField($key);
+    // If field is an implicit join, use the original fk field
+    if (!empty($field['implicit_join'])) {
+      return $this->getEditableInfo(substr($key, 0, -1 - strlen($field['name'])));
+    }
+    if ($field) {
+      $idKey = CoreUtil::getIdFieldName($field['entity']);
+      $idPath = ($field['explicit_join'] ? $field['explicit_join'] . '.' : '') . $idKey;
+      // Hack to support editing relationships
+      if ($field['entity'] === 'RelationshipCache') {
+        $field['entity'] = 'Relationship';
+        $idPath = ($field['explicit_join'] ? $field['explicit_join'] . '.' : '') . 'relationship_id';
+      }
+      return [
+        'entity' => $field['entity'],
+        'input_type' => $field['input_type'],
+        'data_type' => $field['data_type'],
+        'options' => !empty($field['options']),
+        'serialize' => !empty($field['serialize']),
+        'fk_entity' => $field['fk_entity'],
+        'value_key' => $field['name'],
+        'value_path' => $key,
+        'id_key' => $idKey,
+        'id_path' => $idPath,
+      ];
+    }
+    return NULL;
   }
 
+  /**
+   * @param $column
+   * @param $data
+   * @return array{url: string, width: int, height: int}
+   */
   private function formatImage($column, $data) {
     $tokenExpr = $column['rewrite'] ?: '[' . $column['key'] . ']';
     return [
@@ -575,10 +629,13 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction {
         $possibleTokens .= implode('', array_column($column['links'], 'text'));
       }
 
-      // Select value fields for in-place editing
-      if (isset($column['editable']['value'])) {
-        $additions[] = $column['editable']['value'];
-        $additions[] = $column['editable']['id'];
+      // Select id & value for in-place editing
+      if (!empty($column['editable'])) {
+        $editable = $this->getEditableInfo($column['key']);
+        if ($editable) {
+          $additions[] = $editable['value_path'];
+          $additions[] = $editable['id_path'];
+        }
       }
     }
     // Add fields referenced via token
index 1e05fa1a57174896e05a59609c703aa2070766dc..5a912e3c7d68eaa453de040300d241c6280efc85 100644 (file)
@@ -122,7 +122,6 @@ class Admin {
           $field['fieldName'] = $field['name'];
           // Hack for RelationshipCache to make Relationship fields editable
           if ($entity['name'] === 'RelationshipCache') {
-            $entity['primary_key'] = ['relationship_id'];
             if (in_array($field['name'], ['is_active', 'start_date', 'end_date'])) {
               $field['readonly'] = FALSE;
             }
index 22219d66720db11cbdc25e25d1af2b053ded533d..9aea30e3d93a8a0f82f7e8e7571c6303898dddd6 100644 (file)
       this.toggleEditable = function(col) {
         if (col.editable) {
           delete col.editable;
-          return;
-        }
-
-        var info = searchMeta.parseExpr(col.key),
-          arg = _.findWhere(info.args, {type: 'field'}) || {},
-          value = col.key.split(':')[0];
-        if (!arg.field || info.fn) {
-          delete col.editable;
-          return;
-        }
-        // If field is an implicit join, use the original fk field
-        if (arg.field.name !== arg.field.fieldName) {
-          value = value.substr(0, value.lastIndexOf('.'));
-          info = searchMeta.parseExpr(value);
-          arg = info.args[0];
+        } else {
+          col.editable = true;
         }
-        col.editable = {
-          // Hack to support editing relationships
-          entity: arg.field.entity.replace('RelationshipCache', 'Relationship'),
-          input_type: arg.field.input_type,
-          data_type: arg.field.data_type,
-          options: !!arg.field.options,
-          serialize: !!arg.field.serialize,
-          fk_entity: arg.field.fk_entity,
-          id: arg.prefix + searchMeta.getEntity(arg.field.entity).primary_key[0],
-          name: arg.field.name,
-          value: value
-        };
       };
 
-      this.isEditable = function(col) {
+      this.canBeEditable = function(col) {
         var expr = ctrl.getExprFromSelect(col.key),
           info = searchMeta.parseExpr(expr);
         return !col.image && !col.rewrite && !col.link && !info.fn && info.args[0] && info.args[0].field && !info.args[0].field.readonly;
         if (column.link) {
           ctrl.onChangeLink(column, column.link.path, '');
         } else {
+          delete column.editable;
           var defaultLink = ctrl.getLinks(column.key)[0];
           column.link = {path: defaultLink ? defaultLink.path : 'civicrm/'};
           ctrl.onChangeLink(column, null, column.link.path);
index 801c3d310830eec7a3759e8715339dfcf406ebbd..414eba6b30fc0cd2fede34c3bdcfafaae5992bae 100644 (file)
   <crm-search-admin-token-select ng-if="col.rewrite" model="col" field="rewrite" suffix=":label"></crm-search-admin-token-select>
 </div>
 <div class="form-inline">
-  <label ng-if="$ctrl.parent.isEditable(col)" title="{{:: ts('Users will be able to click to edit this field.') }}">
+  <label ng-if="$ctrl.parent.canBeEditable(col)" title="{{:: ts('Users will be able to click to edit this field.') }}">
     <input type="checkbox" ng-checked="col.editable" ng-click="$ctrl.parent.toggleEditable(col)">
     {{:: ts('In-Place Edit') }}
   </label>
-  <label ng-if="!$ctrl.parent.isEditable(col)" class="disabled" title="{{:: ts('Read-only or rewritten fields cannot be editable.') }}">
+  <label ng-if="!$ctrl.parent.canBeEditable(col)" class="disabled" title="{{:: ts('Read-only or rewritten fields cannot be editable.') }}">
     <input type="checkbox" disabled>
     {{:: ts('In-Place Edit') }}
   </label>
index faab99654f06c375d12549655dc5778ca5a05467..e39d7e215e7c51ae7d985643fe340d31edab231d 100644 (file)
@@ -1,5 +1,5 @@
-<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])">
+<crm-search-display-editable row="row" col="colData" on-success="$ctrl.runSearch(row)" cancel="$ctrl.editing = null;" ng-if="colData.edit && $ctrl.editing && $ctrl.editing[0] === rowIndex && $ctrl.editing[1] === colIndex"></crm-search-display-editable>
+<span ng-if="::!colData.links && !colData.img" ng-class="{'crm-editable-enabled': colData.edit && !$ctrl.editing}" ng-click="colData.edit && !$ctrl.editing && ($ctrl.editing = [rowIndex, colIndex])">
   {{:: $ctrl.formatFieldValue(colData) }}
 </span>
 <span ng-if="::colData.links">
index 55cb13b943c70deb5f9acb715a5d6d4e847844d4..2df08087f87561793818d9e30fd52b1c68ebdd01 100644 (file)
 
       this.$onInit = function() {
         col = this.col;
-        this.value = _.cloneDeep(this.row[col.editable.value].raw);
-        initialValue = _.cloneDeep(this.row[col.editable.value].raw);
+        this.value = _.cloneDeep(col.edit.value);
+        initialValue = _.cloneDeep(col.edit.value);
 
         this.field = {
-          data_type: col.editable.data_type,
-          input_type: col.editable.input_type,
-          name: col.editable.name,
-          options: col.editable.options,
-          fk_entity: col.editable.fk_entity,
-          serialize: col.editable.serialize,
+          data_type: col.edit.data_type,
+          input_type: col.edit.input_type,
+          name: col.edit.value_key,
+          options: col.edit.options,
+          fk_entity: col.edit.fk_entity,
+          serialize: col.edit.serialize,
         };
 
         $(document).on('keydown.crmSearchDisplayEditable', function(e) {
           ctrl.cancel();
           return;
         }
-        var values = {id: ctrl.row[col.editable.id].raw};
-        values[col.editable.name] = ctrl.value;
+        var record = _.cloneDeep(col.edit.record);
+        record[col.edit.value_key] = ctrl.value;
         $('input', $element).attr('disabled', true);
-        crmStatus({}, crmApi4(col.editable.entity, 'update', {
-          values: values
+        crmStatus({}, crmApi4(col.edit.entity, 'update', {
+          values: record
         })).then(ctrl.onSuccess);
       };
 
       function loadOptions() {
-        var cacheKey = col.editable.entity + ' ' + ctrl.field.name;
+        var cacheKey = col.edit.entity + ' ' + ctrl.field.name;
         if (optionsCache[cacheKey]) {
           ctrl.field.options = optionsCache[cacheKey];
           return;
         }
-        crmApi4(col.editable.entity, 'getFields', {
+        crmApi4(col.edit.entity, 'getFields', {
           action: 'update',
           select: ['options'],
           loadOptions: ['id', 'name', 'label', 'description', 'color', 'icon'],