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
'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' => [],
],
],
];
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;
}
/* 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,
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: '*';
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,
// 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,
--- /dev/null
+// 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._);
--- /dev/null
+<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>
</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>
}
};
+ 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();
-<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)">
</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>
--- /dev/null
+(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._);
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;
+}