Afform - improve drag-n-drop UI
[civicrm-core.git] / ext / afform / admin / ang / afGuiEditor.js
1 (function(angular, $, _) {
2 "use strict";
3
4 angular.module('afGuiEditor', CRM.angRequires('afGuiEditor'))
5
6 .service('afGui', function(crmApi4, $parse, $q) {
7
8 // Parse strings of javascript that php couldn't interpret
9 // TODO: Figure out which attributes actually need to be evaluated, as a whitelist would be less error-prone than a blacklist
10 var doNotEval = ['filters'];
11 function evaluate(collection) {
12 _.each(collection, function(item) {
13 if (_.isPlainObject(item)) {
14 evaluate(item['#children']);
15 _.each(item, function(prop, key) {
16 if (_.isString(prop) && !_.includes(doNotEval, key)) {
17 var str = _.trim(prop);
18 if (str[0] === '{' || str[0] === '[' || str.slice(0, 3) === 'ts(') {
19 item[key] = $parse(str)({ts: CRM.ts('afform')});
20 }
21 }
22 });
23 }
24 });
25 }
26
27 function getStyles(node) {
28 return !node || !node.style ? {} : _.transform(node.style.split(';'), function(styles, style) {
29 var keyVal = _.map(style.split(':'), _.trim);
30 if (keyVal.length > 1 && keyVal[1].length) {
31 styles[keyVal[0]] = keyVal[1];
32 }
33 }, {});
34 }
35
36 function setStyle(node, name, val) {
37 var styles = getStyles(node);
38 styles[name] = val;
39 if (!val) {
40 delete styles[name];
41 }
42 if (_.isEmpty(styles)) {
43 delete node.style;
44 } else {
45 node.style = _.transform(styles, function(combined, val, name) {
46 combined.push(name + ': ' + val);
47 }, []).join('; ');
48 }
49 }
50
51 // Turns a space-separated list (e.g. css classes) into an array
52 function splitClass(str) {
53 if (_.isArray(str)) {
54 return str;
55 }
56 return str ? _.unique(_.trim(str).split(/\s+/g)) : [];
57 }
58
59 function modifyClasses(node, toRemove, toAdd) {
60 var classes = splitClass(node['class']);
61 if (toRemove) {
62 classes = _.difference(classes, splitClass(toRemove));
63 }
64 if (toAdd) {
65 classes = _.unique(classes.concat(splitClass(toAdd)));
66 }
67 node['class'] = classes.join(' ');
68 }
69
70 return {
71 // Called when loading a new afform for editing - clears out stale metadata
72 resetMeta: function() {
73 _.each(CRM.afGuiEditor.entities, function(entity, type) {
74 // Skip the "*" pseudo-entity which should always have an empty list of fields
75 if (entity.fields && type !== '*') {
76 delete entity.fields;
77 }
78 });
79 CRM.afGuiEditor.blocks = {};
80 CRM.afGuiEditor.searchDisplays = {};
81 },
82
83 // Takes the results from api.Afform.loadAdminData and processes the metadata
84 // Note this runs once when loading a new afform for editing (just after this.resetMeta is called)
85 // and it also runs when adding new entities or blocks to the form.
86 addMeta: function(data) {
87 evaluate(data.definition.layout);
88 if (data.definition.type === 'block' && data.definition.name) {
89 CRM.afGuiEditor.blocks[data.definition.directive_name] = data.definition;
90 }
91 // Add new or updated blocks
92 _.each(data.blocks, function(block) {
93 // Avoid overwriting complete block record with an incomplete one
94 if (!CRM.afGuiEditor.blocks[block.directive_name] || block.layout) {
95 if (block.layout) {
96 evaluate(block.layout);
97 }
98 CRM.afGuiEditor.blocks[block.directive_name] = block;
99 }
100 });
101 _.each(data.entities, function(entity, entityName) {
102 if (!CRM.afGuiEditor.entities[entityName]) {
103 CRM.afGuiEditor.entities[entityName] = entity;
104 }
105 });
106 _.each(data.fields, function(fields, entityName) {
107 if (CRM.afGuiEditor.entities[entityName]) {
108 CRM.afGuiEditor.entities[entityName].fields = fields;
109 }
110 });
111 // Optimization - since contact fields are a combination of these three,
112 // the server doesn't send contact fields if sending contact-type fields
113 if ('Individual' in data.fields || 'Household' in data.fields || 'Organization' in data.fields) {
114 CRM.afGuiEditor.entities.Contact.fields = _.assign({},
115 (CRM.afGuiEditor.entities.Individual || {}).fields,
116 (CRM.afGuiEditor.entities.Household || {}).fields,
117 (CRM.afGuiEditor.entities.Organization || {}).fields
118 );
119 }
120 _.each(data.search_displays, function(display) {
121 CRM.afGuiEditor.searchDisplays[display['saved_search.name'] + '.' + display.name] = display;
122 });
123 },
124
125 meta: CRM.afGuiEditor,
126
127 getEntity: function(entityName) {
128 return CRM.afGuiEditor.entities[entityName];
129 },
130
131 getField: function(entityName, fieldName) {
132 var fields = CRM.afGuiEditor.entities[entityName].fields;
133 return fields[fieldName] || fields[fieldName.substr(fieldName.indexOf('.') + 1)];
134 },
135
136 // Recursively searches a collection and its children using _.filter
137 // Returns an array of all matches, or an object if the indexBy param is used
138 findRecursive: function findRecursive(collection, predicate, indexBy) {
139 var items = _.filter(collection, predicate);
140 _.each(collection, function(item) {
141 if (_.isPlainObject(item) && item['#children']) {
142 var childMatches = findRecursive(item['#children'], predicate);
143 if (childMatches.length) {
144 Array.prototype.push.apply(items, childMatches);
145 }
146 }
147 });
148 return indexBy ? _.indexBy(items, indexBy) : items;
149 },
150
151 // Applies _.remove() to an item and its children
152 removeRecursive: function removeRecursive(collection, removeParams) {
153 _.remove(collection, removeParams);
154 _.each(collection, function(item) {
155 if (_.isPlainObject(item) && item['#children']) {
156 removeRecursive(item['#children'], removeParams);
157 }
158 });
159 },
160
161 splitClass: splitClass,
162 modifyClasses: modifyClasses,
163 getStyles: getStyles,
164 setStyle: setStyle,
165
166 pickIcon: function() {
167 var deferred = $q.defer();
168 $('#af-gui-icon-picker').off('change').siblings('.crm-icon-picker-button').click();
169 $('#af-gui-icon-picker').on('change', function() {
170 deferred.resolve($(this).val());
171 });
172 return deferred.promise;
173 }
174 };
175 });
176
177 $(function() {
178 // Shoehorn in a non-angular widget for picking icons
179 $('#crm-container').append('<div style="display:none"><input id="af-gui-icon-picker"></div>');
180 CRM.loadScript(CRM.config.resourceBase + 'js/jquery/jquery.crmIconPicker.js').done(function() {
181 $('#af-gui-icon-picker').crmIconPicker();
182 });
183 // Add css class while dragging
184 $(document)
185 .on('sortover', function(e) {
186 $('.af-gui-container').removeClass('af-gui-dragtarget');
187 $(e.target).closest('.af-gui-container').addClass('af-gui-dragtarget');
188 })
189 .on('sortout', '.af-gui-container', function() {
190 $(this).removeClass('af-gui-dragtarget');
191 })
192 .on('sortstart', '#afGuiEditor', function() {
193 $('body').addClass('af-gui-dragging');
194 })
195 .on('sortstop', function() {
196 $('body').removeClass('af-gui-dragging');
197 $('.af-gui-dragtarget').removeClass('af-gui-dragtarget');
198 });
199 });
200
201 // Connect bootstrap dropdown.js with angular
202 // Allows menu content to be conditionally rendered only if open
203 // This gives a large performance boost for a page with lots of menus
204 angular.module('afGuiEditor').directive('afGuiMenu', function() {
205 return {
206 restrict: 'A',
207 link: function($scope, element, attrs) {
208 $scope.menu = {};
209 element
210 .on('show.bs.dropdown', function() {
211 $scope.$apply(function() {
212 $scope.menu.open = true;
213 });
214 })
215 .on('hidden.bs.dropdown', function() {
216 $scope.$apply(function() {
217 $scope.menu.open = false;
218 });
219 });
220 }
221 };
222 });
223
224 })(angular, CRM.$, CRM._);