Afform Gui - Support editing search forms
authorColeman Watts <coleman@civicrm.org>
Thu, 28 Jan 2021 19:04:32 +0000 (14:04 -0500)
committerColeman Watts <coleman@civicrm.org>
Sat, 30 Jan 2021 01:41:06 +0000 (20:41 -0500)
This permits search forms to be edited and saved in the Afform GUI,
and also provides a link to do so from the Search Kit admin screen.

19 files changed:
ext/afform/admin/Civi/Api4/Action/Afform/LoadAdminData.php
ext/afform/admin/ang/afAdmin/afAdminList.controller.js
ext/afform/admin/ang/afAdmin/afAdminList.html
ext/afform/admin/ang/afGuiEditor.css
ext/afform/admin/ang/afGuiEditor.js
ext/afform/admin/ang/afGuiEditor/afGuiEditor.component.js
ext/afform/admin/ang/afGuiEditor/afGuiEditorPalette.html
ext/afform/admin/ang/afGuiEditor/afGuiSearch.component.js [new file with mode: 0644]
ext/afform/admin/ang/afGuiEditor/afGuiSearch.html [new file with mode: 0644]
ext/afform/admin/ang/afGuiEditor/elements/afGuiContainer.component.js
ext/afform/admin/ang/afGuiEditor/elements/afGuiContainer.html
ext/afform/admin/ang/afGuiEditor/elements/afGuiField.component.js
ext/afform/admin/ang/afGuiEditor/elements/afGuiField.html
ext/afform/admin/ang/afGuiEditor/elements/afGuiSearchDisplay.component.js [new file with mode: 0644]
ext/afform/admin/ang/afGuiEditor/elements/afGuiSearchDisplay.html [new file with mode: 0644]
ext/search/Civi/Search/Admin.php
ext/search/ang/crmSearchAdmin/searchList.controller.js
ext/search/ang/crmSearchAdmin/searchList.html
ext/search/css/crmSearchAdmin.css

index 350ea8252a816c628bf5299d8961f81655ebc84e..db3dcb3b4d08e4b71ab5552a08fb7530dde4dae6 100644 (file)
@@ -56,6 +56,20 @@ class LoadAdminData extends \Civi\Api4\Generic\AbstractAction {
             'layout' => [],
           ];
           break;
+
+        case 'search':
+          $info['definition'] = $this->definition + [
+            'title' => '',
+            'permission' => 'access CiviCRM',
+            'layout' => [
+              [
+                '#tag' => 'div',
+                'af-fieldset' => '',
+                '#children' => [],
+              ],
+            ],
+          ];
+          break;
       }
     }
 
@@ -133,9 +147,39 @@ class LoadAdminData extends \Civi\Api4\Generic\AbstractAction {
       $entities[] = $info['definition']['join'] ?? $info['definition']['block'];
     }
 
+    if ($info['definition']['type'] === 'search') {
+      $getFieldsMode = 'search';
+      $displayTags = [];
+      if ($newForm) {
+        [$searchName, $displayName] = array_pad(explode('.', $this->entity ?? ''), 2, '');
+        $displayTags[] = ['search-name' => $searchName, 'display-name' => $displayName];
+      }
+      else {
+        foreach (\Civi\Search\Display::getDisplayTypes(['name']) as $displayType) {
+          $displayTags = array_merge($displayTags, \CRM_Utils_Array::findAll($info['definition']['layout'], ['#tag' => $displayType['name']]));
+        }
+      }
+      foreach ($displayTags as $displayTag) {
+        $display = \Civi\Api4\SearchDisplay::get(FALSE)
+          ->addWhere('name', '=', $displayTag['display-name'])
+          ->addWhere('saved_search.name', '=', $displayTag['search-name'])
+          ->addSelect('*', 'type:name', 'type:icon', 'saved_search.name', 'saved_search.api_entity', 'saved_search.api_params')
+          ->execute()->first();
+        $info['search_displays'][] = $display;
+        if ($newForm) {
+          $info['definition']['layout'][0]['#children'][] = $displayTag + ['#tag' => $display['type:name']];
+        }
+        $entities[] = $display['saved_search.api_entity'];
+        foreach ($display['saved_search.api_params']['join'] ?? [] as $join) {
+          $entities[] = explode(' AS ', $join[0])[0];
+        }
+      }
+      $entities = array_unique($entities);
+    }
+
     // Optimization - since contact fields are a combination of these three,
     // we'll combine them client-side rather than sending them via ajax.
-    if (array_intersect($entities, ['Individual', 'Household', 'Organization'])) {
+    elseif (array_intersect($entities, ['Individual', 'Household', 'Organization'])) {
       $entities = array_diff($entities, ['Contact']);
     }
 
@@ -169,6 +213,10 @@ class LoadAdminData extends \Civi\Api4\Generic\AbstractAction {
         'name' => 'fields',
         'data_type' => 'Array',
       ],
+      [
+        'name' => 'search_displays',
+        'data_type' => 'Array',
+      ],
     ];
   }
 
index 10f4e7fc9d43835fa16999144a2d1c91fa6066cb..f1d8003439593d062e5f94786d1a0eb0ab783a5f 100644 (file)
         });
         $scope.tabs.block.options = _.sortBy(links, 'Label');
       }
+
+      if (ctrl.tab === 'search') {
+        crmApi4('SearchDisplay', 'get', {
+          select: ['name', 'label', 'type:icon', 'saved_search.name', 'saved_search.label']
+        }).then(function(searchDisplays) {
+          _.each(searchDisplays, function(searchDisplay) {
+            links.push({
+              url: '#create/search/' + searchDisplay['saved_search.name'] + '.' + searchDisplay.name,
+              label: searchDisplay['saved_search.label'] + ': ' + searchDisplay.label,
+              icon: searchDisplay['type:icon']
+            });
+          });
+          $scope.tabs.search.options = _.sortBy(links, 'Label');
+        });
+      }
     };
 
     this.revert = function(afform) {
index c4b0901d184f70a3fcef6da8bf52b97407c308ca..09111b0a5e1af08ffbe6ab2f907960f947188a66 100644 (file)
@@ -38,8 +38,8 @@
     <tr>
       <th>{{:: ts('Title') }}</th>
       <th>{{:: ts('Name') }}</th>
-      <th>{{:: ts('Server Route') }}</th>
-      <th>{{:: ts('Frontend?') }}</th>
+      <th>{{:: ts('Page') }}</th>
+      <th>{{:: ts('Style') }}</th>
       <th></th>
     </tr>
     </thead>
index 55703580bf07d8336880decf3af2886d0f473755..d38dfc3ac5d915106b8b96b58b649094f4722433 100644 (file)
   background-color: #b3b3b3;
 }
 
+#afGuiEditor .af-gui-bar > .form-inline > span {
+  color: #696969;
+  font-style: italic;
+}
+
 #afGuiEditor .af-gui-element {
   position: relative;
   padding: 0 3px 3px;
   display: block;
 }
 
+#afGuiEditor .af-gui-search-display {
+  border: 1px dotted gray;
+  color: gray;
+  padding: 3em;
+  background-color: #f9f9f9;
+}
+
 #afGuiEditor .af-gui-container-type-fieldset {
   box-shadow: 0 0 5px #bbbbbb;
 }
index ca6947e87f545ee31679fc8fd42c84dcae6fddab..17fb4d5aee65cf587a00c868b408b88dd0dcb7b9 100644 (file)
@@ -72,6 +72,7 @@
             delete entity.fields;
           });
           CRM.afGuiEditor.blocks = {};
+          CRM.afGuiEditor.searchDisplays = {};
         },
 
         // Takes the results from api.Afform.loadAdminData and processes the metadata
               (CRM.afGuiEditor.entities.Organization || {}).fields
             );
           }
+          _.each(data.search_displays, function(display) {
+            CRM.afGuiEditor.searchDisplays[display['saved_search.name'] + '.' + display.name] = display;
+          });
         },
 
         meta: CRM.afGuiEditor,
         },
 
         getField: function(entityName, fieldName) {
-          return CRM.afGuiEditor.entities[entityName].fields[fieldName];
+          var fields = CRM.afGuiEditor.entities[entityName].fields;
+          return fields[fieldName] || fields[fieldName.substr(fieldName.indexOf('.') + 1)];
         },
 
         // Recursively searches a collection and its children using _.filter
index 092505cb2061dfcab2364a42338fc72b305a1639..4cbe9a44f2a3999300d8591ae5b99fa1f5bc1cb9 100644 (file)
@@ -51,7 +51,7 @@
           }
         }
 
-        else if ($scope.afform.type === 'block') {
+        if ($scope.afform.type === 'block') {
           editor.layout['#children'] = $scope.afform.layout;
           editor.blockEntity = $scope.afform.join || $scope.afform.block;
           $scope.entities[editor.blockEntity] = {
           };
         }
 
+        if ($scope.afform.type === 'search') {
+          editor.layout['#children'] = afGui.findRecursive($scope.afform.layout, {'af-fieldset': ''})[0]['#children'];
+
+        }
+
         // Set changesSaved to true on initial load, false thereafter whenever changes are made to the model
         $scope.changesSaved = editor.mode === 'edit' ? 1 : false;
         $scope.$watch('afform', function () {
index f166d96ed1675f08b5a1e55a07ed645d8ed67095..1db7dd30436f48135a01dd49c4790ef6e3464c96 100644 (file)
           <i ng-if="entity.loading" class="crm-i fa-spin fa-spinner"></i>
         </a>
       </li>
+      <li role="presentation" ng-repeat="(key, searchDisplay) in editor.meta.searchDisplays" ng-class="{active: selectedEntityName === key}">
+        <a href ng-click="editor.selectEntity(key)">
+          <span>{{ searchDisplay.label }}</span>
+        </a>
+      </li>
       <li role="presentation" class="dropdown" ng-if="editor.allowEntityConfig">
         <a href class="dropdown-toggle" data-toggle="dropdown" title="{{ ts('Add Entity') }}">
           <span><i class="crm-i fa-plus"></i></span>
@@ -30,4 +35,7 @@
   <div class="panel-body" ng-repeat="entity in entities" ng-if="selectedEntityName === entity.name">
     <af-gui-entity entity="entity"></af-gui-entity>
   </div>
+  <div class="panel-body" ng-repeat="(key, searchDisplay) in editor.meta.searchDisplays" ng-if="selectedEntityName === key">
+    <af-gui-search display="searchDisplay"></af-gui-search>
+  </div>
 </div>
diff --git a/ext/afform/admin/ang/afGuiEditor/afGuiSearch.component.js b/ext/afform/admin/ang/afGuiEditor/afGuiSearch.component.js
new file mode 100644 (file)
index 0000000..cfe19c9
--- /dev/null
@@ -0,0 +1,127 @@
+// https://civicrm.org/licensing
+(function(angular, $, _) {
+  "use strict";
+
+  angular.module('afGuiEditor').component('afGuiSearch', {
+    templateUrl: '~/afGuiEditor/afGuiSearch.html',
+    bindings: {
+      display: '<'
+    },
+    require: {editor: '^^afGuiEditor'},
+    controller: function ($scope, $timeout, afGui) {
+      var ts = $scope.ts = CRM.ts();
+      var ctrl = this;
+      $scope.controls = {};
+      $scope.fieldList = [];
+      $scope.elementList = [];
+      $scope.elementTitles = [];
+
+      $scope.getField = afGui.getField;
+
+      function buildPaletteLists() {
+        var search = $scope.controls.fieldSearch ? $scope.controls.fieldSearch.toLowerCase() : null;
+        buildFieldList(search);
+        buildElementList(search);
+      }
+
+      function buildFieldList(search) {
+        $scope.fieldList.length = 0;
+        var entity = afGui.getEntity(ctrl.display['saved_search.api_entity']),
+          entityCount = {};
+        entityCount[entity.entity] = 1;
+        $scope.fieldList.push({
+          entityType: entity.entity,
+          label: ts('%1 Fields', {1: entity.label}),
+          fields: filterFields(entity.fields)
+        });
+
+        _.each(ctrl.display['saved_search.api_params'].join, function(join) {
+          var joinInfo = join[0].split(' AS '),
+            entity = afGui.getEntity(joinInfo[0]),
+            alias = joinInfo[1];
+          entityCount[entity.entity] = entityCount[entity.entity] ? entityCount[entity.entity] + 1 : 1;
+          $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)
+          });
+        });
+
+        function filterFields(fields, prefix) {
+          return _.transform(fields, function(fieldList, field) {
+            if (!search || _.contains(field.name, search) || _.contains(field.label.toLowerCase(), search)) {
+              fieldList.push({
+                "#tag": "af-field",
+                name: (prefix ? prefix + '.' : '') + field.name
+              });
+            }
+          }, []);
+        }
+      }
+
+      function buildElementList(search) {
+        $scope.elementList.length = 0;
+        $scope.elementTitles.length = 0;
+        _.each(afGui.meta.elements, function(element, name) {
+          if (!search || _.contains(name, search) || _.contains(element.title.toLowerCase(), search)) {
+            var node = _.cloneDeep(element.element);
+            if (name === 'fieldset') {
+              return;
+            }
+            $scope.elementList.push(node);
+            $scope.elementTitles.push(element.title);
+          }
+        });
+      }
+
+      $scope.clearSearch = function() {
+        $scope.controls.fieldSearch = '';
+      };
+
+      // This gets called from jquery-ui so we have to manually apply changes to scope
+      $scope.buildPaletteLists = function() {
+        $timeout(function() {
+          $scope.$apply(function() {
+            buildPaletteLists();
+          });
+        });
+      };
+
+      // 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});
+      };
+
+      // Check for a matching item for this entity
+      // Recursively checks the form layout, including block directives
+      function check(group, criteria, found) {
+        if (!found) {
+          found = {};
+        }
+        if (_.find(group, criteria)) {
+          found.match = true;
+          return true;
+        }
+        _.each(group, function(item) {
+          if (found.match) {
+            return false;
+          }
+          if (_.isPlainObject(item)) {
+            // Recurse through everything
+            if (item['#children']) {
+              check(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);
+            }
+          }
+        });
+        return found.match;
+      }
+
+      $scope.$watch('controls.fieldSearch', buildPaletteLists);
+    }
+  });
+
+})(angular, CRM.$, CRM._);
diff --git a/ext/afform/admin/ang/afGuiEditor/afGuiSearch.html b/ext/afform/admin/ang/afGuiEditor/afGuiSearch.html
new file mode 100644 (file)
index 0000000..aaaccd9
--- /dev/null
@@ -0,0 +1,28 @@
+<div>
+  <fieldset class="af-gui-entity-palette">
+    <legend class="form-inline">
+      {{:: ts('Add:') }}
+      <input ng-model="controls.fieldSearch" class="form-control" type="search" placeholder="&#xf002" title="{{:: ts('Search fields') }}" />
+    </legend>
+    <div class="af-gui-entity-palette-select-list">
+      <div ng-if="elementList.length">
+        <label>{{:: ts('Elements') }}</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="elementList">
+          <div ng-repeat="element in elementList" >
+            {{:: elementTitles[$index] }}
+          </div>
+        </div>
+      </div>
+      <div ng-repeat="fieldGroup in fieldList">
+        <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)}">
+              {{:: getField(fieldGroup.entityType, field.name).label }}
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </fieldset>
+</div>
index 5ebe110b48fa1bcd9eaacc0f560a4d1c1a54243f..236a99aaedf050d55d7f88ce64c8e227854c57b7 100644 (file)
         if (node['#tag'] === 'af-field') {
           return 'field';
         }
-        if (node['af-fieldset']) {
+        if ('af-fieldset' in node) {
           return 'fieldset';
         }
         if (node['af-join']) {
         if (node['#tag'] && node['#tag'] in afGui.meta.blocks) {
           return 'container';
         }
+        if (node['#tag'] && (node['#tag'].slice(0, 19) === 'crm-search-display-')) {
+          return 'searchDisplay';
+        }
         var classes = afGui.splitClass(node['class']),
           types = ['af-container', 'af-text', 'af-button', 'af-markup'],
           type = _.intersection(types, classes);
       };
 
       this.getEntityName = function() {
-        return ctrl.entityName.split('-join-')[0];
+        return ctrl.entityName ? ctrl.entityName.split('-join-')[0] : null;
       };
 
       // Returns the primary entity type for this container e.g. "Contact"
       };
 
       // Returns the entity type for fields within this conainer (join entity type if this is a join, else the primary entity type)
-      this.getFieldEntityType = function() {
-        var joinType = ctrl.entityName.split('-join-');
-        return joinType[1] || (ctrl.editor && ctrl.editor.getEntity(joinType[0]).type);
+      this.getFieldEntityType = function(fieldName) {
+        // If entityName is declared for this fieldset, return entity-type or join-type
+        if (ctrl.entityName) {
+          var joinType = ctrl.entityName.split('-join-');
+          return joinType[1] || (ctrl.editor && ctrl.editor.getEntity(joinType[0]).type);
+        }
+        // If entityName is not declared, this field belongs to a search
+        var entityType,
+          prefix = _.includes(fieldName, '.') ? fieldName.split('.')[0] : null;
+        _.each(afGui.meta.searchDisplays, function(searchDisplay) {
+          if (prefix) {
+            _.each(searchDisplay['saved_search.api_params'].join, function(join) {
+              var joinInfo = join[0].split(' AS ');
+              if (prefix === joinInfo[1]) {
+                entityType = joinInfo[0];
+                return false;
+              }
+            });
+          }
+          if (!entityType && afGui.getField(searchDisplay['saved_search.api_entity'], fieldName)) {
+            entityType = searchDisplay['saved_search.api_entity'];
+          }
+          if (entityType) {
+            return false;
+          }
+        });
+        return entityType;
       };
 
     }
index bf9057694227ae55009bcebd114532af8de47798..ae6be1c8060f5e918ad3ba79264ab3b7cc60818f 100644 (file)
@@ -2,7 +2,7 @@
   <div ng-if="!$ctrl.loading" class="form-inline" af-gui-menu>
     <span ng-if="$ctrl.getNodeType($ctrl.node) == 'fieldset'">{{ $ctrl.editor.getEntity($ctrl.entityName).label }}</span>
     <span ng-if="block">{{ $ctrl.join ? ts($ctrl.join) + ':' : ts('Block:') }}</span>
-    <span ng-if="!block">{{ tags[$ctrl.node['#tag']].toLowerCase() }}</span>
+    <span ng-if="!block">{{ tags[$ctrl.node['#tag']] }}</span>
     <select ng-if="block" ng-model="block.directive" ng-change="selectBlockDirective()">
       <option value="">{{:: ts('Custom') }}</option>
       <option ng-value="option.id" ng-repeat="option in block.options track by option.id">{{ option.text }}</option>
@@ -25,6 +25,7 @@
       <af-gui-text ng-switch-when="text" node="item" delete-this="$ctrl.removeElement(item)" class="af-gui-element af-gui-text" ></af-gui-text>
       <af-gui-markup ng-switch-when="markup" node="item" delete-this="$ctrl.removeElement(item)" class="af-gui-markup" ></af-gui-markup>
       <af-gui-button ng-switch-when="button" node="item" delete-this="$ctrl.removeElement(item)" class="af-gui-element af-gui-button" ></af-gui-button>
+      <af-gui-search-display ng-switch-when="searchDisplay" node="item" class="af-gui-element"></af-gui-search-display>
     </div>
   </div>
 </div>
index 50781e4a989d8b53004c710e49f7c27b1b3bb2f5..2c76c72bc2dac654b1e87fa4b92186e556595ad6 100644 (file)
         $scope.meta = afGui.meta;
       };
 
-      $scope.getEntity = function() {
-        return ctrl.editor ? ctrl.editor.getEntity(ctrl.container.getEntityName()) : {};
+      // $scope.getEntity = function() {
+      //   return ctrl.editor ? ctrl.editor.getEntity(ctrl.container.getEntityName()) : {};
+      // };
+
+      // Returns the original field definition from metadata
+      this.getDefn = function() {
+        return ctrl.editor ? afGui.getField(ctrl.container.getFieldEntityType(ctrl.node.name), ctrl.node.name) : {};
       };
 
-      $scope.getDefn = this.getDefn = function() {
-        return ctrl.editor ? afGui.getField(ctrl.container.getFieldEntityType(), ctrl.node.name) : {};
+      $scope.getOriginalLabel = function() {
+        if (ctrl.container.getEntityName()) {
+          return ctrl.editor.getEntity(ctrl.container.getEntityName()).label + ': ' + ctrl.getDefn().label;
+        }
+        return afGui.getEntity(ctrl.container.getFieldEntityType(ctrl.node.name)).label + ': ' + ctrl.getDefn().label;
       };
 
       $scope.hasOptions = function() {
         var inputType = $scope.getProp('input_type');
-        return _.contains(['CheckBox', 'Radio', 'Select'], inputType) && !(inputType === 'CheckBox' && !$scope.getDefn().options);
+        return _.contains(['CheckBox', 'Radio', 'Select'], inputType) && !(inputType === 'CheckBox' && !ctrl.getDefn().options);
       };
 
       $scope.getOptions = this.getOptions = function() {
         if (ctrl.node.defn && ctrl.node.defn.options) {
           return ctrl.node.defn.options;
         }
-        return $scope.getDefn().options || ($scope.getProp('input_type') === 'CheckBox' ? null : yesNo);
+        return ctrl.getDefn().options || ($scope.getProp('input_type') === 'CheckBox' ? null : yesNo);
       };
 
       $scope.resetOptions = function() {
@@ -56,7 +64,7 @@
       };
 
       $scope.inputTypeCanBe = function(type) {
-        var defn = $scope.getDefn();
+        var defn = ctrl.getDefn();
         switch (type) {
           case 'CheckBox':
           case 'Radio':
@@ -78,7 +86,7 @@
         if (typeof localDefn[item] !== 'undefined') {
           return localDefn[item];
         }
-        return drillDown($scope.getDefn(), path)[item];
+        return drillDown(ctrl.getDefn(), path)[item];
       };
 
       // Checks for a value in either the local field defn or the base defn
       };
 
       $scope.toggleHelp = function(position) {
-        getSet('help_' + position, $scope.propIsset('help_' + position) ? null : ($scope.getDefn()['help_' + position] || ts('Enter text')));
+        getSet('help_' + position, $scope.propIsset('help_' + position) ? null : (ctrl.getDefn()['help_' + position] || ts('Enter text')));
         return false;
       };
 
           var path = propName.split('.'),
             item = path.pop(),
             localDefn = drillDown(ctrl.node, ['defn'].concat(path)),
-            fieldDefn = drillDown($scope.getDefn(), path);
+            fieldDefn = drillDown(ctrl.getDefn(), path);
           // Set the value if different than the field defn, otherwise unset it
           if (typeof val !== 'undefined' && (val !== fieldDefn[item] && !(!val && !fieldDefn[item]))) {
             localDefn[item] = val;
index 813c9f5be45a97ee892e140e19a331e91470aef7..a22ece82de0864b26e7a335793d7aabd314bb0f8 100644 (file)
@@ -1,8 +1,9 @@
 <af-gui-edit-options ng-if="editingOptions" class="af-gui-content-editing-area"></af-gui-edit-options>
 <div ng-if="!editingOptions" class="af-gui-element af-gui-field" >
-  <div class="af-gui-bar" title="{{ getEntity().label + ': ' + getDefn().label }}">
-    <div class="form-inline pull-right">
-      <div class="btn-group" af-gui-menu >
+  <div class="af-gui-bar" title="{{:: getOriginalLabel() }}">
+    <div class="form-inline">
+      <span ng-if="$ctrl.node.defn.label === false">{{:: getOriginalLabel() }}</span>
+      <div class="btn-group pull-right" af-gui-menu >
         <button type="button" class="btn btn-default btn-xs dropdown-toggle af-gui-add-element-button" data-toggle="dropdown" title="{{:: ts('Configure') }}">
           <span><i class="crm-i fa-gear"></i></span>
         </button>
     </div>
   </div>
   <label ng-style="{visibility: $ctrl.node.defn.label === false ? 'hidden' : 'visible'}" ng-class="{'af-gui-field-required': getProp('required')}" class="af-gui-node-title">
-    <span af-gui-editable ng-model="getSet('label')" ng-model-options="{getterSetter: true}" default-value="getDefn().label">{{ getProp('label') }}</span>
+    <span af-gui-editable ng-model="getSet('label')" ng-model-options="{getterSetter: true}" default-value="$ctrl.getDefn().label">{{ getProp('label') }}</span>
   </label>
   <div class="af-gui-field-help" ng-if="propIsset('help_pre')">
-    <span af-gui-editable ng-model="getSet('help_pre')" ng-model-options="{getterSetter: true}" default-value="getDefn().help_pre">{{ getProp('help_pre') }}</span>
+    <span af-gui-editable ng-model="getSet('help_pre')" ng-model-options="{getterSetter: true}" default-value="$ctrl.getDefn().help_pre">{{ getProp('help_pre') }}</span>
   </div>
   <div class="af-gui-field-input af-gui-field-input-type-{{ getProp('input_type').toLowerCase() }}" ng-include="'~/afGuiEditor/inputType/' + getProp('input_type') + '.html'"></div>
   <div class="af-gui-field-help" ng-if="propIsset('help_post')">
-    <span af-gui-editable ng-model="getSet('help_post')" ng-model-options="{getterSetter: true}" default-value="getDefn().help_post">{{ getProp('help_post') }}</span>
+    <span af-gui-editable ng-model="getSet('help_post')" ng-model-options="{getterSetter: true}" default-value="$ctrl.getDefn().help_post">{{ getProp('help_post') }}</span>
   </div>
 </div>
diff --git a/ext/afform/admin/ang/afGuiEditor/elements/afGuiSearchDisplay.component.js b/ext/afform/admin/ang/afGuiEditor/elements/afGuiSearchDisplay.component.js
new file mode 100644 (file)
index 0000000..f8426a8
--- /dev/null
@@ -0,0 +1,21 @@
+// https://civicrm.org/licensing
+(function(angular, $, _) {
+  "use strict";
+
+  angular.module('afGuiEditor').component('afGuiSearchDisplay', {
+    templateUrl: '~/afGuiEditor/elements/afGuiSearchDisplay.html',
+    bindings: {
+      node: '='
+    },
+    controller: function($scope, afGui) {
+      var ts = $scope.ts = CRM.ts(),
+        ctrl = this;
+
+      this.$onInit = function() {
+        ctrl.display = afGui.meta.searchDisplays[ctrl.node['search-name'] + '.' + ctrl.node['display-name']];
+      };
+
+    }
+  });
+
+})(angular, CRM.$, CRM._);
diff --git a/ext/afform/admin/ang/afGuiEditor/elements/afGuiSearchDisplay.html b/ext/afform/admin/ang/afGuiEditor/elements/afGuiSearchDisplay.html
new file mode 100644 (file)
index 0000000..e49a310
--- /dev/null
@@ -0,0 +1,8 @@
+<div class="af-gui-bar">
+  <div class="form-inline">
+    <span>{{ $ctrl.display.label }}</span>
+  </div>
+</div>
+<p class="text-center af-gui-search-display">
+  <i class="crm-i fa-3x {{ $ctrl.display['type:icon'] }}"></i>
+</p>
index cfdd1a06ba3192d6e638ce45ee201bc2918e1462..ff9d8127fd1a177a34966acb5a07107b5b6d5c2f 100644 (file)
@@ -28,6 +28,14 @@ class Admin {
       'operators' => \CRM_Utils_Array::makeNonAssociative(self::getOperators()),
       'functions' => \CRM_Api4_Page_Api4Explorer::getSqlFunctions(),
       'displayTypes' => Display::getDisplayTypes(['id', 'name', 'label', 'description', 'icon']),
+      'afformEnabled' => (bool) \CRM_Utils_Array::findAll(
+        \CRM_Extension_System::singleton()->getMapper()->getActiveModuleFiles(),
+        ['fullName' => 'org.civicrm.afform']
+      ),
+      'afformAdminEnabled' => (bool) \CRM_Utils_Array::findAll(
+        \CRM_Extension_System::singleton()->getMapper()->getActiveModuleFiles(),
+        ['fullName' => 'org.civicrm.afform_admin']
+      ),
     ];
   }
 
index 105647f718e54217944dcf487aad39efe472e48c..92fc05a2af1176231615e19725324b192163528b 100644 (file)
@@ -5,11 +5,15 @@
     var ts = $scope.ts = CRM.ts(),
       ctrl = $scope.$ctrl = this;
     this.savedSearches = savedSearches;
+    this.afformEnabled = CRM.crmSearchAdmin.afformEnabled;
+    this.afformAdminEnabled = CRM.crmSearchAdmin.afformAdminEnabled;
+
     this.entityTitles = _.transform(CRM.crmSearchAdmin.schema, function(titles, entity) {
       titles[entity.name] = entity.title_plural;
     }, {});
 
-    this.searchPath = window.location.href.split('#')[0].replace('civicrm/admin/search', 'civicrm/search');
+    this.searchPath = CRM.url('civicrm/search');
+    this.newFormPath = CRM.url('civicrm/admin/afform');
 
     this.encode = function(params) {
       return encodeURI(angular.toJson(params));
         savedSearches.splice(index, 1);
       }
     };
+
+    this.loadAfforms = function() {
+      if (ctrl.afforms || ctrl.afforms === null) {
+        return;
+      }
+      ctrl.afforms = null;
+      crmApi4('Afform', 'get', {
+        select: ['layout', 'name', 'title', 'server_route'],
+        where: [['type', '=', 'search']],
+        layoutFormat: 'html'
+      }).then(function(afforms) {
+        ctrl.afforms = {};
+        _.each(afforms, function(afform) {
+          var searchName = afform.layout.match(/<crm-search-display-[^>]+search-name[ ]*=[ ]*['"]([^"']+)/);
+          if (searchName) {
+            ctrl.afforms[searchName[1]] = ctrl.afforms[searchName[1]] || [];
+            ctrl.afforms[searchName[1]].push({
+              title: afform.title,
+              url: afform.server_route ? CRM.url(afform.server_route) : null
+            });
+          }
+        });
+      });
+    };
+
   });
 
 })(angular, CRM.$, CRM._);
index cb97d13a628b7c0775936d60fa562c244431c4a1..b5893b0587013b8ac7f80793c65397cdcab21593 100644 (file)
@@ -1,6 +1,8 @@
 <div id="bootstrap-theme" class="crm-search">
   <h1 crm-page-title>{{:: ts('Saved Searches') }}</h1>
   <div class="form-inline">
+    <label for="search-list-filter">{{:: ts('Filter:') }}</label>
+    <input class="form-control" type="search" id="search-list-filter" ng-model="$ctrl.searchFilter" placeholder="&#xf002">
     <a class="btn btn-primary pull-right" href="#/create/Contact/">
       <i class="crm-i fa-plus"></i>
       {{:: ts('New Search') }}
         <th>{{:: ts('For') }}</th>
         <th>{{:: ts('Displays') }}</th>
         <th>{{:: ts('Smart Group') }}</th>
+        <th ng-if="$ctrl.afformEnabled">{{:: ts('Forms') }}</th>
         <th></th>
       </tr>
     </thead>
     <tbody>
-      <tr ng-repeat="search in $ctrl.savedSearches">
+      <tr ng-repeat="search in $ctrl.savedSearches | filter:$ctrl.searchFilter">
         <td>{{ search.id }}</td>
         <td>{{ search.label }}</td>
         <td>{{ $ctrl.entityTitles[search.api_entity] }}</td>
             </button>
             <ul class="dropdown-menu" ng-if=":: search.display_name.length">
               <li ng-repeat="display_name in search.display_name">
-                <a href="{{:: $ctrl.searchPath + '#/display/' + search.name + '/' + display_name }}"><i class="fa {{:: search.display_icon[$index] }}"></i> {{:: search.display_label[$index] }}</a>
+                <a href="{{:: $ctrl.searchPath + '#/display/' + search.name + '/' + display_name }}" target="_blank">
+                  <i class="fa {{:: search.display_icon[$index] }}"></i>
+                  {{:: search.display_label[$index] }}
+                </a>
               </li>
             </ul>
           </div>
         </td>
         <td>{{ search.groups.join(', ') }}</td>
+        <td ng-if="$ctrl.afformEnabled">
+          <div class="btn-group">
+            <button type="button" ng-click="$ctrl.loadAfforms()" ng-if="search.display_name" class="btn btn-xs dropdown-toggle btn-primary-outline" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+              {{:: ts('Forms') }} <span class="caret"></span>
+            </button>
+            <ul class="dropdown-menu">
+              <li ng-repeat="display_name in search.display_name" ng-if="::$ctrl.afformAdminEnabled">
+                <a href="{{:: $ctrl.newFormPath + '#/create/search/' + search.name + '.' + display_name }}">
+                  <i class="fa fa-plus"></i> {{:: ts('Create form for %1', {1: search.display_label[$index]}) }}
+                </a>
+              </li>
+              <li class="divider" role="separator" ng-if="::$ctrl.afformAdminEnabled"></li>
+              <li ng-if="!$ctrl.afforms || !$ctrl.afforms[search.name]" class="disabled">
+                <a href>
+                  <i ng-if="!$ctrl.afforms" class="crm-i fa-spinner fa-spin"></i>
+                  <em ng-if="$ctrl.afforms && !$ctrl.afforms[search.name]">{{:: ts('None Found') }}</em>
+                </a>
+              </li>
+              <li ng-if="$ctrl.afforms" ng-repeat="afform in $ctrl.afforms[search.name]" ng-class="{disabled: !afform.url}">
+                <a href="{{:: afform.url }}" target="_blank">
+                  {{:: afform.title }}
+                </a>
+              </li>
+            </ul>
+          </div>
+        </td>
         <td class="text-right">
           <a class="btn btn-xs btn-default" href="#/edit/{{:: search.id }}">{{:: ts('Edit') }}</a>
           <a class="btn btn-xs btn-default" href="#/create/{{:: search.api_entity + '?params=' + $ctrl.encode(search.api_params) }}">{{:: ts('Clone') }}</a>
index d6ac891b3cbcf15a733e7f4db9f2e8d85f2c48a9..69a780068a71c4fb29c30f2555f8bc53e0c29b58 100644 (file)
   right: 0;
   top: 0;
 }
+
+#bootstrap-theme input[type=search]::placeholder {
+  font-family: FontAwesome;
+  text-align: right;
+}
+#bootstrap-theme input[type=search]:-ms-input-placeholder {
+  font-family: FontAwesome;
+  text-align: right;
+}
+#bootstrap-theme input[type=search]::-ms-input-placeholder {
+  font-family: FontAwesome;
+  text-align: right;
+}