Afform - add UI for configuring search display filters
authorColeman Watts <coleman@civicrm.org>
Sat, 25 Dec 2021 20:02:09 +0000 (15:02 -0500)
committerColeman Watts <coleman@civicrm.org>
Sun, 26 Dec 2021 19:05:41 +0000 (14:05 -0500)
Allows filters from the URL and/or fixed values to be
added to an embedded search display.

ext/afform/admin/Civi/Api4/Action/Afform/LoadAdminData.php
ext/afform/admin/ang/afGuiEditor/afGuiSearch.component.js
ext/afform/admin/ang/afGuiEditor/afGuiSearch.html

index 5c73354997c1d09a7574e440dcb6e8b761ad8ba8..e137707d343dfacece2ff69985f55b0e3b6aae45 100644 (file)
@@ -187,6 +187,7 @@ class LoadAdminData extends \Civi\Api4\Generic\AbstractAction {
           ->addSelect('*', 'type:name', 'type:icon', 'saved_search_id.name', 'saved_search_id.api_entity', 'saved_search_id.api_params')
           ->execute()->first();
         $display['calc_fields'] = $this->getCalcFields($display['saved_search_id.api_entity'], $display['saved_search_id.api_params']);
+        $display['filters'] = empty($displayTag['filters']) ? NULL : (\CRM_Utils_JS::getRawProps($displayTag['filters']) ?: NULL);
         $info['search_displays'][] = $display;
         if ($newForm) {
           $info['definition']['layout'][0]['#children'][] = $displayTag + ['#tag' => $display['type:name']];
index d74672828408bfb629140d684d9af75b1ec90a81..cd89fa8e0c3f83acbc7292268653500524b4a5bd 100644 (file)
 
       $scope.getField = afGui.getField;
 
+      // Live results for the select2 of filter fields
+      this.getFilterFields = function() {
+        var fieldGroups = [],
+          entities = getEntities();
+        if (ctrl.display.calc_fields && ctrl.display.calc_fields.length) {
+          fieldGroups.push({
+            text: ts('Calculated Fields'),
+            children: _.transform(ctrl.display.calc_fields, function(fields, el) {
+              fields.push({id: el.name, text: el.defn.label, disabled: ctrl.fieldInUse(el.name)});
+            }, [])
+          });
+        }
+        _.each(entities, function(entity) {
+          fieldGroups.push({
+            text: entity.label,
+            children: _.transform(entity.fields, function(fields, field) {
+              fields.push({id: entity.prefix + field.name, text: entity.label + ' ' + field.label, disabled: ctrl.fieldInUse(entity.prefix + field.name)});
+            }, [])
+          });
+        });
+        return {results: fieldGroups};
+      };
+
       this.buildPaletteLists = function() {
         var search = $scope.controls.fieldSearch ? $scope.controls.fieldSearch.toLowerCase() : null;
         buildCalcFieldList(search);
         buildElementList(search);
       };
 
+      // Gets the name of the entity a field belongs to
+      this.getFieldEntity = function(fieldName) {
+        if (fieldName.indexOf('.') < 0) {
+          return ctrl.display['saved_search_id.api_entity'];
+        }
+        var alias = fieldName.split('.')[0],
+          entity;
+        _.each(ctrl.display['saved_search_id.api_params'].join, function(join) {
+          var joinInfo = join[0].split(' AS ');
+          if (alias === joinInfo[1]) {
+            entity = joinInfo[0];
+            return false;
+          }
+        });
+        return entity || ctrl.display['saved_search_id.api_entity'];
+      };
+
       function buildCalcFieldList(search) {
         $scope.calcFieldList.length = 0;
         _.each(_.cloneDeep(ctrl.display.calc_fields), function(field) {
         });
       }
 
-      function buildFieldList(search) {
-        $scope.fieldList.length = 0;
-        var entity = afGui.getEntity(ctrl.display['saved_search_id.api_entity']),
-          entityCount = {};
-        entityCount[entity.entity] = 1;
-        $scope.fieldList.push({
-          entityType: entity.entity,
-          label: ts('%1 Fields', {1: entity.label}),
-          fields: filterFields(entity.fields)
-        });
+      // Fetch all entities used in search (main entity + joins)
+      function getEntities() {
+        var
+          mainEntity = afGui.getEntity(ctrl.display['saved_search_id.api_entity']),
+          entityCount = {},
+          entities = [{
+            name: mainEntity.entity,
+            prefix: '',
+            label: mainEntity.label,
+            fields: mainEntity.fields
+          }];
+        entityCount[mainEntity.entity] = 1;
 
         _.each(ctrl.display['saved_search_id.api_params'].join, function(join) {
           var joinInfo = join[0].split(' AS '),
-            entity = afGui.getEntity(joinInfo[0]),
-            alias = joinInfo[1];
+            entity = afGui.getEntity(joinInfo[0]);
           entityCount[entity.entity] = (entityCount[entity.entity] || 0) + 1;
+          entities.push({
+            name: entity.entity,
+            prefix: joinInfo[1] + '.',
+            label: entity.label + (entityCount[entity.entity] > 1 ? ' ' + entityCount[entity.entity] : ''),
+            fields: entity.fields,
+          });
+        });
+
+        return entities;
+      }
+
+      function buildFieldList(search) {
+        $scope.fieldList.length = 0;
+        var entities = getEntities();
+        _.each(entities, function(entity) {
           $scope.fieldList.push({
-            entityType: entity.entity,
-            label: ts('%1 Fields', {1: entity.label + (entityCount[entity.entity] > 1 ? ' ' + entityCount[entity.entity] : '')}),
-            fields: filterFields(entity.fields, alias)
+            entityType: entity.name,
+            label: ts('%1 Fields', {1: entity.label}),
+            fields: filterFields(entity.fields, entity.prefix)
           });
         });
 
         function fieldDefaults(field, prefix) {
           var tag = {
             "#tag": "af-field",
-            name: (prefix ? prefix + '.' : '') + field.name
+            name: prefix + field.name
           };
           if (field.input_type === 'Select' || field.input_type === 'ChainSelect') {
             tag.defn = {input_attrs: {multiple: true}};
         });
       };
 
-      // Checks if a field is on the form or set as a value
-      $scope.fieldInUse = function(fieldName) {
-        return check(ctrl.editor.layout['#children'], {'#tag': 'af-field', name: fieldName});
+      // Checks if a field is on the form or set as a filter
+      this.fieldInUse = function(fieldName) {
+        if (_.findIndex(ctrl.filters, {name: fieldName}) >= 0) {
+          return true;
+        }
+        return !!getElement(ctrl.editor.layout['#children'], {'#tag': 'af-field', name: fieldName});
       };
 
       // Checks if fields in a block are already in use on the form.
       // Note that if a block contains no fields it can be used repeatedly, so this will always return false for those.
       $scope.blockInUse = function(block) {
         if (block['af-join']) {
-          return check(ctrl.editor.layout['#children'], {'af-join': block['af-join']});
+          return !!getElement(ctrl.editor.layout['#children'], {'af-join': block['af-join']});
         }
         var fieldsInBlock = _.pluck(afGui.findRecursive(afGui.meta.blocks[block['#tag']].layout, {'#tag': 'af-field'}), 'name');
-        return check(ctrl.editor.layout['#children'], function(item) {
+        return !!getElement(ctrl.editor.layout['#children'], function(item) {
           return item['#tag'] === 'af-field' && _.includes(fieldsInBlock, item.name);
         });
       };
 
-      // Check for a matching item for this entity
+      function getSearchDisplayElement() {
+        return getElement(ctrl.editor.layout['#children'], {'#tag': ctrl.display['type:name'], 'display-name': ctrl.display.name, 'search-name': ctrl.display['saved_search_id.name']});
+      }
+
+      // Return an item matching criteria
       // Recursively checks the form layout, including block directives
-      function check(group, criteria, found) {
+      function getElement(group, criteria, found) {
         if (!found) {
           found = {};
         }
-        if (_.find(group, criteria)) {
-          found.match = true;
-          return true;
+        var match = _.find(group, criteria);
+        if (match) {
+          found.match = match;
+          return match;
         }
         _.each(group, function(item) {
           if (found.match) {
           if (_.isPlainObject(item)) {
             // Recurse through everything
             if (item['#children']) {
-              check(item['#children'], criteria, found);
+              getElement(item['#children'], criteria, found);
             }
             // Recurse into block directives
             else if (item['#tag'] && item['#tag'] in afGui.meta.blocks) {
-              check(afGui.meta.blocks[item['#tag']].layout, criteria, found);
+              getElement(afGui.meta.blocks[item['#tag']].layout, criteria, found);
             }
           }
         });
         return found.match;
       }
 
+      function filtersToArray() {
+        return _.transform(ctrl.display.filters, function(result, value, key) {
+          var info = {
+            name: key,
+            mode: value.indexOf('routeParams') === 0 ? 'url' : 'val'
+          };
+          // Object dot notation
+          if (info.mode === 'url' && value.indexOf('routeParams.') === 0) {
+            info.value = value.replace('routeParams.', '');
+          }
+          // Object bracket notation
+          else if (info.mode === 'url') {
+            info.value = decode(value.substring(value.indexOf('[') + 1, value.lastIndexOf(']')));
+          }
+          // Literal value
+          else {
+            info.value = decode(value);
+          }
+          result.push(info);
+        }, []);
+      }
+
+      // Convert javascript notation to value
+      function decode(encoded) {
+        // Single-quoted string
+        if (encoded.indexOf("'") === 0 && encoded.charAt(encoded.length - 1) === "'") {
+          return encoded.substring(1, encoded.length - 1);
+        }
+        // Anything else
+        return JSON.parse(encoded);
+      }
+
+      // Convert value to javascript notation
+      function encode(value) {
+        var encoded = JSON.stringify(value),
+          split = encoded.split('"');
+        // Convert double-quotes to single-quotes if possible
+        if (split.length === 3 && split[0] === '' && split[2] === '' && encoded.indexOf("'") < 0) {
+          return "'" + split[1] + "'";
+        }
+        return encoded;
+      }
+
+      // Append a search filter
+      this.addFilter = function(fieldName) {
+        ctrl.filters.push({
+          name: fieldName,
+          value: fieldName,
+          mode: 'url'
+        });
+      };
+
+      // Respond to changing a filter field name
+      this.onChangeFilter = function(index) {
+        var filter = ctrl.filters[index];
+        if (filter.name) {
+          filter.mode = 'url';
+          filter.value = filter.name;
+        } else {
+          ctrl.filters.splice(index, 1);
+        }
+      };
+
+      // Convert filters array to js notation & add to crm-search-display element
+      function writeFilters() {
+        var element = getSearchDisplayElement(),
+          output = [];
+        if (!ctrl.filters.length) {
+          delete element.filters;
+          return;
+        }
+        _.each(ctrl.filters, function(filter) {
+          var keyVal = [
+            // Enclose the key in quotes unless it is purely alphanumeric
+            filter.name.match(/\W/) ? encode(filter.name) : filter.name,
+          ];
+          // Object dot notation
+          if (filter.mode === 'url' && !filter.value.match(/\W/)) {
+            keyVal.push('routeParams.' + filter.value);
+          }
+          // Object bracket notation
+          else if (filter.mode === 'url') {
+            keyVal.push('routeParams[' + encode(filter.value) + ']');
+          }
+          // Literal value
+          else {
+            keyVal.push(encode(filter.value));
+          }
+          output.push(keyVal.join(': '));
+        });
+        element.filters = '{' + output.join(', ') + '}';
+      }
+
       this.$onInit = function() {
-        // When a new block is saved, update the list
         this.meta = afGui.meta;
+        this.filters = filtersToArray();
+        $scope.$watch('$ctrl.filters', writeFilters, true);
+        // When a new block is saved, update the list
         $scope.$watchCollection('$ctrl.meta.blocks', function() {
           $scope.controls.fieldSearch = '';
           ctrl.buildPaletteLists();
index 63f107c8033c9b66e82dd5c1f3ce9b164edf16b2..ee688e42b7318bbdef384dc139871d056a057aa3 100644 (file)
@@ -1,4 +1,35 @@
-<div>
+<div class="af-gui-columns crm-flex-box">
+  <fieldset class="af-gui-entity-values">
+    <legend>{{:: ts('Filters:') }}</legend>
+    <div class="form-inline" ng-repeat="filter in $ctrl.filters">
+      <input class="form-control" ng-model="filter.name" ng-change="$ctrl.onChangeFilter($index)" crm-ui-select="{data: $ctrl.getFilterFields, placeholder: ' '}" />
+      <div class="input-group">
+        <div class="input-group-btn">
+          <button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+            {{ filter.mode === 'url' ? ts('Url') : ts('Value') }}
+            <span class="caret"></span>
+          </button>
+          <ul class="dropdown-menu">
+            <li>
+              <a href ng-click="$ctrl.onChangeFilter($index)">{{:: ts('Url variable') }}</a>
+            </li>
+            <li>
+              <a href ng-click="filter.mode = 'val'; filter.value = ''">{{:: ts('Fixed value') }}</a>
+            </li>
+          </ul>
+        </div>
+        <input ng-if="filter.mode === 'url'" class="form-control" ng-model="filter.value" />
+        <span ng-if="filter.mode === 'val'">
+          <input class="form-control" af-gui-field-value="getField($ctrl.getFieldEntity(filter.name), filter.name)" ng-model="filter.value" />
+        </span>
+      </div>
+    </div>
+    <hr />
+    <div class="form-inline">
+      <input class="form-control" on-crm-ui-select="$ctrl.addFilter(selection)" crm-ui-select="{data: $ctrl.getFilterFields, placeholder: ts('Add filter')}" />
+    </div>
+  </fieldset>
+
   <fieldset class="af-gui-entity-palette">
     <legend class="form-inline">
       {{:: ts('Add:') }}
@@ -24,7 +55,7 @@
       <div ng-if="calcFieldList.length">
         <label>{{:: ts('Calculated Fields') }}</label>
         <div ui-sortable="$ctrl.editor.getSortableOptions()" ui-sortable-update="buildPaletteLists" ng-model="calcFieldList">
-          <div ng-repeat="field in calcFieldList" ng-class="{disabled: fieldInUse(field.name)}">
+          <div ng-repeat="field in calcFieldList" ng-class="{disabled: $ctrl.fieldInUse(field.name)}">
             <div class="af-gui-palette-item">{{:: field.defn.label }}</div>
           </div>
         </div>
@@ -33,7 +64,7 @@
         <div ng-if="fieldGroup.fields.length">
           <label>{{:: fieldGroup.label }}</label>
           <div ui-sortable="{update: buildPaletteLists, items: '&gt; div:not(.disabled)', connectWith: '[ui-sortable]', placeholder: 'af-gui-dropzone'}" ui-sortable-update="$ctrl.editor.onDrop" ng-model="fieldGroup.fields">
-            <div ng-repeat="field in fieldGroup.fields" ng-class="{disabled: fieldInUse(field.name)}">
+            <div ng-repeat="field in fieldGroup.fields" ng-class="{disabled: $ctrl.fieldInUse(field.name)}">
               {{:: getField(fieldGroup.entityType, field.name).label }}
             </div>
           </div>