Fields drag n drop
[civicrm-core.git] / ext / afform / gui / ang / afGuiEditor.js
CommitLineData
f6c0358e
CW
1(function(angular, $, _) {
2 angular.module('afGuiEditor', CRM.angRequires('afGuiEditor'));
3
65c9e7ae 4 angular.module('afGuiEditor').directive('afGuiEditor', function(crmApi4, $parse, $timeout) {
f6c0358e
CW
5 return {
6 restrict: 'A',
f6c0358e
CW
7 templateUrl: '~/afGuiEditor/main.html',
8 scope: {
9 afGuiEditor: '='
10 },
f3cd3852 11 controller: function($scope) {
f6c0358e
CW
12 $scope.ts = CRM.ts();
13 $scope.afform = null;
14 $scope.selectedEntity = null;
c820e08c 15 $scope.meta = CRM.afformAdminData;
a064e90d 16 $scope.controls = {};
65c9e7ae 17 $scope.fieldList = {};
f3cd3852 18 $scope.editor = this;
f6c0358e
CW
19 var newForm = {
20 title: ts('Untitled Form'),
66af6937 21 layout: [{
f6c0358e
CW
22 '#tag': 'af-form',
23 ctrl: 'modelListCtrl',
24 '#children': [
25 {
26 '#tag': 'af-entity',
27 type: 'Contact',
28 data: {
29 contact_type: 'Individual'
30 },
31 name: 'Contact1',
32 label: 'Contact 1',
33 'url-autofill': '1',
34 autofill: 'user'
35 }
36 ]
66af6937 37 }]
f6c0358e
CW
38 };
39 if ($scope.afGuiEditor.name) {
66af6937 40 crmApi4('Afform', 'get', {where: [['name', '=', $scope.afGuiEditor.name]], layoutFormat: 'shallow'}, 0)
f6c0358e
CW
41 .then(initialize);
42 }
43 else {
44 initialize(newForm);
45 }
46
47 function initialize(afform) {
48 // Todo - show error msg if form is not found
49 $scope.afform = afform;
66af6937
CW
50 $scope.layout = getTags($scope.afform.layout, 'af-form')[0];
51 evaluate($scope.layout['#children']);
66af6937 52 $scope.entities = getTags($scope.layout['#children'], 'af-entity', 'name');
65c9e7ae
CW
53 expandFields($scope.layout['#children']);
54 _.each(_.keys($scope.entities), buildFieldList);
f6c0358e
CW
55 }
56
f3cd3852 57 this.addEntity = function(entityType) {
f6c0358e
CW
58 var existingEntitiesofThisType = _.map(_.filter($scope.entities, {type: entityType}), 'name'),
59 num = existingEntitiesofThisType.length + 1;
60 // Give this new entity a unique name
61 while (_.contains(existingEntitiesofThisType, entityType + num)) {
62 num++;
63 }
64 $scope.entities[entityType + num] = {
65 '#tag': 'af-entity',
66 type: entityType,
67 name: entityType + num,
68 label: entityType + ' ' + num
69 };
66af6937 70 $scope.layout['#children'].unshift($scope.entities[entityType + num]);
f3cd3852 71 this.selectEntity(entityType + num);
f6c0358e
CW
72 };
73
f3cd3852 74 this.removeEntity = function(entityName) {
f6c0358e 75 delete $scope.entities[entityName];
66af6937 76 _.remove($scope.layout['#children'], {'#tag': 'af-entity', name: entityName});
f3cd3852 77 this.selectEntity(null);
f6c0358e
CW
78 };
79
f3cd3852 80 this.selectEntity = function(entityName) {
f6c0358e
CW
81 $scope.selectedEntity = entityName;
82 };
83
f3cd3852
CW
84 this.getField = function(entityType, fieldName) {
85 return _.filter($scope.meta.fields[entityType], {name: fieldName})[0];
86 };
87
88 this.getEntity = function(entityName) {
89 return $scope.entities[entityName];
90 };
91
92 this.getSelectedEntity = function() {
93 return $scope.selectedEntity;
a064e90d
CW
94 };
95
65c9e7ae
CW
96 $scope.rebuildFieldList = function() {
97 $timeout(function() {
98 $scope.$apply(function() {
99 buildFieldList($scope.selectedEntity);
100 });
101 });
102 };
103
104 function buildFieldList(entityName) {
105 $scope.fieldList[entityName] = $scope.fieldList[entityName] || [];
106 $scope.fieldList[entityName].length = 0;
107 _.each($scope.meta.fields[$scope.entities[entityName].type], function(field) {
108 $scope.fieldList[entityName].push({
109 "#tag": "af-field",
110 name: field.name,
111 defn: _.cloneDeep(_.pick(field, ['title', 'input_type', 'input_attrs']))
112 });
113 });
114 }
115
a064e90d
CW
116 $scope.valuesFields = function() {
117 var fields = _.transform($scope.meta.fields[$scope.entities[$scope.selectedEntity].type], function(fields, field) {
65c9e7ae 118 fields.push({id: field.name, text: field.title, disabled: $scope.fieldInUse($scope.selectedEntity, field.name)});
a064e90d
CW
119 }, []);
120 return {results: fields};
121 };
122
123 $scope.removeValue = function(entity, fieldName) {
124 delete entity.data[fieldName];
125 };
126
127 $scope.$watch('controls.addValue', function(fieldName) {
128 if (fieldName) {
66af6937
CW
129 if (!$scope.entities[$scope.selectedEntity].data) {
130 $scope.entities[$scope.selectedEntity].data = {};
131 }
a064e90d
CW
132 $scope.entities[$scope.selectedEntity].data[fieldName] = '';
133 $scope.controls.addValue = '';
134 }
135 });
136
65c9e7ae
CW
137 // Checks if a field is on the form or set as a value
138 $scope.fieldInUse = function(entityName, fieldName) {
139 var data = $scope.entities[entityName].data || {},
140 found = false;
141 if (fieldName in data) {
142 return true;
143 }
144 return check($scope.layout['#children']);
145 function check(group) {
146 _.each(group, function(item) {
147 if (found) {
148 return false;
149 }
150 if (_.isPlainObject(item)) {
151 if ((!item['af-fieldset'] || (item['af-fieldset'] === entityName)) && item['#children']) {
152 check(item['#children']);
153 }
154 if (item['#tag'] === 'af-field' && item.name === fieldName) {
155 found = true;
156 }
157 }
158 });
159 return found;
160 }
161 };
162
163 // Parse strings of javascript that php couldn't interpret
66af6937
CW
164 function evaluate(collection) {
165 _.each(collection, function(item) {
166 if (_.isPlainObject(item)) {
167 evaluate(item['#children']);
168 _.each(item, function(node, idx) {
169 if (_.isString(node)) {
170 var str = _.trim(node);
171 if (str[0] === '{' || str[0] === '[' || str.slice(0, 3) === 'ts(') {
172 item[idx] = $parse(str)({ts: $scope.ts});
173 }
174 }
175 });
176 }
177 });
178 }
179
65c9e7ae
CW
180 function expandFields(collection, entityType) {
181 _.each(collection, function (item) {
182 if (_.isPlainObject(item)) {
183 if (item['af-fieldset']) {
184 expandFields(item['#children'], $scope.editor.getEntity(item['af-fieldset']).type);
185 }
186 else if (item['#tag'] === 'af-field') {
187 item.defn = item.defn || {};
188 _.defaults(item.defn, _.cloneDeep(_.pick($scope.editor.getField(entityType, item.name), ['title', 'input_type', 'input_attrs'])));
189 } else {
190 expandFields(item['#children'], entityType);
191 }
192 }
193 });
194 }
195
f6c0358e
CW
196 }
197 };
198 });
199
200 function getTags(collection, tagName, indexBy) {
201 var items = [];
202 _.each(collection, function(item) {
203 if (item && typeof item === 'object') {
204 if (item['#tag'] === tagName) {
205 items.push(item);
206 }
207 var childTags = item['#children'] ? getTags(item['#children'], tagName) : [];
208 if (childTags.length) {
209 Array.prototype.push.apply(items, childTags);
210 }
211 }
212 });
213 return indexBy ? _.indexBy(items, indexBy) : items;
214 }
215
f3cd3852
CW
216 // Turns a space-separated list (e.g. css classes) into an array
217 function splitClass(str) {
218 if (_.isArray(str)) {
219 return str;
220 }
221 return str ? _.unique(_.trim(str).split(/\s+/g)) : [];
222 }
223
66af6937
CW
224 angular.module('afGuiEditor').directive('afGuiBlock', function() {
225 return {
226 restrict: 'A',
227 templateUrl: '~/afGuiEditor/block.html',
228 scope: {
f3cd3852
CW
229 node: '=afGuiBlock',
230 entityName: '='
66af6937 231 },
f3cd3852
CW
232 require: '^^afGuiEditor',
233 link: function($scope, element, attrs, editor) {
234 $scope.editor = editor;
235 },
236 controller: function($scope) {
237 $scope.block = this;
238 this.node = $scope.node;
239
240 this.modifyClasses = function(item, toRemove, toAdd) {
241 var classes = splitClass(item['class']);
242 if (toRemove) {
243 classes = _.difference(classes, splitClass(toRemove));
244 }
245 if (toAdd) {
246 classes = _.unique(classes.concat(splitClass(toAdd)));
247 }
248 item['class'] = classes.join(' ');
249 };
250
251 this.getNodeType = function(node) {
252 if (!node) {
253 return null;
254 }
255 if (node['#tag'] === 'af-field') {
256 return 'field';
257 }
258 if (node['af-fieldset']) {
259 return 'fieldset';
260 }
261 var classes = splitClass(node['class']);
262 if (_.contains(classes, 'af-block')) {
263 return 'block';
264 }
265 if (_.contains(classes, 'af-text')) {
266 return 'text';
267 }
268 return null;
269 };
270
271 $scope.isSelectedFieldset = function(entityName) {
272 return entityName === $scope.editor.getSelectedEntity();
273 };
274
275 $scope.selectEntity = function() {
276 if ($scope.node['af-fieldset']) {
277 $scope.editor.selectEntity($scope.node['af-fieldset']);
278 }
66af6937 279 };
f3cd3852
CW
280
281 $scope.tags = {
282 div: ts('Block'),
283 fieldset: ts('Fieldset')
284 };
285
66af6937
CW
286 }
287 };
288 });
289
f3cd3852 290 angular.module('afGuiEditor').directive('afGuiField', function() {
66af6937
CW
291 return {
292 restrict: 'A',
f3cd3852 293 templateUrl: '~/afGuiEditor/field.html',
66af6937 294 scope: {
f3cd3852
CW
295 node: '=afGuiField',
296 entityName: '='
297 },
298 require: '^^afGuiEditor',
299 link: function($scope, element, attrs, editor) {
300 $scope.editor = editor;
66af6937 301 },
f3cd3852
CW
302 controller: function($scope) {
303
304 $scope.getEntity = function() {
305 return $scope.editor.getEntity($scope.entityName);
306 };
307
308 $scope.getDefn = function() {
309 return $scope.editor.getField($scope.getEntity().type, $scope.node.name);
66af6937
CW
310 };
311 }
312 };
313 });
314
f3cd3852 315 angular.module('afGuiEditor').directive('afGuiText', function() {
66af6937
CW
316 return {
317 restrict: 'A',
f3cd3852 318 templateUrl: '~/afGuiEditor/text.html',
66af6937 319 scope: {
f3cd3852 320 node: '=afGuiText'
66af6937 321 },
f3cd3852
CW
322 require: '^^afGuiBlock',
323 link: {
324 pre: function($scope, element, attrs, block) {
325 $scope.block = block;
326 },
327 post: function($scope, element, attrs) {
328 if ($scope.block.node && $scope.block.node['#tag'] === 'fieldset') {
329 $scope.tags.legend = ts('Fieldset Legend');
330 }
331 }
332 },
333 controller: function($scope) {
334 $scope.tags = {
335 p: ts('Normal Text'),
336 h1: ts('Heading 1'),
337 h2: ts('Heading 2'),
338 h3: ts('Heading 3'),
339 h4: ts('Heading 4'),
340 h5: ts('Heading 5'),
341 h6: ts('Heading 6')
342 };
343
344 $scope.alignments = {
345 'text-left': ts('Align left'),
346 'text-center': ts('Align center'),
347 'text-right': ts('Align right'),
348 'text-justify': ts('Justify')
349 };
350
351 $scope.getAlign = function() {
352 return _.intersection(splitClass($scope.node['class']), _.keys($scope.alignments))[0];
353 };
354
355 $scope.setAlign = function(val) {
356 $scope.block.modifyClasses($scope.node, _.keys($scope.alignments), val);
357 };
66af6937
CW
358 }
359 };
360 });
361
a2854e2e 362 // Editable titles using ngModel & html5 contenteditable
a064e90d 363 // Cribbed from ContactLayoutEditor
a2854e2e
CW
364 angular.module('afGuiEditor').directive("afGuiEditable", function() {
365 return {
366 restrict: "A",
367 require: "ngModel",
368 link: function(scope, element, attrs, ngModel) {
369 var ts = CRM.ts();
370
371 function read() {
372 var htmlVal = element.html();
373 if (!htmlVal) {
374 htmlVal = ts('Unnamed');
375 element.html(htmlVal);
376 }
377 ngModel.$setViewValue(htmlVal);
378 }
379
380 ngModel.$render = function() {
381 element.html(ngModel.$viewValue || ' ');
382 };
383
384 // Special handling for enter and escape keys
385 element.on('keydown', function(e) {
386 // Enter: prevent line break and save
387 if (e.which === 13) {
388 e.preventDefault();
389 element.blur();
390 }
391 // Escape: undo
392 if (e.which === 27) {
393 element.html(ngModel.$viewValue || ' ');
394 element.blur();
395 }
396 });
397
398 element.on("blur change", function() {
399 scope.$apply(read);
400 });
401
402 element.attr('contenteditable', 'true').addClass('crm-editable-enabled');
403 }
404 };
405 });
406
a064e90d
CW
407 // Cribbed from the Api4 Explorer
408 angular.module('afGuiEditor').directive('afGuiFieldValue', function() {
409 return {
410 scope: {
411 field: '=afGuiFieldValue'
412 },
413 require: 'ngModel',
414 link: function (scope, element, attrs, ctrl) {
415 var ts = scope.ts = CRM.ts(),
416 multi;
417
418 function destroyWidget() {
419 var $el = $(element);
420 if ($el.is('.crm-form-date-wrapper .crm-hidden-date')) {
421 $el.crmDatepicker('destroy');
422 }
423 if ($el.is('.select2-container + input')) {
424 $el.crmEntityRef('destroy');
425 }
426 $(element).removeData().removeAttr('type').removeAttr('placeholder').show();
427 }
428
429 function makeWidget(field) {
430 var $el = $(element),
431 inputType = field.input_type,
432 dataType = field.data_type;
433 multi = field.serialize || dataType === 'Array';
434 if (inputType === 'Date') {
435 $el.crmDatepicker({time: (field.input_attrs && field.input_attrs.time) || false});
436 }
437 else if (field.fk_entity || field.options || dataType === 'Boolean') {
438 if (field.fk_entity) {
439 $el.crmEntityRef({entity: field.fk_entity, select:{multiple: multi}});
440 } else if (field.options) {
441 var options = _.transform(field.options, function(options, val, key) {
442 options.push({id: key, text: val});
443 }, []);
444 $el.select2({data: options, multiple: multi});
445 } else if (dataType === 'Boolean') {
446 $el.attr('placeholder', ts('- select -')).crmSelect2({allowClear: false, multiple: multi, placeholder: ts('- select -'), data: [
447 {id: '1', text: ts('Yes')},
448 {id: '0', text: ts('No')}
449 ]});
450 }
451 } else if (dataType === 'Integer' && !multi) {
452 $el.attr('type', 'number');
453 }
454 }
455
456 // Copied from ng-list but applied conditionally if field is multi-valued
457 var parseList = function(viewValue) {
458 // If the viewValue is invalid (say required but empty) it will be `undefined`
459 if (_.isUndefined(viewValue)) return;
460
461 if (!multi) {
462 return viewValue;
463 }
464
465 var list = [];
466
467 if (viewValue) {
468 _.each(viewValue.split(','), function(value) {
469 if (value) list.push(_.trim(value));
470 });
471 }
472
473 return list;
474 };
475
476 // Copied from ng-list
477 ctrl.$parsers.push(parseList);
478 ctrl.$formatters.push(function(value) {
479 return _.isArray(value) ? value.join(', ') : value;
480 });
481
482 // Copied from ng-list
483 ctrl.$isEmpty = function(value) {
484 return !value || !value.length;
485 };
486
487 scope.$watchCollection('field', function(field) {
488 destroyWidget();
489 if (field) {
490 makeWidget(field);
491 }
492 });
493 }
494 };
495 });
496
f6c0358e 497})(angular, CRM.$, CRM._);