Afform - Refactor icon picker to service
[civicrm-core.git] / ext / afform / admin / ang / afGuiEditor.js
CommitLineData
f6c0358e 1(function(angular, $, _) {
881d52bb 2 "use strict";
cb46dc65
CW
3 angular.module('afGuiEditor', CRM.angRequires('afGuiEditor'))
4
d132f0a6 5 .service('afAdmin', function(crmApi4, $parse, $q) {
cb46dc65
CW
6
7 // Parse strings of javascript that php couldn't interpret
8 function evaluate(collection) {
9 _.each(collection, function(item) {
10 if (_.isPlainObject(item)) {
11 evaluate(item['#children']);
12 _.each(item, function(node, idx) {
13 if (_.isString(node)) {
14 var str = _.trim(node);
15 if (str[0] === '{' || str[0] === '[' || str.slice(0, 3) === 'ts(') {
16 item[idx] = $parse(str)({ts: CRM.ts('afform')});
17 }
18 }
19 });
20 }
21 });
22 }
23
24 function getStyles(node) {
25 return !node || !node.style ? {} : _.transform(node.style.split(';'), function(styles, style) {
26 var keyVal = _.map(style.split(':'), _.trim);
27 if (keyVal.length > 1 && keyVal[1].length) {
28 styles[keyVal[0]] = keyVal[1];
29 }
30 }, {});
31 }
32
33 function setStyle(node, name, val) {
34 var styles = getStyles(node);
35 styles[name] = val;
36 if (!val) {
37 delete styles[name];
38 }
39 if (_.isEmpty(styles)) {
40 delete node.style;
41 } else {
42 node.style = _.transform(styles, function(combined, val, name) {
43 combined.push(name + ': ' + val);
44 }, []).join('; ');
45 }
46 }
47
48 // Turns a space-separated list (e.g. css classes) into an array
49 function splitClass(str) {
50 if (_.isArray(str)) {
51 return str;
52 }
53 return str ? _.unique(_.trim(str).split(/\s+/g)) : [];
54 }
55
56 function modifyClasses(node, toRemove, toAdd) {
57 var classes = splitClass(node['class']);
58 if (toRemove) {
59 classes = _.difference(classes, splitClass(toRemove));
60 }
61 if (toAdd) {
62 classes = _.unique(classes.concat(splitClass(toAdd)));
63 }
64 node['class'] = classes.join(' ');
65 }
66
67 return {
68 // Initialize/refresh data about the current afform + available blocks
69 initialize: function(afName) {
70 var promise = crmApi4('Afform', 'get', {
71 layoutFormat: 'shallow',
72 formatWhitespace: true,
73 where: [afName ? ["OR", [["name", "=", afName], ["block", "IS NOT NULL"]]] : ["block", "IS NOT NULL"]]
74 });
75 promise.then(function(afforms) {
76 CRM.afGuiEditor.blocks = {};
77 _.each(afforms, function(form) {
78 evaluate(form.layout);
79 if (form.block) {
80 CRM.afGuiEditor.blocks[form.directive_name] = form;
81 }
82 });
83 });
84 return promise;
85 },
86
87 meta: CRM.afGuiEditor,
88
89 getField: function(entityType, fieldName) {
90 return CRM.afGuiEditor.entities[entityType].fields[fieldName];
91 },
92
93 // Recursively searches a collection and its children using _.filter
94 // Returns an array of all matches, or an object if the indexBy param is used
95 findRecursive: function findRecursive(collection, predicate, indexBy) {
96 var items = _.filter(collection, predicate);
97 _.each(collection, function(item) {
98 if (_.isPlainObject(item) && item['#children']) {
99 var childMatches = findRecursive(item['#children'], predicate);
100 if (childMatches.length) {
101 Array.prototype.push.apply(items, childMatches);
102 }
103 }
104 });
105 return indexBy ? _.indexBy(items, indexBy) : items;
106 },
107
108 // Applies _.remove() to an item and its children
109 removeRecursive: function removeRecursive(collection, removeParams) {
110 _.remove(collection, removeParams);
111 _.each(collection, function(item) {
112 if (_.isPlainObject(item) && item['#children']) {
113 removeRecursive(item['#children'], removeParams);
114 }
115 });
116 },
117
118 splitClass: splitClass,
119 modifyClasses: modifyClasses,
120 getStyles: getStyles,
d132f0a6
CW
121 setStyle: setStyle,
122
123 pickIcon: function() {
124 var deferred = $q.defer();
125 $('#af-gui-icon-picker').off('change').siblings('.crm-icon-picker-button').click();
126 $('#af-gui-icon-picker').on('change', function() {
127 deferred.resolve($(this).val());
128 });
129 return deferred.promise;
130 }
cb46dc65
CW
131 };
132 });
f6c0358e 133
d132f0a6
CW
134 // Shoehorn in a non-angular widget for picking icons
135 $(function() {
136 $('#crm-container').append('<div style="display:none"><input id="af-gui-icon-picker"></div>');
137 CRM.loadScript(CRM.config.resourceBase + 'js/jquery/jquery.crmIconPicker.js').done(function() {
138 $('#af-gui-icon-picker').crmIconPicker();
139 });
140 });
141
2e2aaaea
CW
142 angular.module('afGuiEditor').component('afGuiEditor', {
143 templateUrl: '~/afGuiEditor/main.html',
144 bindings: {
145 name: '<'
146 },
147 controllerAs: 'editor',
cb46dc65
CW
148 controller: function($scope, crmApi4, afAdmin, $parse, $timeout, $location) {
149 var ts = $scope.ts = CRM.ts('afform');
2e2aaaea
CW
150 $scope.afform = null;
151 $scope.saving = false;
152 $scope.selectedEntityName = null;
cb46dc65 153 this.meta = afAdmin.meta;
2e2aaaea
CW
154 var editor = this;
155 var newForm = {
156 title: '',
157 permission: 'access CiviCRM',
158 layout: [{
159 '#tag': 'af-form',
160 ctrl: 'afform',
161 '#children': []
162 }]
163 };
164
165 this.$onInit = function() {
9633741a 166 // Fetch the current form plus all blocks
cb46dc65
CW
167 afAdmin.initialize(editor.name)
168 .then(initializeForm);
2e2aaaea 169 };
9633741a 170
cb46dc65
CW
171 // Initialize the current form
172 function initializeForm(afforms) {
173 $scope.afform = _.findWhere(afforms, {name: editor.name});
2e2aaaea
CW
174 if (!$scope.afform) {
175 $scope.afform = _.cloneDeep(newForm);
176 if (editor.name != '0') {
177 alert('Error: unknown form "' + editor.name + '"');
9633741a 178 }
2e2aaaea
CW
179 }
180 $scope.canvasTab = 'layout';
181 $scope.layoutHtml = '';
cb46dc65
CW
182 editor.layout = afAdmin.findRecursive($scope.afform.layout, {'#tag': 'af-form'})[0];
183 $scope.entities = afAdmin.findRecursive(editor.layout['#children'], {'#tag': 'af-entity'}, 'name');
2e2aaaea
CW
184
185 if (editor.name == '0') {
186 editor.addEntity('Individual');
cb46dc65 187 editor.layout['#children'].push(afAdmin.meta.elements.submit.element);
f6c0358e
CW
188 }
189
2e2aaaea
CW
190 // Set changesSaved to true on initial load, false thereafter whenever changes are made to the model
191 $scope.changesSaved = editor.name == '0' ? false : 1;
192 $scope.$watch('afform', function () {
193 $scope.changesSaved = $scope.changesSaved === 1;
194 }, true);
195 }
37413559 196
2e2aaaea
CW
197 $scope.updateLayoutHtml = function() {
198 $scope.layoutHtml = '...Loading...';
67db2e07 199 crmApi4('Afform', 'convert', {layout: [editor.layout], from: 'deep', to: 'html', formatWhitespace: true})
2e2aaaea
CW
200 .then(function(r){
201 $scope.layoutHtml = r[0].layout || '(Error)';
202 })
203 .catch(function(r){
204 $scope.layoutHtml = '(Error)';
1b745abc 205 });
2e2aaaea 206 };
f6c0358e 207
2e2aaaea 208 this.addEntity = function(type) {
cb46dc65 209 var meta = afAdmin.meta.entities[type],
2e2aaaea
CW
210 num = 1;
211 // Give this new entity a unique name
212 while (!!$scope.entities[type + num]) {
213 num++;
214 }
215 $scope.entities[type + num] = _.assign($parse(meta.defaults)($scope), {
216 '#tag': 'af-entity',
217 type: meta.entity,
218 name: type + num,
219 label: meta.label + ' ' + num
220 });
221 // Add this af-entity tag after the last existing one
67db2e07
CW
222 var pos = 1 + _.findLastIndex(editor.layout['#children'], {'#tag': 'af-entity'});
223 editor.layout['#children'].splice(pos, 0, $scope.entities[type + num]);
2e2aaaea 224 // Create a new af-fieldset container for the entity
cb46dc65 225 var fieldset = _.cloneDeep(afAdmin.meta.elements.fieldset.element);
2e2aaaea
CW
226 fieldset['af-fieldset'] = type + num;
227 fieldset['#children'][0]['#children'][0]['#text'] = meta.label + ' ' + num;
228 // Add default contact name block
229 if (meta.entity === 'Contact') {
230 fieldset['#children'].push({'#tag': 'afblock-name-' + type.toLowerCase()});
231 }
232 // Attempt to place the new af-fieldset after the last one on the form
67db2e07 233 pos = 1 + _.findLastIndex(editor.layout['#children'], 'af-fieldset');
2e2aaaea 234 if (pos) {
67db2e07 235 editor.layout['#children'].splice(pos, 0, fieldset);
2e2aaaea 236 } else {
67db2e07 237 editor.layout['#children'].push(fieldset);
2e2aaaea
CW
238 }
239 return type + num;
240 };
f6c0358e 241
2e2aaaea
CW
242 this.removeEntity = function(entityName) {
243 delete $scope.entities[entityName];
cb46dc65
CW
244 afAdmin.removeRecursive(editor.layout['#children'], {'#tag': 'af-entity', name: entityName});
245 afAdmin.removeRecursive(editor.layout['#children'], {'af-fieldset': entityName});
2e2aaaea
CW
246 this.selectEntity(null);
247 };
f6c0358e 248
2e2aaaea
CW
249 this.selectEntity = function(entityName) {
250 $scope.selectedEntityName = entityName;
251 };
f3cd3852 252
2e2aaaea
CW
253 this.getEntity = function(entityName) {
254 return $scope.entities[entityName];
255 };
a064e90d 256
2e2aaaea
CW
257 this.getSelectedEntityName = function() {
258 return $scope.selectedEntityName;
259 };
260
261 // Validates that a drag-n-drop action is allowed
262 this.onDrop = function(event, ui) {
263 var sort = ui.item.sortable;
264 // Check if this is a callback for an item dropped into a different container
265 // @see https://github.com/angular-ui/ui-sortable notes on canceling
266 if (!sort.received && sort.source[0] !== sort.droptarget[0]) {
267 var $source = $(sort.source[0]),
268 $target = $(sort.droptarget[0]),
269 $item = $(ui.item[0]);
270 // Fields cannot be dropped outside their own entity
271 if ($item.is('[af-gui-field]') || $item.has('[af-gui-field]').length) {
272 if ($source.closest('[data-entity]').attr('data-entity') !== $target.closest('[data-entity]').attr('data-entity')) {
e9dc7ca5
CW
273 return sort.cancel();
274 }
275 }
2e2aaaea
CW
276 // Entity-fieldsets cannot be dropped into other entity-fieldsets
277 if ((sort.model['af-fieldset'] || $item.has('.af-gui-fieldset').length) && $target.closest('.af-gui-fieldset').length) {
278 return sort.cancel();
279 }
280 }
281 };
fd6c0814 282
2e2aaaea
CW
283 $scope.addEntity = function(entityType) {
284 var entityName = editor.addEntity(entityType);
285 editor.selectEntity(entityName);
286 };
28b4ace4 287
2e2aaaea
CW
288 $scope.save = function() {
289 $scope.saving = $scope.changesSaved = true;
290 crmApi4('Afform', 'save', {formatWhitespace: true, records: [JSON.parse(angular.toJson($scope.afform))]})
291 .then(function (data) {
292 $scope.saving = false;
293 $scope.afform.name = data[0].name;
294 // FIXME: This causes an unnecessary reload when saving a new form
295 $location.search('name', data[0].name);
296 });
297 };
1b745abc 298
2e2aaaea
CW
299 $scope.$watch('afform.title', function(newTitle, oldTitle) {
300 if (typeof oldTitle === 'string') {
301 _.each($scope.entities, function(entity) {
302 if (entity.data && entity.data.source === oldTitle) {
303 entity.data.source = newTitle;
66af6937
CW
304 }
305 });
306 }
2e2aaaea 307 });
2e2aaaea 308 }
f6c0358e
CW
309 });
310
67db2e07
CW
311 angular.module('afGuiEditor').component('afGuiEntity', {
312 templateUrl: '~/afGuiEditor/entity.html',
313 bindings: {
314 entity: '<'
315 },
316 require: {editor: '^^afGuiEditor'},
cb46dc65 317 controller: function ($scope, $timeout, afAdmin) {
67db2e07
CW
318 var ts = $scope.ts = CRM.ts();
319 var ctrl = this;
320 $scope.controls = {};
321 $scope.fieldList = [];
322 $scope.blockList = [];
323 $scope.blockTitles = [];
324 $scope.elementList = [];
325 $scope.elementTitles = [];
326
327 function getEntityType() {
328 return ctrl.entity.type === 'Contact' ? ctrl.entity.data.contact_type : ctrl.entity.type;
329 }
54dbfd05 330
67db2e07 331 $scope.getMeta = function() {
cb46dc65 332 return afAdmin.meta.entities[getEntityType()];
67db2e07 333 };
0406c8f9 334
cb46dc65
CW
335 $scope.getField = afAdmin.getField;
336
67db2e07
CW
337 $scope.valuesFields = function() {
338 var fields = _.transform($scope.getMeta().fields, function(fields, field) {
339 fields.push({id: field.name, text: field.label, disabled: $scope.fieldInUse(field.name)});
340 }, []);
341 return {results: fields};
342 };
e1aca853 343
67db2e07
CW
344 $scope.removeValue = function(entity, fieldName) {
345 delete entity.data[fieldName];
346 };
54dbfd05 347
67db2e07
CW
348 function buildPaletteLists() {
349 var search = $scope.controls.fieldSearch ? $scope.controls.fieldSearch.toLowerCase() : null;
350 buildFieldList(search);
351 buildBlockList(search);
352 buildElementList(search);
353 }
54dbfd05 354
67db2e07
CW
355 function buildFieldList(search) {
356 $scope.fieldList.length = 0;
357 $scope.fieldList.push({
358 entityName: ctrl.entity.name,
359 entityType: getEntityType(),
360 label: ts('%1 Fields', {1: $scope.getMeta().label}),
361 fields: filterFields($scope.getMeta().fields)
362 });
363
cb46dc65 364 _.each(afAdmin.meta.entities, function(entity, entityName) {
67db2e07
CW
365 if (check(ctrl.editor.layout['#children'], {'af-join': entityName})) {
366 $scope.fieldList.push({
367 entityName: ctrl.entity.name + '-join-' + entityName,
368 entityType: entityName,
369 label: ts('%1 Fields', {1: entity.label}),
370 fields: filterFields(entity.fields)
371 });
54dbfd05 372 }
67db2e07 373 });
e1aca853 374
67db2e07
CW
375 function filterFields(fields) {
376 return _.transform(fields, function(fieldList, field) {
377 if (!search || _.contains(field.name, search) || _.contains(field.label.toLowerCase(), search)) {
378 fieldList.push({
379 "#tag": "af-field",
380 name: field.name
381 });
e1aca853 382 }
67db2e07 383 }, []);
e1aca853 384 }
67db2e07 385 }
e1aca853 386
67db2e07
CW
387 function buildBlockList(search) {
388 $scope.blockList.length = 0;
389 $scope.blockTitles.length = 0;
cb46dc65 390 _.each(afAdmin.meta.blocks, function(block, directive) {
67db2e07
CW
391 if ((!search || _.contains(directive, search) || _.contains(block.name.toLowerCase(), search) || _.contains(block.title.toLowerCase(), search)) &&
392 (block.block === '*' || block.block === ctrl.entity.type || (ctrl.entity.type === 'Contact' && block.block === ctrl.entity.data.contact_type))
393 ) {
394 var item = {"#tag": block.join ? "div" : directive};
395 if (block.join) {
396 item['af-join'] = block.join;
397 item['#children'] = [{"#tag": directive}];
398 }
399 if (block.repeat) {
400 item['af-repeat'] = ts('Add');
401 item.min = '1';
402 if (typeof block.repeat === 'number') {
403 item.max = '' + block.repeat;
e9dc7ca5 404 }
e1aca853 405 }
67db2e07
CW
406 $scope.blockList.push(item);
407 $scope.blockTitles.push(block.title);
408 }
409 });
410 }
edac0d6e 411
67db2e07
CW
412 function buildElementList(search) {
413 $scope.elementList.length = 0;
414 $scope.elementTitles.length = 0;
cb46dc65 415 _.each(afAdmin.meta.elements, function(element, name) {
67db2e07
CW
416 if (!search || _.contains(name, search) || _.contains(element.title.toLowerCase(), search)) {
417 var node = _.cloneDeep(element.element);
418 if (name === 'fieldset') {
419 node['af-fieldset'] = ctrl.entity.name;
420 }
421 $scope.elementList.push(node);
422 $scope.elementTitles.push(name === 'fieldset' ? ts('Fieldset for %1', {1: ctrl.entity.label}) : element.title);
423 }
424 });
425 }
edac0d6e 426
67db2e07
CW
427 $scope.clearSearch = function() {
428 $scope.controls.fieldSearch = '';
429 };
430
431 // This gets called from jquery-ui so we have to manually apply changes to scope
432 $scope.buildPaletteLists = function() {
433 $timeout(function() {
434 $scope.$apply(function() {
435 buildPaletteLists();
edac0d6e 436 });
67db2e07
CW
437 });
438 };
edac0d6e 439
67db2e07
CW
440 // Checks if a field is on the form or set as a value
441 $scope.fieldInUse = function(fieldName) {
442 var data = ctrl.entity.data || {};
443 if (fieldName in data) {
444 return true;
445 }
446 return check(ctrl.editor.layout['#children'], {'#tag': 'af-field', name: fieldName});
447 };
e1aca853 448
67db2e07
CW
449 $scope.blockInUse = function(block) {
450 if (block['af-join']) {
451 return check(ctrl.editor.layout['#children'], {'af-join': block['af-join']});
452 }
cb46dc65 453 var fieldsInBlock = _.pluck(afAdmin.findRecursive(afAdmin.meta.blocks[block['#tag']].layout, {'#tag': 'af-field'}), 'name');
67db2e07
CW
454 return check(ctrl.editor.layout['#children'], function(item) {
455 return item['#tag'] === 'af-field' && _.includes(fieldsInBlock, item.name);
456 });
457 };
edac0d6e 458
67db2e07
CW
459 // Check for a matching item for this entity
460 // Recursively checks the form layout, including block directives
461 function check(group, criteria, found) {
462 if (!found) {
463 found = {};
464 }
465 if (_.find(group, criteria)) {
466 found.match = true;
467 return true;
468 }
469 _.each(group, function(item) {
470 if (found.match) {
471 return false;
e1aca853 472 }
67db2e07
CW
473 if (_.isPlainObject(item)) {
474 // Recurse through everything but skip fieldsets for other entities
475 if ((!item['af-fieldset'] || (item['af-fieldset'] === ctrl.entity.name)) && item['#children']) {
476 check(item['#children'], criteria, found);
e1aca853 477 }
67db2e07 478 // Recurse into block directives
cb46dc65
CW
479 else if (item['#tag'] && item['#tag'] in afAdmin.meta.blocks) {
480 check(afAdmin.meta.blocks[item['#tag']].layout, criteria, found);
e1aca853 481 }
edac0d6e
CW
482 }
483 });
67db2e07 484 return found.match;
edac0d6e 485 }
67db2e07
CW
486
487 $scope.$watch('controls.addValue', function(fieldName) {
488 if (fieldName) {
489 if (!ctrl.entity.data) {
490 ctrl.entity.data = {};
491 }
492 ctrl.entity.data[fieldName] = '';
493 $scope.controls.addValue = '';
494 }
495 });
496
497 $scope.$watch('controls.fieldSearch', buildPaletteLists);
498 }
edac0d6e
CW
499 });
500
cb46dc65 501 angular.module('afGuiEditor').directive('afGuiContainer', function(crmApi4, dialogService, afAdmin) {
66af6937
CW
502 return {
503 restrict: 'A',
6fb9e8d2 504 templateUrl: '~/afGuiEditor/container.html',
66af6937 505 scope: {
6fb9e8d2 506 node: '=afGuiContainer',
344e8290 507 join: '=',
f3cd3852 508 entityName: '='
66af6937 509 },
e1aca853
CW
510 require: ['^^afGuiEditor', '?^^afGuiContainer'],
511 link: function($scope, element, attrs, ctrls) {
344e8290 512 var ts = $scope.ts = CRM.ts();
e1aca853
CW
513 $scope.editor = ctrls[0];
514 $scope.parentContainer = ctrls[1];
f3cd3852
CW
515
516 $scope.isSelectedFieldset = function(entityName) {
0406c8f9 517 return entityName === $scope.editor.getSelectedEntityName();
f3cd3852
CW
518 };
519
520 $scope.selectEntity = function() {
521 if ($scope.node['af-fieldset']) {
522 $scope.editor.selectEntity($scope.node['af-fieldset']);
523 }
66af6937 524 };
f3cd3852
CW
525
526 $scope.tags = {
6fb9e8d2 527 div: ts('Container'),
f3cd3852
CW
528 fieldset: ts('Fieldset')
529 };
530
e1aca853 531 // Block settings
344e8290
CW
532 var block = {};
533 $scope.block = null;
5eaf91d9 534
e1aca853 535 $scope.getSetChildren = function(val) {
344e8290 536 var collection = block.layout || ($scope.node && $scope.node['#children']);
e1aca853 537 return arguments.length ? (collection = val) : collection;
5eaf91d9
CW
538 };
539
344e8290
CW
540 $scope.isRepeatable = function() {
541 return $scope.node['af-fieldset'] || (block.directive && $scope.editor.meta.blocks[block.directive].repeat) || $scope.join;
542 };
e1aca853 543
344e8290
CW
544 $scope.toggleRepeat = function() {
545 if ('af-repeat' in $scope.node) {
546 delete $scope.node.max;
547 delete $scope.node.min;
548 delete $scope.node['af-repeat'];
549 delete $scope.node['add-icon'];
550 } else {
551 $scope.node.min = '1';
552 $scope.node['af-repeat'] = ts('Add');
553 }
554 };
e1aca853 555
344e8290
CW
556 $scope.getSetMin = function(val) {
557 if (arguments.length) {
558 if ($scope.node.max && val > parseInt($scope.node.max, 10)) {
559 $scope.node.max = '' + val;
560 }
561 if (!val) {
e1aca853 562 delete $scope.node.min;
e1aca853 563 }
344e8290
CW
564 else {
565 $scope.node.min = '' + val;
e1aca853 566 }
344e8290
CW
567 }
568 return $scope.node.min ? parseInt($scope.node.min, 10) : null;
569 };
e1aca853 570
344e8290
CW
571 $scope.getSetMax = function(val) {
572 if (arguments.length) {
573 if ($scope.node.min && val && val < parseInt($scope.node.min, 10)) {
574 $scope.node.min = '' + val;
e1aca853 575 }
344e8290
CW
576 if (typeof val !== 'number') {
577 delete $scope.node.max;
578 }
579 else {
580 $scope.node.max = '' + val;
581 }
582 }
583 return $scope.node.max ? parseInt($scope.node.max, 10) : null;
584 };
585
586 $scope.pickAddIcon = function() {
d132f0a6
CW
587 afAdmin.pickIcon().then(function(val) {
588 $scope.node['add-icon'] = val;
589 });
344e8290
CW
590 };
591
592 function getBlockNode() {
593 return !$scope.join ? $scope.node : ($scope.node['#children'] && $scope.node['#children'].length === 1 ? $scope.node['#children'][0] : null);
594 }
595
596 function setBlockDirective(directive) {
597 if ($scope.join) {
598 $scope.node['#children'] = [{'#tag': directive}];
599 } else {
600 delete $scope.node['#children'];
601 delete $scope.node['class'];
602 $scope.node['#tag'] = directive;
603 }
604 }
605
606 function overrideBlockContents(layout) {
607 $scope.node['#children'] = layout || [];
608 if (!$scope.join) {
609 $scope.node['#tag'] = 'div';
610 $scope.node['class'] = 'af-container';
611 }
9633741a 612 block.layout = block.directive = null;
344e8290
CW
613 }
614
615 $scope.layouts = {
616 'af-layout-rows': ts('Contents display as rows'),
617 'af-layout-cols': ts('Contents are evenly-spaced columns'),
618 'af-layout-inline': ts('Contents are arranged inline')
619 };
620
621 $scope.getLayout = function() {
622 if (!$scope.node) {
623 return '';
624 }
cb46dc65 625 return _.intersection(afAdmin.splitClass($scope.node['class']), _.keys($scope.layouts))[0] || 'af-layout-rows';
344e8290
CW
626 };
627
628 $scope.setLayout = function(val) {
629 var classes = ['af-container'];
630 if (val !== 'af-layout-rows') {
631 classes.push(val);
632 }
cb46dc65 633 afAdmin.modifyClasses($scope.node, _.keys($scope.layouts), classes);
344e8290 634 };
e1aca853 635
9633741a
CW
636 $scope.selectBlockDirective = function() {
637 if (block.directive) {
638 block.layout = _.cloneDeep($scope.editor.meta.blocks[block.directive].layout);
639 block.original = block.directive;
640 setBlockDirective(block.directive);
641 }
642 else {
643 overrideBlockContents(block.layout);
644 }
645 };
646
344e8290 647 if (($scope.node['#tag'] in $scope.editor.meta.blocks) || $scope.join) {
9633741a
CW
648 initializeBlockContainer();
649 }
650
651 function initializeBlockContainer() {
652
653 // Cancel the below $watch expressions if already set
654 _.each(block.listeners, function(deregister) {
655 deregister();
656 });
344e8290
CW
657
658 block = $scope.block = {
659 directive: null,
660 layout: null,
9633741a 661 original: null,
344e8290 662 options: [],
9633741a 663 listeners: []
e1aca853 664 };
51ec7d5a 665
344e8290
CW
666 _.each($scope.editor.meta.blocks, function(blockInfo, directive) {
667 if (directive === $scope.node['#tag'] || blockInfo.join === $scope.container.getFieldEntityType()) {
668 block.options.push({
669 id: directive,
670 text: blockInfo.title
671 });
e1aca853 672 }
344e8290
CW
673 });
674
9633741a
CW
675 if (getBlockNode() && getBlockNode()['#tag'] in $scope.editor.meta.blocks) {
676 block.directive = block.original = getBlockNode()['#tag'];
677 block.layout = _.cloneDeep($scope.editor.meta.blocks[block.directive].layout);
678 }
e1aca853 679
9633741a
CW
680 block.listeners.push($scope.$watch('block.layout', function (layout, oldVal) {
681 if (block.directive && layout && layout !== oldVal && !angular.equals(layout, $scope.editor.meta.blocks[block.directive].layout)) {
344e8290 682 overrideBlockContents(block.layout);
e1aca853 683 }
9633741a 684 }, true));
e1aca853 685 }
9633741a
CW
686
687 $scope.saveBlock = function() {
688 var options = CRM.utils.adjustDialogDefaults({
689 width: '500px',
690 height: '300px',
691 autoOpen: false,
692 title: ts('Save block')
693 });
694 var model = {
695 title: '',
696 name: null,
697 layout: $scope.node['#children']
698 };
699 if ($scope.join) {
700 model.join = $scope.join;
701 }
702 if ($scope.block && $scope.block.original) {
703 model.title = $scope.editor.meta.blocks[$scope.block.original].title;
704 model.name = $scope.editor.meta.blocks[$scope.block.original].name;
705 model.block = $scope.editor.meta.blocks[$scope.block.original].block;
706 }
707 else {
708 model.block = $scope.container.getFieldEntityType() || '*';
709 }
710 dialogService.open('saveBlockDialog', '~/afGuiEditor/saveBlock.html', model, options)
711 .then(function(block) {
e38db494
CW
712 $scope.editor.meta.blocks[block.directive_name] = block;
713 setBlockDirective(block.directive_name);
9633741a
CW
714 initializeBlockContainer();
715 });
716 };
717
344e8290 718 },
cb46dc65 719 controller: function($scope, afAdmin) {
344e8290
CW
720 var container = $scope.container = this;
721 this.node = $scope.node;
e1aca853 722
344e8290
CW
723 this.getNodeType = function(node) {
724 if (!node) {
725 return null;
726 }
727 if (node['#tag'] === 'af-field') {
728 return 'field';
729 }
730 if (node['af-fieldset']) {
731 return 'fieldset';
732 }
733 if (node['af-join']) {
734 return 'join';
735 }
736 if (node['#tag'] && node['#tag'] in $scope.editor.meta.blocks) {
737 return 'container';
738 }
cb46dc65 739 var classes = afAdmin.splitClass(node['class']),
344e8290
CW
740 types = ['af-container', 'af-text', 'af-button', 'af-markup'],
741 type = _.intersection(types, classes);
742 return type.length ? type[0].replace('af-', '') : null;
743 };
744
745 this.removeElement = function(element) {
cb46dc65 746 afAdmin.removeRecursive($scope.getSetChildren(), {$$hashKey: element.$$hashKey});
344e8290
CW
747 };
748
749 this.getEntityName = function() {
750 return $scope.entityName.split('-join-')[0];
751 };
752
753 // Returns the primary entity type for this container e.g. "Contact"
754 this.getMainEntityType = function() {
755 return $scope.editor && $scope.editor.getEntity(container.getEntityName()).type;
756 };
757
758 // Returns the entity type for fields within this conainer (join entity type if this is a join, else the primary entity type)
759 this.getFieldEntityType = function() {
760 var joinType = $scope.entityName.split('-join-');
761 return joinType[1] || ($scope.editor && $scope.editor.getEntity(joinType[0]).type);
762 };
e1aca853 763
66af6937
CW
764 }
765 };
766 });
767
9633741a
CW
768 angular.module('afGuiEditor').controller('afGuiSaveBlock', function($scope, crmApi4, dialogService) {
769 var ts = $scope.ts = CRM.ts(),
770 model = $scope.model,
771 original = $scope.original = {
772 title: model.title,
773 name: model.name
774 };
775 if (model.name) {
776 $scope.$watch('model.name', function(val, oldVal) {
777 if (!val && model.title === original.title) {
778 model.title += ' ' + ts('(copy)');
779 }
780 else if (val === original.name && val !== oldVal) {
781 model.title = original.title;
782 }
783 });
784 }
785 $scope.cancel = function() {
786 dialogService.cancel('saveBlockDialog');
787 };
788 $scope.save = function() {
789 $('.ui-dialog:visible').block();
790 crmApi4('Afform', 'save', {formatWhitespace: true, records: [JSON.parse(angular.toJson(model))]})
791 .then(function(result) {
792 dialogService.close('saveBlockDialog', result[0]);
793 });
794 };
795 });
796
f3cd3852 797 angular.module('afGuiEditor').directive('afGuiField', function() {
66af6937
CW
798 return {
799 restrict: 'A',
f3cd3852 800 templateUrl: '~/afGuiEditor/field.html',
66af6937 801 scope: {
e1aca853 802 node: '=afGuiField'
f3cd3852 803 },
6fb9e8d2 804 require: ['^^afGuiEditor', '^^afGuiContainer'],
4bd44053
CW
805 link: function($scope, element, attrs, ctrls) {
806 $scope.editor = ctrls[0];
6fb9e8d2 807 $scope.container = ctrls[1];
66af6937 808 },
cb46dc65 809 controller: function($scope, afAdmin) {
4bd44053 810 var ts = $scope.ts = CRM.ts();
b4def6e9
CW
811 $scope.editingOptions = false;
812 var yesNo = [
813 {key: '1', label: ts('Yes')},
814 {key: '0', label: ts('No')}
815 ];
f3cd3852
CW
816
817 $scope.getEntity = function() {
e1aca853 818 return $scope.editor ? $scope.editor.getEntity($scope.container.getEntityName()) : {};
f3cd3852
CW
819 };
820
b4def6e9 821 $scope.getDefn = this.getDefn = function() {
cb46dc65 822 return $scope.editor ? afAdmin.getField($scope.container.getFieldEntityType(), $scope.node.name) : {};
66af6937 823 };
4bd44053 824
b4def6e9
CW
825 $scope.hasOptions = function() {
826 var inputType = $scope.getProp('input_type');
827 return _.contains(['CheckBox', 'Radio', 'Select'], inputType) && !(inputType === 'CheckBox' && !$scope.getDefn().options);
828 };
829
830 $scope.getOptions = this.getOptions = function() {
831 if ($scope.node.defn && $scope.node.defn.options) {
832 return $scope.node.defn.options;
833 }
834 return $scope.getDefn().options || ($scope.getProp('input_type') === 'CheckBox' ? null : yesNo);
835 };
836
b4def6e9
CW
837 $scope.resetOptions = function() {
838 delete $scope.node.defn.options;
839 };
840
841 $scope.editOptions = function() {
842 $scope.editingOptions = true;
192695ae 843 $('#afGuiEditor').addClass('af-gui-editing-content');
b4def6e9
CW
844 };
845
846 $scope.inputTypeCanBe = function(type) {
847 var defn = $scope.getDefn();
848 switch (type) {
849 case 'CheckBox':
850 case 'Radio':
851 case 'Select':
852 return !(!defn.options && defn.data_type !== 'Boolean');
853
854 case 'TextArea':
855 case 'RichTextEditor':
856 return (defn.data_type === 'Text' || defn.data_type === 'String');
857 }
858 return true;
859 };
860
861 // Returns a value from either the local field defn or the base defn
4bd44053
CW
862 $scope.getProp = function(propName) {
863 var path = propName.split('.'),
864 item = path.pop(),
865 localDefn = drillDown($scope.node.defn || {}, path);
866 if (typeof localDefn[item] !== 'undefined') {
867 return localDefn[item];
868 }
869 return drillDown($scope.getDefn(), path)[item];
870 };
871
b4def6e9
CW
872 // Checks for a value in either the local field defn or the base defn
873 $scope.propIsset = function(propName) {
874 var val = $scope.getProp(propName);
875 return !(typeof val === 'undefined' || val === null);
876 };
877
0d97b137
CW
878 $scope.toggleLabel = function() {
879 $scope.node.defn = $scope.node.defn || {};
44f7db4c
TO
880 if ($scope.node.defn.label === false) {
881 delete $scope.node.defn.label;
0d97b137 882 } else {
44f7db4c 883 $scope.node.defn.label = false;
0d97b137
CW
884 }
885 };
886
4bd44053
CW
887 $scope.toggleRequired = function() {
888 getSet('required', !getSet('required'));
889 return false;
890 };
891
892 $scope.toggleHelp = function(position) {
b4def6e9 893 getSet('help_' + position, $scope.propIsset('help_' + position) ? null : ($scope.getDefn()['help_' + position] || ts('Enter text')));
4bd44053
CW
894 return false;
895 };
896
897 // Getter/setter for definition props
898 $scope.getSet = function(propName) {
899 return _.wrap(propName, getSet);
900 };
901
4bd44053
CW
902 // Getter/setter callback
903 function getSet(propName, val) {
904 if (arguments.length > 1) {
905 var path = propName.split('.'),
906 item = path.pop(),
907 localDefn = drillDown($scope.node, ['defn'].concat(path)),
908 fieldDefn = drillDown($scope.getDefn(), path);
909 // Set the value if different than the field defn, otherwise unset it
e1aca853 910 if (typeof val !== 'undefined' && (val !== fieldDefn[item] && !(!val && !fieldDefn[item]))) {
4bd44053
CW
911 localDefn[item] = val;
912 } else {
913 delete localDefn[item];
e1aca853 914 clearOut($scope.node, ['defn'].concat(path));
4bd44053
CW
915 }
916 return val;
917 }
918 return $scope.getProp(propName);
919 }
b4def6e9
CW
920 this.getSet = getSet;
921
922 this.setEditingOptions = function(val) {
923 $scope.editingOptions = val;
924 };
925
926 // Returns a reference to a path n-levels deep within an object
927 function drillDown(parent, path) {
928 var container = parent;
929 _.each(path, function(level) {
930 container[level] = container[level] || {};
931 container = container[level];
932 });
933 return container;
934 }
e1aca853
CW
935
936 // Recursively clears out empty arrays and objects
937 function clearOut(parent, path) {
938 var item;
939 while (path.length && _.every(drillDown(parent, path), _.isEmpty)) {
940 item = path.pop();
941 delete drillDown(parent, path)[item];
942 }
943 }
b4def6e9
CW
944 }
945 };
946 });
4bd44053 947
b4def6e9
CW
948 angular.module('afGuiEditor').directive('afGuiEditOptions', function() {
949 return {
950 restrict: 'A',
951 templateUrl: '~/afGuiEditor/editOptions.html',
952 scope: true,
953 require: '^^afGuiField',
954 link: function ($scope, element, attrs, afGuiField) {
955 $scope.field = afGuiField;
956 $scope.options = JSON.parse(angular.toJson(afGuiField.getOptions()));
957 var optionKeys = _.map($scope.options, 'key');
958 $scope.deletedOptions = _.filter(JSON.parse(angular.toJson(afGuiField.getDefn().options || [])), function(item) {
959 return !_.contains(optionKeys, item.key);
960 });
961 $scope.originalLabels = _.transform(afGuiField.getDefn().options || [], function(originalLabels, item) {
962 originalLabels[item.key] = item.label;
963 }, {});
964 },
965 controller: function ($scope) {
966 var ts = $scope.ts = CRM.ts();
967
968 $scope.deleteOption = function(option, $index) {
969 $scope.options.splice($index, 1);
970 $scope.deletedOptions.push(option);
971 };
972
973 $scope.restoreOption = function(option, $index) {
974 $scope.deletedOptions.splice($index, 1);
975 $scope.options.push(option);
976 };
6fb9e8d2 977
b4def6e9
CW
978 $scope.save = function() {
979 $scope.field.getSet('options', JSON.parse(angular.toJson($scope.options)));
980 $scope.close();
981 };
982
983 $scope.close = function() {
984 $scope.field.setEditingOptions(false);
192695ae 985 $('#afGuiEditor').removeClass('af-gui-editing-content');
b4def6e9 986 };
66af6937
CW
987 }
988 };
989 });
990
f3cd3852 991 angular.module('afGuiEditor').directive('afGuiText', function() {
66af6937
CW
992 return {
993 restrict: 'A',
f3cd3852 994 templateUrl: '~/afGuiEditor/text.html',
66af6937 995 scope: {
f3cd3852 996 node: '=afGuiText'
66af6937 997 },
6fb9e8d2
CW
998 require: '^^afGuiContainer',
999 link: function($scope, element, attrs, container) {
1000 $scope.container = container;
f3cd3852 1001 },
cb46dc65 1002 controller: function($scope, afAdmin) {
edac0d6e
CW
1003 var ts = $scope.ts = CRM.ts();
1004
f3cd3852
CW
1005 $scope.tags = {
1006 p: ts('Normal Text'),
fd6c0814 1007 legend: ts('Fieldset Legend'),
f3cd3852
CW
1008 h1: ts('Heading 1'),
1009 h2: ts('Heading 2'),
1010 h3: ts('Heading 3'),
1011 h4: ts('Heading 4'),
1012 h5: ts('Heading 5'),
1013 h6: ts('Heading 6')
1014 };
1015
1016 $scope.alignments = {
1017 'text-left': ts('Align left'),
1018 'text-center': ts('Align center'),
1019 'text-right': ts('Align right'),
1020 'text-justify': ts('Justify')
1021 };
1022
1023 $scope.getAlign = function() {
cb46dc65 1024 return _.intersection(afAdmin.splitClass($scope.node['class']), _.keys($scope.alignments))[0] || 'text-left';
f3cd3852
CW
1025 };
1026
1027 $scope.setAlign = function(val) {
cb46dc65 1028 afAdmin.modifyClasses($scope.node, _.keys($scope.alignments), val === 'text-left' ? null : val);
f3cd3852 1029 };
51ec7d5a 1030
2e2aaaea 1031 $scope.styles = _.transform(CRM.afGuiEditor.styles, function(styles, val, key) {
51ec7d5a
CW
1032 styles['text-' + key] = val;
1033 });
1034
1035 // Getter/setter for ng-model
1036 $scope.getSetStyle = function(val) {
1037 if (arguments.length) {
cb46dc65 1038 return afAdmin.modifyClasses($scope.node, _.keys($scope.styles), val === 'text-default' ? null : val);
51ec7d5a 1039 }
cb46dc65 1040 return _.intersection(afAdmin.splitClass($scope.node['class']), _.keys($scope.styles))[0] || 'text-default';
51ec7d5a
CW
1041 };
1042
66af6937
CW
1043 }
1044 };
1045 });
192695ae
CW
1046
1047 var richtextId = 0;
1048 angular.module('afGuiEditor').directive('afGuiMarkup', function($sce, $timeout) {
1049 return {
1050 restrict: 'A',
1051 templateUrl: '~/afGuiEditor/markup.html',
1052 scope: {
1053 node: '=afGuiMarkup'
1054 },
6fb9e8d2
CW
1055 require: '^^afGuiContainer',
1056 link: function($scope, element, attrs, container) {
1057 $scope.container = container;
192695ae
CW
1058 // CRM.wysiwyg doesn't work without a dom id
1059 $scope.id = 'af-markup-editor-' + richtextId++;
1060
6fb9e8d2 1061 // When creating a new markup container, go straight to edit mode
192695ae
CW
1062 $timeout(function() {
1063 if ($scope.node['#markup'] === false) {
1064 $scope.edit();
1065 }
1066 });
1067 },
1068 controller: function($scope) {
1069 var ts = $scope.ts = CRM.ts();
1070
1071 $scope.getMarkup = function() {
1072 return $sce.trustAsHtml($scope.node['#markup'] || '');
1073 };
1074
1075 $scope.edit = function() {
1076 $('#afGuiEditor').addClass('af-gui-editing-content');
1077 $scope.editingMarkup = true;
1078 CRM.wysiwyg.create('#' + $scope.id);
1079 CRM.wysiwyg.setVal('#' + $scope.id, $scope.node['#markup'] || '<p></p>');
1080 };
1081
1082 $scope.save = function() {
1083 $scope.node['#markup'] = CRM.wysiwyg.getVal('#' + $scope.id);
1084 $scope.close();
1085 };
1086
1087 $scope.close = function() {
1088 CRM.wysiwyg.destroy('#' + $scope.id);
1089 $('#afGuiEditor').removeClass('af-gui-editing-content');
6fb9e8d2 1090 // If a newly-added wysiwyg was canceled, just remove it
192695ae 1091 if ($scope.node['#markup'] === false) {
6fb9e8d2 1092 $scope.container.removeElement($scope.node);
192695ae
CW
1093 } else {
1094 $scope.editingMarkup = false;
1095 }
1096 };
1097 }
1098 };
1099 });
1100
1101
e72f4d81
CW
1102 angular.module('afGuiEditor').directive('afGuiButton', function() {
1103 return {
1104 restrict: 'A',
1105 templateUrl: '~/afGuiEditor/button.html',
1106 scope: {
1107 node: '=afGuiButton'
1108 },
6fb9e8d2
CW
1109 require: '^^afGuiContainer',
1110 link: function($scope, element, attrs, container) {
1111 $scope.container = container;
e72f4d81 1112 },
cb46dc65 1113 controller: function($scope, afAdmin) {
edac0d6e 1114 var ts = $scope.ts = CRM.ts();
e72f4d81
CW
1115
1116 // TODO: Add action selector to UI
1117 // $scope.actions = {
905b0fd5 1118 // "afform.submit()": ts('Submit Form')
e72f4d81
CW
1119 // };
1120
2e2aaaea 1121 $scope.styles = _.transform(CRM.afGuiEditor.styles, function(styles, val, key) {
51ec7d5a
CW
1122 styles['btn-' + key] = val;
1123 });
e72f4d81 1124
4bd44053
CW
1125 // Getter/setter for ng-model
1126 $scope.getSetStyle = function(val) {
1127 if (arguments.length) {
cb46dc65 1128 return afAdmin.modifyClasses($scope.node, _.keys($scope.styles), ['btn', val]);
4bd44053 1129 }
cb46dc65 1130 return _.intersection(afAdmin.splitClass($scope.node['class']), _.keys($scope.styles))[0] || '';
e72f4d81
CW
1131 };
1132
e72f4d81 1133 $scope.pickIcon = function() {
d132f0a6
CW
1134 afAdmin.pickIcon().then(function(val) {
1135 $scope.node['crm-icon'] = val;
1136 });
e72f4d81
CW
1137 };
1138
1139 }
1140 };
1141 });
66af6937 1142
b4def6e9
CW
1143 // Connect bootstrap dropdown.js with angular
1144 // Allows menu content to be conditionally rendered only if open
1145 // This gives a large performance boost for a page with lots of menus
1146 angular.module('afGuiEditor').directive('afGuiMenu', function() {
1147 return {
1148 restrict: 'A',
1149 link: function($scope, element, attrs) {
1150 $scope.menu = {};
1151 element
1152 .on('show.bs.dropdown', function() {
1153 $scope.$apply(function() {
1154 $scope.menu.open = true;
1155 });
1156 })
1157 .on('hidden.bs.dropdown', function() {
1158 $scope.$apply(function() {
1159 $scope.menu.open = false;
1160 });
1161 });
1162 }
1163 };
1164 });
1165
192695ae 1166 // Menu item to control the border property of a node
cb46dc65 1167 angular.module('afGuiEditor').directive('afGuiMenuItemBorder', function(afAdmin) {
192695ae
CW
1168 return {
1169 restrict: 'A',
1170 templateUrl: '~/afGuiEditor/menu-item-border.html',
1171 scope: {
1172 node: '=afGuiMenuItemBorder'
1173 },
1174 link: function($scope, element, attrs) {
1175 var ts = $scope.ts = CRM.ts();
1176
1177 $scope.getSetBorderWidth = function(width) {
1178 return getSetBorderProp($scope.node, 0, arguments.length ? width : null);
1179 };
1180
1181 $scope.getSetBorderStyle = function(style) {
1182 return getSetBorderProp($scope.node, 1, arguments.length ? style : null);
1183 };
1184
1185 $scope.getSetBorderColor = function(color) {
1186 return getSetBorderProp($scope.node, 2, arguments.length ? color : null);
1187 };
1188
1189 function getSetBorderProp(node, idx, val) {
1190 var border = getBorder(node) || ['1px', '', '#000000'];
1191 if (val === null) {
1192 return border[idx];
1193 }
1194 border[idx] = val;
cb46dc65 1195 afAdmin.setStyle(node, 'border', val ? border.join(' ') : null);
192695ae
CW
1196 }
1197
1198 function getBorder(node) {
cb46dc65 1199 var border = _.map((afAdmin.getStyles(node).border || '').split(' '), _.trim);
192695ae
CW
1200 return border.length > 2 ? border : null;
1201 }
1202 }
1203 };
1204 });
1205
1206 // Menu item to control the background property of a node
cb46dc65 1207 angular.module('afGuiEditor').directive('afGuiMenuItemBackground', function(afAdmin) {
192695ae
CW
1208 return {
1209 restrict: 'A',
1210 templateUrl: '~/afGuiEditor/menu-item-background.html',
1211 scope: {
1212 node: '=afGuiMenuItemBackground'
1213 },
1214 link: function($scope, element, attrs) {
1215 var ts = $scope.ts = CRM.ts();
1216
1217 $scope.getSetBackgroundColor = function(color) {
1218 if (!arguments.length) {
cb46dc65 1219 return afAdmin.getStyles($scope.node)['background-color'] || '#ffffff';
192695ae 1220 }
cb46dc65 1221 afAdmin.setStyle($scope.node, 'background-color', color);
192695ae
CW
1222 };
1223 }
1224 };
1225 });
1226
f6c0358e 1227})(angular, CRM.$, CRM._);