Afform - add collapsible title as directive
authorColeman Watts <coleman@civicrm.org>
Fri, 18 Mar 2022 13:42:49 +0000 (09:42 -0400)
committerColeman Watts <coleman@civicrm.org>
Sun, 20 Mar 2022 02:02:59 +0000 (22:02 -0400)
Before: A fieldset `<legend>` was treated as its own element. This was more flexible but more complex.
After: Augenerated `<legend> for fieldsets or `<h4>` for other containers based on new `af-title` directive.

This allows central control of titles for e.g. collapsible styles.
Fixes dev/core#3110

12 files changed:
ext/afform/admin/Civi/AfformAdmin/AfformAdminMeta.php
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/afGuiMenuItemCollapsible.component.js [new file with mode: 0644]
ext/afform/admin/ang/afGuiEditor/afGuiMenuItemCollapsible.html [new file with mode: 0644]
ext/afform/admin/ang/afGuiEditor/elements/afGuiContainer-menu.html
ext/afform/admin/ang/afGuiEditor/elements/afGuiContainer.component.js
ext/afform/admin/ang/afGuiEditor/elements/afGuiContainer.html
ext/afform/admin/ang/afGuiEditor/elements/afGuiSearchContainer-menu.html
ext/afform/core/ang/af/afTitle.directive.js [new file with mode: 0644]
ext/afform/core/ang/afCore.css

index 7038b42321ddc187aa3c63b521328746555e1011..8ca5ec909c415422229d2ceeb7654fe37518682f 100644 (file)
@@ -249,17 +249,8 @@ class AfformAdminMeta {
         'element' => [
           '#tag' => 'fieldset',
           'af-fieldset' => NULL,
-          '#children' => [
-            [
-              '#tag' => 'legend',
-              'class' => 'af-text',
-              '#children' => [
-                [
-                  '#text' => E::ts('Enter title'),
-                ],
-              ],
-            ],
-          ],
+          'af-title' => E::ts('Enter title'),
+          '#children' => [],
         ],
       ],
     ];
index 35ff7e113dd9b16a139dce51b33f15ff4439a62b..3b60112dd1e6f6a0e878c6c94700eb82f0ee7d1d 100644 (file)
   font-family: "Courier New", Courier, monospace;
   font-size: 12px;
 }
-#afGuiEditor-canvas:not(.af-gui-menu-open) .af-gui-bar {
+#afGuiEditor-canvas:not(.af-gui-menu-open) .af-gui-bar,
+#afGuiEditor-canvas:not(.af-gui-menu-open) .af-gui-container-title span:empty {
   opacity: 0;
 }
 #afGuiEditor-canvas [ui-sortable] .af-gui-bar {
   left: 0;
   padding-left: 15px;
 }
-#afGuiEditor:not(.af-gui-dragging *) #afGuiEditor-canvas:hover .af-gui-bar {
+#afGuiEditor:not(.af-gui-dragging *) #afGuiEditor-canvas:hover .af-gui-bar,
+#afGuiEditor:not(.af-gui-dragging *) #afGuiEditor-canvas:hover .af-gui-container-title span:empty {
   opacity: 1;
   transition: opacity .2s;
 }
-#afGuiEditor #afGuiEditor-canvas .af-gui-dragtarget > .af-gui-bar {
+#afGuiEditor #afGuiEditor-canvas .af-gui-dragtarget > .af-gui-bar,
+#afGuiEditor #afGuiEditor-canvas .af-gui-dragtarget > .af-gui-container-title span:empty {
   background-color: #d7e6ff;
   opacity: 1;
   transition: opacity .1s;
@@ -263,7 +266,8 @@ body.af-gui-dragging {
 }
 /* Fix button colors when bar is highlighted */
 #afGuiEditor #afGuiEditor-canvas .af-entity-selected > .af-gui-bar > .form-inline > .btn-group > .btn-group > button > span,
-#afGuiEditor #afGuiEditor-canvas .af-entity-selected > .af-gui-bar > .form-inline > span {
+#afGuiEditor #afGuiEditor-canvas .af-entity-selected > .af-gui-bar > .form-inline > span,
+#afGuiEditor #afGuiEditor-canvas .af-entity-selected > .af-gui-node-title {
   color: white;
 }
 #afGuiEditor #afGuiEditor-canvas .af-entity-selected > .af-gui-bar > .form-inline > .btn-group > .btn-group > button:hover > span,
@@ -381,6 +385,12 @@ body.af-gui-dragging {
   margin-right: 20px;
   position: relative;
 }
+#afGuiEditor .af-gui-container-title {
+  top: -21px;
+}
+#afGuiEditor .af-gui-container-title span:empty {
+  font-weight: lighter;
+}
 
 #afGuiEditor .af-gui-field-required:after {
   content: '*';
index 60391af1f2b42c90f31774e3bdf42d5e82bfe3cf..13ec8bc1edcfdc4d83e57aaed45cf5939b527b53 100644 (file)
         return str ? _.unique(_.trim(str).split(/\s+/g)) : [];
       }
 
+      // Check if a node has class(es)
+      function hasClass(node, className) {
+        if (!node['class']) {
+          return false;
+        }
+        var classes = splitClass(node['class']),
+          classNames = className.split(' ');
+        return _.intersection(classes, classNames).length === classNames.length;
+      }
+
       function modifyClasses(node, toRemove, toAdd) {
         var classes = splitClass(node['class']);
         if (toRemove) {
         if (toAdd) {
           classes = _.unique(classes.concat(splitClass(toAdd)));
         }
-        node['class'] = classes.join(' ');
+        if (classes.length) {
+          node['class'] = classes.join(' ');
+        } else if ('class' in node) {
+          delete node['class'];
+        }
       }
 
       return {
         },
 
         splitClass: splitClass,
+        hasClass: hasClass,
         modifyClasses: modifyClasses,
         getStyles: getStyles,
         setStyle: setStyle,
index 5aa0a449440395c4ad4575ab2ea48acaa9462bfe..a7b93544d850bd8e71475a189d6133eecb489cb3 100644 (file)
           // Create a new af-fieldset container for the entity
           var fieldset = _.cloneDeep(afGui.meta.elements.fieldset.element);
           fieldset['af-fieldset'] = type + num;
-          fieldset['#children'][0]['#children'][0]['#text'] = meta.label + ' ' + num;
+          fieldset['af-title'] = meta.label + ' ' + num;
           // Add boilerplate contents
           _.each(meta.boilerplate, function (tag) {
             fieldset['#children'].push(tag);
         var fieldset = {
           '#tag': 'div',
           'af-fieldset': '',
+          'af-title': display.label,
           '#children': [
             {
               '#tag': display.tag,
diff --git a/ext/afform/admin/ang/afGuiEditor/afGuiMenuItemCollapsible.component.js b/ext/afform/admin/ang/afGuiEditor/afGuiMenuItemCollapsible.component.js
new file mode 100644 (file)
index 0000000..0beec12
--- /dev/null
@@ -0,0 +1,43 @@
+// https://civicrm.org/licensing
+(function(angular, $, _) {
+  "use strict";
+
+  // Menu item to control the border property of a node
+  angular.module('afGuiEditor').component('afGuiMenuItemCollapsible', {
+    templateUrl: '~/afGuiEditor/afGuiMenuItemCollapsible.html',
+    bindings: {
+      node: '='
+    },
+    controller: function($scope, afGui) {
+      var ts = $scope.ts = CRM.ts('org.civicrm.afform_admin'),
+        ctrl = this;
+
+      this.isCollapsible = function() {
+        return afGui.hasClass(ctrl.node, 'af-collapsible');
+      };
+
+      this.isCollapsed = function() {
+        return afGui.hasClass(ctrl.node, 'af-collapsible af-collapsed');
+      };
+
+      this.toggleCollapsible = function() {
+        // Node must have a title to be collapsible
+        if (ctrl.isCollapsible() || !ctrl.node['af-title']) {
+          afGui.modifyClasses(ctrl.node, 'af-collapsible af-collapsed');
+        } else {
+          afGui.modifyClasses(ctrl.node, null, 'af-collapsible');
+        }
+      };
+
+      this.toggleCollapsed = function() {
+        if (ctrl.isCollapsed()) {
+          afGui.modifyClasses(ctrl.node, 'af-collapsed');
+        } else {
+          afGui.modifyClasses(ctrl.node, null, 'af-collapsed');
+        }
+      };
+
+    }
+  });
+
+})(angular, CRM.$, CRM._);
diff --git a/ext/afform/admin/ang/afGuiEditor/afGuiMenuItemCollapsible.html b/ext/afform/admin/ang/afGuiEditor/afGuiMenuItemCollapsible.html
new file mode 100644 (file)
index 0000000..da10569
--- /dev/null
@@ -0,0 +1,8 @@
+<label ng-class="{disabled: !$ctrl.node['af-title']}" ng-click="$ctrl.toggleCollapsible(); $event.stopPropagation();" title="{{ $ctrl.node['af-title'] ? ts('Allow user to collapse this to only show title') : ts('Must have a title to be collapsible') }}">
+  <i class="crm-i fa-{{ $ctrl.isCollapsible() ? 'check-' : '' }}square-o"></i>
+  {{:: ts('Collapsible') }}
+</label>
+<a href ng-click="$ctrl.toggleCollapsed(); $event.stopPropagation();" class="btn btn-sm btn-default" ng-class="{invisible: !$ctrl.isCollapsible()}">
+  <i class="crm-i fa-caret-{{ $ctrl.isCollapsed() ? 'right' : 'down' }}"></i>
+  {{ $ctrl.isCollapsed() ? ts('Closed') : ts('Open') }}
+</a>
index 77d2e5ceae9063a655ced3abf3cb649f0ca98b7b..9afec26ebda05041875fd5eedc7dc90658e02106 100644 (file)
@@ -30,6 +30,7 @@
     </div>
   </div>
 </li>
+<li><af-gui-menu-item-collapsible ng-if="!block" node="$ctrl.node" class="af-gui-field-select-in-dropdown form-inline"></af-gui-menu-item-collapsible></li>
 <li><af-gui-menu-item-border node="$ctrl.node"></af-gui-menu-item-border></li>
 <li><af-gui-menu-item-background node="$ctrl.node"></af-gui-menu-item-background></li>
 <li role="separator" class="divider"></li>
index 1c4a724263996e744f807195fb1b57bb9f12d77e..4942691352e807a2b9efe25e5c1eb63d4091404e 100644 (file)
         }
       };
 
+      this.getCollapsibleIcon = function() {
+        if (afGui.hasClass(ctrl.node, 'af-collapsible')) {
+          return afGui.hasClass(ctrl.node, 'af-collapsed') ? 'fa-caret-right' : 'fa-caret-down';
+        }
+      };
+
       // Sets min value for af-repeat as a string, returns it as an int
       $scope.getSetMin = function(val) {
         if (arguments.length) {
         return type.length ? type[0].replace('af-', '') : null;
       };
 
+      this.getSetTitle = function(value) {
+        if (arguments.length) {
+          if (value.length) {
+            ctrl.node['af-title'] = value;
+          } else {
+            delete ctrl.node['af-title'];
+            // With no title, cannot be collapsible
+            afGui.modifyClasses(ctrl.node, 'af-collapsible af-collapsed');
+          }
+        }
+        return ctrl.node['af-title'];
+      };
+
+      this.getToolTip = function() {
+        var text = '', nodeType;
+        if (!$scope.block) {
+          nodeType = ctrl.getNodeType(ctrl.node);
+          if (nodeType === 'fieldset') {
+            text = ctrl.editor.getEntity(ctrl.entityName).label;
+          } else if (nodeType === 'searchFieldset') {
+            text = ts('Search Display');
+          }
+          text += ' ' + $scope.tags[ctrl.node['#tag']];
+        }
+        return text;
+      };
+
       this.removeElement = function(element) {
         afGui.removeRecursive($scope.getSetChildren(), {$$hashKey: element.$$hashKey});
         ctrl.editor.onRemoveElement();
index 302043892f2bb939562ac607b4d21560f17552c9..6e3fe030db3169ab89be97d01fae5691fecc8f38 100644 (file)
@@ -1,15 +1,12 @@
-<div class="af-gui-bar" ng-if="$ctrl.node['#tag']" ng-click="selectEntity()" >
-  <div ng-if="!$ctrl.loading" class="form-inline">
-    <span ng-if="$ctrl.getNodeType($ctrl.node) == 'fieldset'">{{ $ctrl.editor.getEntity($ctrl.entityName).label }}</span>
-    <span ng-if="$ctrl.getNodeType($ctrl.node) == 'searchFieldset'">{{:: ts('Search Display') }}</span>
+<div class="af-gui-bar {{ block ? 'af-gui-block-bar' : '' }}" ng-if="$ctrl.node['#tag']" ng-click="selectEntity()" >
+  <div ng-if="!$ctrl.loading" class="form-inline" title="{{ $ctrl.getToolTip() }}">
     <span ng-if="block">{{ $ctrl.join ? ts($ctrl.join) + ':' : ts('Block:') }}</span>
-    <span ng-if="!block">{{ tags[$ctrl.node['#tag']] }}</span>
-    <select ng-if="block" ng-model="block.directive" ng-change="selectBlockDirective()">
+    <select ng-if="block" ng-model="block.directive" ng-change="selectBlockDirective()" title="{{:: ts('Select block') }}">
       <option value="">{{:: ts('Custom') }}</option>
       <option ng-value="option.id" ng-repeat="option in block.options track by option.id">{{ option.text }}</option>
     </select>
-    <button type="button" class="btn btn-default btn-xs" ng-if="block && !block.layout" ng-click="saveBlock()">{{:: ts('Save...') }}</button>
-    <div class="btn-group pull-right">
+    <button type="button" class="btn btn-default btn-xs" ng-if="block && !block.layout" ng-click="saveBlock()" title="{{:: ts('Save block') }}">{{:: ts('Save...') }}</button>
+    <div class="btn-group pull-right" title="">
       <af-gui-container-multi-toggle ng-if="!ctrl.loading && ($ctrl.join || $ctrl.node['af-repeat'])" entity="$ctrl.getFieldEntityType()" class="btn-group"></af-gui-container-multi-toggle>
       <div class="btn-group" 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') }}">
   </div>
   <div ng-if="$ctrl.loading"><i class="crm-i fa-spin fa-spinner"></i></div>
 </div>
+<label class="af-gui-node-title af-gui-container-title af-gui-text-h3" ng-if="$ctrl.node['#tag'] && !block" title="{{:: ts('Container title') }}">
+  <i class="crm-i {{ $ctrl.getCollapsibleIcon() }}"></i>
+  <span placeholder="{{:: ts('No title') }}" crm-ui-editable ng-model="$ctrl.getSetTitle" ng-model-options="{getterSetter: true}"></span>
+</label>
 <div ng-if="!$ctrl.loading" ui-sortable="$ctrl.sortableOptions" ui-sortable-update="$ctrl.editor.onDrop" ng-model="getSetChildren" ng-model-options="{getterSetter: true}" class="af-gui-layout {{ getLayout() }}">
   <div ng-repeat="item in getSetChildren()" >
     <div ng-switch="$ctrl.getNodeType(item)">
index 84db1151f5796b6f4fb93f2e7853097b8eb74088..ac3b8b7c303f805f69ecda3189af87b9597bcede 100644 (file)
@@ -6,6 +6,7 @@
     </select>
   </div>
 </li>
+<li><af-gui-menu-item-collapsible node="$ctrl.node" class="af-gui-field-select-in-dropdown form-inline"></af-gui-menu-item-collapsible></li>
 <li><af-gui-menu-item-border node="$ctrl.node"></af-gui-menu-item-border></li>
 <li><af-gui-menu-item-background node="$ctrl.node"></af-gui-menu-item-background></li>
 <li role="separator" class="divider"></li>
diff --git a/ext/afform/core/ang/af/afTitle.directive.js b/ext/afform/core/ang/af/afTitle.directive.js
new file mode 100644 (file)
index 0000000..b74a2ab
--- /dev/null
@@ -0,0 +1,28 @@
+(function(angular, $, _) {
+  "use strict";
+  angular.module('af').directive('afTitle', function() {
+    return {
+      restrict: 'A',
+      bindToController: {
+        title: '@afTitle'
+      },
+      controller: function($scope, $element) {
+        var ctrl = this;
+
+        $scope.$watch(function() {return ctrl.title;}, function(text) {
+          var tag = $element.is('fieldset') ? 'legend' : 'h4',
+            $title = $element.children(tag + '.af-title');
+          if (!$title.length) {
+            $title = $('<' + tag + ' class="af-title" />').prependTo($element);
+            if ($element.hasClass('af-collapsible')) {
+              $title.click(function() {
+                $element.toggleClass('af-collapsed');
+              });
+            }
+          }
+          $title.text(text);
+        });
+      }
+    };
+  });
+})(angular, CRM.$, CRM._);
index 554758de1f67e8892297f41c5f2e57d20bc995fb..edc60124370f64b13215cb39bbacdb8f4fc5dba5 100644 (file)
@@ -30,3 +30,20 @@ af-form {
   top: 0;
   right: 0;
 }
+
+/* Collapsible containers */
+.af-collapsible > .af-title {
+  cursor: pointer;
+}
+.af-collapsible > .af-title:before {
+  font-family: "FontAwesome";
+  display: inline-block;
+  width: 1em;
+  content: "\f0d7";
+}
+.af-collapsible.af-collapsed > .af-title:before {
+  content: "\f0da";
+}
+.af-collapsible.af-collapsed > .af-title + * {
+  display: none;
+}