SearchKit - Add "in-place edit" feature
authorColeman Watts <coleman@civicrm.org>
Tue, 9 Mar 2021 15:42:31 +0000 (10:42 -0500)
committerColeman Watts <coleman@civicrm.org>
Tue, 16 Mar 2021 06:02:19 +0000 (02:02 -0400)
17 files changed:
css/civicrm.css
ext/search/Civi/Api4/Action/SearchDisplay/Run.php
ext/search/Civi/Search/Admin.php
ext/search/ang/crmSearchActions/crmSearchActionUpdate.ctrl.js
ext/search/ang/crmSearchAdmin/crmSearchAdminDisplay.component.js
ext/search/ang/crmSearchAdmin/crmSearchAdminLinkSelect.component.js
ext/search/ang/crmSearchAdmin/displays/colType/field.html
ext/search/ang/crmSearchDisplay.module.js
ext/search/ang/crmSearchDisplay/colType/field.html
ext/search/ang/crmSearchDisplay/crmSearchDisplayEditable.component.js [new file with mode: 0644]
ext/search/ang/crmSearchDisplay/crmSearchDisplayEditable.html [new file with mode: 0644]
ext/search/ang/crmSearchDisplayList/crmSearchDisplayList.component.js
ext/search/ang/crmSearchDisplayList/crmSearchDisplayList.html
ext/search/ang/crmSearchDisplayList/crmSearchDisplayListItems.html
ext/search/ang/crmSearchDisplayTable/crmSearchDisplayTable.component.js
ext/search/ang/crmSearchDisplayTable/crmSearchDisplayTable.html
ext/search/css/crmSearchActions.css

index 9a4ae97a4e6c79e57ac5cb80dd760a422baa118f..c928b5fb5ba693cfbcb13ffb652fbceb81c1417f 100644 (file)
@@ -3358,6 +3358,9 @@ span.crm-select-item-color {
 }
 .crm-container span.crm-editable-enabled {
   display: inline-block !important;
+  padding-right: 2px;
+  min-height: 1em;
+  min-width: 3em;
 }
 
 .crm-container .crm-editable-enabled .crm-i {
index 9a890b6aa882655b196b8146525372d070e6b737..7ad2c72c9c1f69848639b5f897c0adbd84d221ab 100644 (file)
@@ -134,6 +134,12 @@ class Run extends \Civi\Api4\Generic\AbstractAction {
             $apiParams['select'][] = $idField;
           }
         }
+        // Select value fields for in-place editing
+        foreach ($settings['columns'] ?? [] as $column) {
+          if (isset($column['editable']['value']) && !in_array($column['editable']['value'], $apiParams['select'])) {
+            $apiParams['select'][] = $column['editable']['value'];
+          }
+        }
     }
 
     $this->applyFilters();
index bcb73ca433297b0b697aa2f2f5bad9d28bd08daa..5b77712b89887fd971bf350a6a29031287241713 100644 (file)
@@ -87,7 +87,7 @@ class Admin {
       ->setChain([
         'get' => ['$name', 'getActions', ['where' => [['name', '=', 'get']]], ['params']],
       ])->execute();
-    $getFields = ['name', 'title', 'label', 'description', 'options', 'input_type', 'input_attrs', 'data_type', 'serialize', 'entity', 'fk_entity'];
+    $getFields = ['name', 'title', 'label', 'description', 'options', 'input_type', 'input_attrs', 'data_type', 'serialize', 'entity', 'fk_entity', 'readonly'];
     foreach ($entities as $entity) {
       // Skip if entity doesn't have a 'get' action or the user doesn't have permission to use get
       if ($entity['get']) {
index 2297be29502f1476b889bd2888048d70fcf6a534..55f704e0b3759c37dea5a4c32c0c054071a7ad02 100644 (file)
@@ -13,6 +13,7 @@
 
     crmApi4(model.entity, 'getFields', {
       action: 'update',
+      select: ['name', 'label', 'description', 'data_type', 'serialize', 'options'],
       loadOptions: ['id', 'name', 'label', 'description', 'color', 'icon'],
       where: [["readonly", "=", false]],
     }).then(function(fields) {
index 2e1e07654c7bb9b140a459d2d49a53b7bda2729e..560445ff53f26b18eadd12e3b8ab40f4dec51682 100644 (file)
         return ctrl.colTypes[col.type].label;
       };
 
+      this.toggleRewrite = function(col) {
+        if (col.rewrite) {
+          col.rewrite = '';
+        } else {
+          col.rewrite = '[' + col.key + ']';
+          delete col.editable;
+        }
+      };
+
+      this.toggleEditable = function(col) {
+        if (col.editable) {
+          delete col.editable;
+          return;
+        }
+
+        var info = searchMeta.parseExpr(col.key),
+          value = col.key.split(':')[0];
+        // If field is an implicit join, use the original fk field
+        if (info.field.entity !== info.field.baseEntity) {
+          value = value.substr(0, value.indexOf('.')) + '_id';
+          info = searchMeta.parseExpr(value);
+        }
+        col.editable = {
+          entity: info.field.baseEntity,
+          options: !!info.field.options,
+          serialize: !!info.field.serialize,
+          fk_entity: info.field.fk_entity,
+          id: info.prefix + 'id',
+          name: info.field.name,
+          value: value
+        };
+      };
+
+      this.isEditable = function(col) {
+        var expr = ctrl.getExprFromSelect(col.key),
+          info = searchMeta.parseExpr(expr);
+        return !col.rewrite && !col.link && !info.fn && info.field && !info.field.readonly;
+      };
+
       function fieldToColumn(fieldExpr, defaults) {
         var info = searchMeta.parseExpr(fieldExpr),
           values = _.cloneDeep(defaults);
               var idField = info.field.fk_entity ? fieldName : fieldName.substr(0, fieldName.lastIndexOf('.')) + '_id';
               if (!ctrl.crmSearchAdmin.canAggregate(idField)) {
                 var joinEntity = searchMeta.getEntity(info.field.fk_entity || info.field.entity);
-                _.each(joinEntity.paths, function(path) {
+                _.each((joinEntity || {}).paths, function(path) {
                   var link = _.cloneDeep(path);
                   link.path = link.path.replace(/\[id/g, '[' + idField);
                   links.push(link);
index 394f8158e4783e6fc9c89e769d6329a762e48ce3..eb8604e9e94d82c7bd733f95ba30a409c2f21a53 100644 (file)
@@ -19,6 +19,7 @@
         if (link) {
           ctrl.column.link = link.path;
           ctrl.column.title = link.title;
+          delete ctrl.column.editable;
         } else {
           if (val === 'civicrm/') {
             ctrl.column.link = val;
index 20e1976e79a0e1d649c45c3a22a8d86b63ee54d7..5960cc5ef22a4cd5edc04f14fa87dbb77a75171c 100644 (file)
@@ -9,9 +9,19 @@
 </div>
 <div class="form-inline crm-search-admin-flex-row">
   <label title="{{ ts('Change the contents of this field, or combine multiple field values.') }}">
-    <input type="checkbox" ng-checked="col.rewrite" ng-click="col.rewrite = col.rewrite ? null : '['+col.key+']'" >
+    <input type="checkbox" ng-checked="col.rewrite" ng-click="$ctrl.parent.toggleRewrite(col)" >
     {{ col.rewrite ? ts('Rewrite:') : ts('Rewrite') }}
   </label>
   <input type="text" class="form-control" ng-if="col.rewrite" ng-model="col.rewrite" ng-model-options="{updateOn: 'blur'}">
   <crm-search-admin-token-select ng-if="col.rewrite" api-entity="$ctrl.apiEntity" api-params="$ctrl.apiParams" model="col" field="rewrite"></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.') }}">
+    <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.') }}">
+    <input type="checkbox" disabled>
+    {{:: ts('In-place edit') }}
+  </label>
+</div>
index 7c134b9f4741f9a6a76cd82f55c00fd55b3b99a1..a8f7cb8501c12c70359ad2f8f0eeb8ce1994771d 100644 (file)
@@ -74,9 +74,9 @@
       }
 
       function getResults(ctrl) {
-        var params = getApiParams(ctrl);
-        crmApi4('SearchDisplay', 'run', params).then(function(results) {
+        return crmApi4('SearchDisplay', 'run', getApiParams(ctrl)).then(function(results) {
           ctrl.results = results;
+          ctrl.editing = false;
           if (ctrl.settings.pager && !ctrl.rowCount) {
             if (results.length < ctrl.settings.limit) {
               ctrl.rowCount = results.length;
index 1c6106010025ccc5336ee02dfa89700fd92f35ca..4f21543abf02a38e9f307be2f40565087b8ef815 100644 (file)
@@ -1 +1,2 @@
-<span ng-bind-html="$ctrl.formatFieldValue(row, col)"></span>
+<crm-search-display-editable row="row" col="col" on-success="$ctrl.refresh(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-bind-html=":: $ctrl.formatFieldValue(row, col)" ng-class="{'crm-editable-enabled': col.editable && !$ctrl.editing && row[col.editable.id]}" ng-click="col.editable && !$ctrl.editing && ($ctrl.editing = [rowIndex, col.key])"></span>
diff --git a/ext/search/ang/crmSearchDisplay/crmSearchDisplayEditable.component.js b/ext/search/ang/crmSearchDisplay/crmSearchDisplayEditable.component.js
new file mode 100644 (file)
index 0000000..181c0d2
--- /dev/null
@@ -0,0 +1,83 @@
+// https://civicrm.org/licensing
+(function(angular, $, _) {
+  "use strict";
+
+  var optionsCache = {};
+
+  angular.module('crmSearchDisplay').component('crmSearchDisplayEditable', {
+    bindings: {
+      row: '<',
+      col: '<',
+      cancel: '&',
+      onSuccess: '&'
+    },
+    templateUrl: '~/crmSearchDisplay/crmSearchDisplayEditable.html',
+    controller: function($scope, $element, crmApi4, crmStatus) {
+      var ctrl = this,
+        initialValue,
+        col;
+
+      this.$onInit = function() {
+        col = this.col;
+        this.value = _.cloneDeep(this.row[col.editable.value]);
+        initialValue = _.cloneDeep(this.row[col.editable.value]);
+
+        this.field = {
+          data_type: col.dataType,
+          name: col.editable.name,
+          options: col.editable.options,
+          fk_entity: col.editable.fk_entity,
+          serialize: col.editable.serialize,
+        };
+
+        $(document).on('keydown.crmSearchDisplayEditable', function(e) {
+          if (e.key === 'Escape') {
+            $scope.$apply(function() {
+              ctrl.cancel();
+            });
+          } else if (e.key === 'Enter') {
+            $scope.$apply(ctrl.save);
+          }
+        });
+
+        if (this.field.options === true) {
+          loadOptions();
+        }
+      };
+
+      this.$onDestroy = function() {
+        $(document).off('.crmSearchDisplayEditable');
+      };
+
+      this.save = function() {
+        if (ctrl.value === initialValue) {
+          ctrl.cancel();
+          return;
+        }
+        var values = {id: ctrl.row[col.editable.id]};
+        values[col.editable.name] = ctrl.value;
+        $('input', $element).attr('disabled', true);
+        crmStatus({}, crmApi4(col.editable.entity, 'update', {
+          values: values
+        })).then(ctrl.onSuccess);
+      };
+
+      function loadOptions() {
+        var cacheKey = col.editable.entity + ' ' + ctrl.field.name;
+        if (optionsCache[cacheKey]) {
+          ctrl.field.options = optionsCache[cacheKey];
+          return;
+        }
+        crmApi4(col.editable.entity, 'getFields', {
+          action: 'update',
+          select: ['options'],
+          loadOptions: ['id', 'name', 'label', 'description', 'color', 'icon'],
+          where: [['name', '=', ctrl.field.name]],
+        }, 0).then(function(field) {
+          ctrl.field.options = optionsCache[cacheKey] = field.options;
+        });
+      }
+    }
+  });
+
+})(angular, CRM.$, CRM._);
diff --git a/ext/search/ang/crmSearchDisplay/crmSearchDisplayEditable.html b/ext/search/ang/crmSearchDisplay/crmSearchDisplayEditable.html
new file mode 100644 (file)
index 0000000..d0d8eca
--- /dev/null
@@ -0,0 +1,9 @@
+<crm-search-input class="form-inline" field="$ctrl.field" ng-model="$ctrl.value"></crm-search-input>
+<div class="form-inline crm-search-display-editable-buttons">
+  <button type="button" ng-click="$ctrl.save()" class="btn btn-xs btn-success">
+    <i class="crm-i fa-check"></i>
+  </button>
+  <button type="button" ng-click="$ctrl.cancel()" class="btn btn-xs btn-danger">
+    <i class="crm-i fa-times"></i>
+  </button>
+</div>
index 3245db91c2803fb59dc1514b7d4db1559c0a302a..2153e6dc1e920caa8ce802c28a0983f50c321173 100644 (file)
         $scope.displayUtils = searchDisplayUtils;
 
         if (this.afFieldset) {
-          $scope.$watch(this.afFieldset.getFieldData, refresh, true);
+          $scope.$watch(this.afFieldset.getFieldData, onChangeFilters, true);
         }
-        $scope.$watch('$ctrl.filters', refresh, true);
+        $scope.$watch('$ctrl.filters', onChangeFilters, true);
       };
 
       this.getResults = _.debounce(function() {
         searchDisplayUtils.getResults(ctrl);
       }, 100);
 
-      function refresh() {
+      // Refresh current page
+      this.refresh = function(row) {
+        searchDisplayUtils.getResults(ctrl);
+      };
+
+      function onChangeFilters() {
         ctrl.page = 1;
         ctrl.rowCount = null;
         ctrl.getResults();
index 00c0f55e47a958555503e6a93ef5687c006522b8..9d039ba19cc8f6c0619da6ecdc0c22a1c1c40e4e 100644 (file)
@@ -1,3 +1,5 @@
-<ol ng-if=":: $ctrl.settings.style === 'ol'" ng-include="'~/crmSearchDisplayList/crmSearchDisplayListItems.html'" ng-style="{'list-style': $ctrl.settings.symbol}"></ol>
-<ul ng-if=":: $ctrl.settings.style !== 'ol'" ng-include="'~/crmSearchDisplayList/crmSearchDisplayListItems.html'" ng-style="{'list-style': $ctrl.settings.symbol}"></ul>
-<div ng-include="'~/crmSearchDisplay/Pager.html'"></div>
+<div class="crm-search-display crm-search-display-list">
+  <ol ng-if=":: $ctrl.settings.style === 'ol'" ng-include="'~/crmSearchDisplayList/crmSearchDisplayListItems.html'" ng-style="{'list-style': $ctrl.settings.symbol}"></ol>
+  <ul ng-if=":: $ctrl.settings.style !== 'ol'" ng-include="'~/crmSearchDisplayList/crmSearchDisplayListItems.html'" ng-style="{'list-style': $ctrl.settings.symbol}"></ul>
+  <div ng-include="'~/crmSearchDisplay/Pager.html'"></div>
+</div>
index e99f04b04f09cb74798984863a9c346c5671b83f..59bae1aab8db509f4b62ddc58006b56863f788d1 100644 (file)
@@ -1,4 +1,4 @@
-<li ng-repeat="row in $ctrl.results">
+<li ng-repeat="(rowIndex, row) in $ctrl.results">
   <div ng-repeat="col in $ctrl.settings.columns" title="{{:: displayUtils.replaceTokens(col.title, row, $ctrl.settings.columns) }}" class="{{:: col.break ? '' : 'crm-inline-block' }}">
     <label ng-if=":: col.label && (col.type !== 'field' || col.forceLabel || row[col.key])">
       {{:: displayUtils.replaceTokens(col.label, row, $ctrl.settings.columns) }}
index 27a8aa11c8d9aaf6845cef816488c80901c29667..dfc029ab5f63cd09f1e7a40cbd4908699e26bfb2 100644 (file)
         $scope.displayUtils = searchDisplayUtils;
 
         if (this.afFieldset) {
-          $scope.$watch(this.afFieldset.getFieldData, refresh, true);
+          $scope.$watch(this.afFieldset.getFieldData, onChangeFilters, true);
         }
-        $scope.$watch('$ctrl.filters', refresh, true);
+        $scope.$watch('$ctrl.filters', onChangeFilters, true);
       };
 
       this.getResults = _.debounce(function() {
         searchDisplayUtils.getResults(ctrl);
       }, 100);
 
-      function refresh() {
+      // Refresh page after inline-editing a row
+      this.refresh = function(row) {
+        var rowId = row.id;
+        searchDisplayUtils.getResults(ctrl)
+          .then(function() {
+            // If edited row disappears (because edits cause it to not meet search criteria), deselect it
+            var index = ctrl.selectedRows.indexOf(rowId);
+            if (index > -1 && !_.findWhere(ctrl.results, {id: rowId})) {
+              ctrl.selectedRows.splice(index, 1);
+            }
+          });
+      };
+
+      function onChangeFilters() {
         ctrl.page = 1;
         ctrl.rowCount = null;
+        ctrl.selectedRows.legth = 0;
+        ctrl.allRowsSelected = false;
         ctrl.getResults();
       }
 
index 3c408c3b885da0ac72d5e4e8458eaea43e59e4bc..e867270f728f1ae5e6b27748950011ddd4a5a6c8 100644 (file)
@@ -1,27 +1,29 @@
-<div class="form-inline" ng-if="$ctrl.settings.actions">
-  <crm-search-actions entity="$ctrl.apiEntity" ids="$ctrl.selectedRows" refresh="$ctrl.getResults()"></crm-search-actions>
+<div class="crm-search-display crm-search-display-table">
+  <div class="form-inline" ng-if="$ctrl.settings.actions">
+    <crm-search-actions entity="$ctrl.apiEntity" ids="$ctrl.selectedRows" refresh="$ctrl.getResults()"></crm-search-actions>
+  </div>
+  <table>
+    <thead>
+      <tr>
+        <th class="crm-search-result-select" ng-if=":: $ctrl.settings.actions">
+          <input type="checkbox" ng-checked="$ctrl.allRowsSelected" ng-click="selectAllRows()" >
+        </th>
+        <th ng-repeat="col in $ctrl.settings.columns" ng-click="setSort(col, $event)" title="{{:: ts('Click to sort results (shift-click to sort by multiple).') }}">
+          <i ng-if="col.type === 'field'" class="crm-i {{ getSort(col) }}"></i>
+          <span>{{ col.label }}</span>
+        </th>
+      </tr>
+    </thead>
+    <tbody>
+      <tr ng-repeat="(rowIndex, row) in $ctrl.results">
+        <td ng-if=":: $ctrl.settings.actions">
+          <input type="checkbox" ng-checked="isRowSelected(row)" ng-click="selectRow(row)" ng-disabled="!(!loadingAllRows && row.id)">
+        </td>
+        <td ng-repeat="col in $ctrl.settings.columns" ng-include="'~/crmSearchDisplay/colType/' + col.type + '.html'" title="{{:: displayUtils.replaceTokens(col.title, row, $ctrl.settings.columns) }}" class="{{:: col.alignment }}">
+        </td>
+        <td></td>
+      </tr>
+    </tbody>
+  </table>
+  <div ng-include="'~/crmSearchDisplay/Pager.html'"></div>
 </div>
-<table>
-  <thead>
-    <tr>
-      <th class="crm-search-result-select" ng-if="$ctrl.settings.actions">
-        <input type="checkbox" ng-checked="$ctrl.allRowsSelected" ng-click="selectAllRows()" >
-      </th>
-      <th ng-repeat="col in $ctrl.settings.columns" ng-click="setSort(col, $event)" title="{{:: ts('Click to sort results (shift-click to sort by multiple).') }}">
-        <i ng-if="col.type === 'field'" class="crm-i {{ getSort(col) }}"></i>
-        <span>{{ col.label }}</span>
-      </th>
-    </tr>
-  </thead>
-  <tbody>
-    <tr ng-repeat="row in $ctrl.results">
-      <td ng-if="$ctrl.settings.actions">
-        <input type="checkbox" ng-checked="isRowSelected(row)" ng-click="selectRow(row)" ng-disabled="!(!loadingAllRows && row.id)">
-      </td>
-      <td ng-repeat="col in $ctrl.settings.columns" ng-include="'~/crmSearchDisplay/colType/' + col.type + '.html'" title="{{:: displayUtils.replaceTokens(col.title, row, $ctrl.settings.columns) }}" class="{{:: col.alignment }}">
-      </td>
-      <td></td>
-    </tr>
-  </tbody>
-</table>
-<div ng-include="'~/crmSearchDisplay/Pager.html'"></div>
index 877e7ddaaaa4bf0682d2a3da374e723a3ffccdfc..aab0e8c9dca1ff3487d870acb3a5f64e5e6a430c 100644 (file)
@@ -3,3 +3,22 @@
   margin-top: 10px;
   border: 1px solid lightgrey;
 }
+
+.crm-search-display.crm-search-display-table td > crm-search-display-editable,
+.crm-search-display.crm-search-display-table td > .crm-editable-enabled {
+  display: block !important;
+}
+
+.crm-search-display crm-search-display-editable {
+  position: relative;
+}
+
+.crm-search-display crm-search-display-editable + span {
+  display: none !important;
+}
+
+.crm-search-display .crm-search-display-editable-buttons {
+  position: absolute;
+  bottom: -22px;
+  left: 0;
+}