13a4bbe5ad3194ba10376d8c35b101b9b29d77f7
[civicrm-core.git] / ext / afform / admin / ang / afGuiEditor / afGuiEditor.component.js
1 // https://civicrm.org/licensing
2 (function(angular, $, _) {
3 "use strict";
4
5 function backfillEntityDefaults(entity) {
6 // These fields did not exist in prior versions. In absence of explicit, these are the values inferred by runtime/server-side.
7 // We cannot currently backfill schema in the upgrade, so this is the next best opportunity.
8 if (entity.actions === undefined) entity.actions = {create: true, update: true};
9 if (entity.security === undefined) entity.security = 'RBAC';
10 return entity;
11 }
12
13 angular.module('afGuiEditor').component('afGuiEditor', {
14 templateUrl: '~/afGuiEditor/afGuiEditor.html',
15 bindings: {
16 data: '<',
17 entity: '<',
18 mode: '@'
19 },
20 controllerAs: 'editor',
21 controller: function($scope, crmApi4, afGui, $parse, $timeout, $location) {
22 var ts = $scope.ts = CRM.ts('org.civicrm.afform_admin');
23
24 this.afform = null;
25 $scope.saving = false;
26 $scope.selectedEntityName = null;
27 this.meta = afGui.meta;
28 var editor = this,
29 sortableOptions = {};
30
31 this.$onInit = function() {
32 // Load the current form plus blocks & fields
33 afGui.resetMeta();
34 afGui.addMeta(this.data);
35 initializeForm();
36
37 $timeout(fixEditorHeight);
38 $timeout(editor.adjustTabWidths);
39 $(window)
40 .on('resize.afGuiEditor', fixEditorHeight)
41 .on('resize.afGuiEditor', editor.adjustTabWidths);
42 };
43
44 this.$onDestroy = function() {
45 $(window).off('.afGuiEditor');
46 };
47
48 // Initialize the current form
49 function initializeForm() {
50 editor.afform = editor.data.definition;
51 if (!editor.afform) {
52 alert('Error: unknown form');
53 }
54 if (editor.mode === 'clone') {
55 delete editor.afform.name;
56 delete editor.afform.server_route;
57 editor.afform.is_dashlet = false;
58 editor.afform.title += ' ' + ts('(copy)');
59 }
60 $scope.canvasTab = 'layout';
61 $scope.layoutHtml = '';
62 editor.layout = {'#children': []};
63 $scope.entities = {};
64
65 if (editor.getFormType() === 'form') {
66 editor.allowEntityConfig = true;
67 editor.layout['#children'] = afGui.findRecursive(editor.afform.layout, {'#tag': 'af-form'})[0]['#children'];
68 $scope.entities = _.mapValues(afGui.findRecursive(editor.layout['#children'], {'#tag': 'af-entity'}, 'name'), backfillEntityDefaults);
69
70 if (editor.mode === 'create') {
71 editor.addEntity(editor.entity);
72 editor.layout['#children'].push(afGui.meta.elements.submit.element);
73 }
74 }
75
76 else if (editor.getFormType() === 'block') {
77 editor.layout['#children'] = editor.afform.layout;
78 editor.blockEntity = editor.afform.join || editor.afform.block;
79 $scope.entities[editor.blockEntity] = backfillEntityDefaults({
80 type: editor.blockEntity,
81 name: editor.blockEntity,
82 label: afGui.getEntity(editor.blockEntity).label
83 });
84 }
85
86 else if (editor.getFormType() === 'search') {
87 editor.layout['#children'] = afGui.findRecursive(editor.afform.layout, {'af-fieldset': ''})[0]['#children'];
88 editor.searchDisplay = afGui.findRecursive(editor.layout['#children'], function(item) {
89 return item['#tag'] && item['#tag'].indexOf('crm-search-display-') === 0;
90 })[0];
91 editor.searchFilters = getSearchFilterOptions();
92 }
93
94 // Set changesSaved to true on initial load, false thereafter whenever changes are made to the model
95 $scope.changesSaved = editor.mode === 'edit' ? 1 : false;
96 $scope.$watch('editor.afform', function () {
97 $scope.changesSaved = $scope.changesSaved === 1;
98 }, true);
99 }
100
101 this.getFormType = function() {
102 return editor.afform.type;
103 };
104
105 $scope.updateLayoutHtml = function() {
106 $scope.layoutHtml = '...Loading...';
107 crmApi4('Afform', 'convert', {layout: editor.afform.layout, from: 'deep', to: 'html', formatWhitespace: true})
108 .then(function(r){
109 $scope.layoutHtml = r[0].layout || '(Error)';
110 })
111 .catch(function(r){
112 $scope.layoutHtml = '(Error)';
113 });
114 };
115
116 this.addEntity = function(type, selectTab) {
117 var meta = afGui.meta.entities[type],
118 num = 1;
119 // Give this new entity a unique name
120 while (!!$scope.entities[type + num]) {
121 num++;
122 }
123 $scope.entities[type + num] = backfillEntityDefaults(_.assign($parse(meta.defaults)($scope), {
124 '#tag': 'af-entity',
125 type: meta.entity,
126 name: type + num,
127 label: meta.label + ' ' + num,
128 loading: true,
129 }));
130
131 function addToCanvas() {
132 // Add this af-entity tag after the last existing one
133 var pos = 1 + _.findLastIndex(editor.layout['#children'], {'#tag': 'af-entity'});
134 editor.layout['#children'].splice(pos, 0, $scope.entities[type + num]);
135 // Create a new af-fieldset container for the entity
136 var fieldset = _.cloneDeep(afGui.meta.elements.fieldset.element);
137 fieldset['af-fieldset'] = type + num;
138 fieldset['#children'][0]['#children'][0]['#text'] = meta.label + ' ' + num;
139 // Add boilerplate contents
140 _.each(meta.boilerplate, function (tag) {
141 fieldset['#children'].push(tag);
142 });
143 // Attempt to place the new af-fieldset after the last one on the form
144 pos = 1 + _.findLastIndex(editor.layout['#children'], 'af-fieldset');
145 if (pos) {
146 editor.layout['#children'].splice(pos, 0, fieldset);
147 } else {
148 editor.layout['#children'].push(fieldset);
149 }
150 delete $scope.entities[type + num].loading;
151 if (selectTab) {
152 editor.selectEntity(type + num);
153 $timeout(function() {
154 editor.scrollToEntity(type + num);
155 });
156 }
157 $timeout(editor.adjustTabWidths);
158 }
159
160 if (meta.fields) {
161 addToCanvas();
162 } else {
163 $timeout(editor.adjustTabWidths);
164 crmApi4('Afform', 'loadAdminData', {
165 definition: {type: 'form'},
166 entity: type
167 }, 0).then(function(data) {
168 afGui.addMeta(data);
169 addToCanvas();
170 });
171 }
172 };
173
174 this.removeEntity = function(entityName) {
175 delete $scope.entities[entityName];
176 afGui.removeRecursive(editor.layout['#children'], {'#tag': 'af-entity', name: entityName});
177 afGui.removeRecursive(editor.layout['#children'], {'af-fieldset': entityName});
178 this.selectEntity(null);
179 };
180
181 this.selectEntity = function(entityName) {
182 $scope.selectedEntityName = entityName;
183 $timeout(editor.adjustTabWidths);
184 };
185
186 this.getEntity = function(entityName) {
187 return $scope.entities[entityName];
188 };
189
190 this.getSelectedEntityName = function() {
191 return $scope.selectedEntityName;
192 };
193
194 this.getEntityDefn = function(entity) {
195 if (entity.type === 'Contact' && entity.data.contact_type) {
196 return editor.meta.entities[entity.data.contact_type];
197 }
198 return editor.meta.entities[entity.type];
199 };
200
201 // Scroll an entity's first fieldset into view of the canvas
202 this.scrollToEntity = function(entityName) {
203 var $canvas = $('#afGuiEditor-canvas-body'),
204 $entity = $('.af-gui-container-type-fieldset[data-entity="' + entityName + '"]').first(),
205 // Scrolltop value needed to place entity's fieldset at top of canvas
206 scrollValue = $canvas.scrollTop() + ($entity.offset().top - $canvas.offset().top),
207 // Maximum possible scrollTop (height minus contents height, adjusting for padding)
208 maxScroll = $('#afGuiEditor-canvas-body > *').height() - $canvas.height() + 20;
209 // Exceeding the maximum scrollTop breaks the animation so keep it under the limit
210 $canvas.animate({scrollTop: scrollValue > maxScroll ? maxScroll : scrollValue}, 500);
211 };
212
213 this.getAfform = function() {
214 return editor.afform;
215 };
216
217 this.getEntities = function(filter) {
218 return filter ? _.filter($scope.entities, filter) : _.toArray($scope.entities);
219 };
220
221 this.toggleContactSummary = function() {
222 if (editor.afform.contact_summary) {
223 editor.afform.contact_summary = false;
224 if (editor.afform.type === 'search') {
225 delete editor.searchDisplay.filters;
226 }
227 } else {
228 editor.afform.contact_summary = 'block';
229 if (editor.afform.type === 'search') {
230 editor.searchDisplay.filters = editor.searchFilters[0].key;
231 }
232 }
233 };
234
235 function getSearchFilterOptions() {
236 var searchDisplay = editor.meta.searchDisplays[editor.searchDisplay['search-name'] + '.' + editor.searchDisplay['display-name']],
237 entityCount = {},
238 options = [];
239
240 addFields(searchDisplay['saved_search.api_entity'], '');
241
242 _.each(searchDisplay['saved_search.api_params'].join, function(join) {
243 var joinInfo = join[0].split(' AS ');
244 addFields(joinInfo[0], joinInfo[1] + '.');
245 });
246
247 function addFields(entityName, prefix) {
248 var entity = afGui.getEntity(entityName);
249 entityCount[entity.entity] = (entityCount[entity.entity] || 0) + 1;
250 var count = (entityCount[entity.entity] > 1 ? ' ' + entityCount[entity.entity] : '');
251 if (entityName === 'Contact') {
252 options.push({
253 key: "{'" + prefix + "id': options.contact_id}",
254 label: entity.label + count
255 });
256 } else {
257 _.each(entity.fields, function(field) {
258 if (field.fk_entity === 'Contact') {
259 options.push({
260 key: "{'" + prefix + field.name + "': options.contact_id}",
261 label: entity.label + count + ' ' + field.label
262 });
263 }
264 });
265 }
266 }
267 return options;
268 }
269
270 this.getLink = function() {
271 if (editor.afform.server_route) {
272 return CRM.url(editor.afform.server_route, null, editor.afform.is_public ? 'front' : 'back');
273 }
274 };
275
276 // Options for ui-sortable in field palette
277 this.getSortableOptions = function(entityName) {
278 if (!sortableOptions[entityName + '']) {
279 sortableOptions[entityName + ''] = {
280 helper: 'clone',
281 appendTo: '#afGuiEditor-canvas-body > af-gui-container',
282 containment: '#afGuiEditor-canvas-body',
283 update: editor.onDrop,
284 items: '> div:not(.disabled)',
285 connectWith: '#afGuiEditor-canvas ' + (entityName ? '[data-entity="' + entityName + '"] > ' : '') + '[ui-sortable]',
286 placeholder: 'af-gui-dropzone',
287 tolerance: 'pointer',
288 scrollSpeed: 8
289 };
290 }
291 return sortableOptions[entityName + ''];
292 };
293
294 // Validates that a drag-n-drop action is allowed
295 this.onDrop = function(event, ui) {
296 var sort = ui.item.sortable;
297 // Check if this is a callback for an item dropped into a different container
298 // @see https://github.com/angular-ui/ui-sortable notes on canceling
299 if (!sort.received && sort.source[0] !== sort.droptarget[0]) {
300 var $source = $(sort.source[0]),
301 $target = $(sort.droptarget[0]),
302 $item = $(ui.item[0]);
303 // Fields cannot be dropped outside their own entity
304 if ($item.find('af-gui-field').length) {
305 if ($source.closest('[data-entity]').attr('data-entity') !== $target.closest('[data-entity]').attr('data-entity')) {
306 return sort.cancel();
307 }
308 }
309 // Entity-fieldsets cannot be dropped into other entity-fieldsets
310 if ((sort.model['af-fieldset'] || $item.find('.af-gui-fieldset').length) && $target.closest('.af-gui-fieldset').length) {
311 return sort.cancel();
312 }
313 }
314 };
315
316 $scope.save = function() {
317 var afform = JSON.parse(angular.toJson(editor.afform));
318 // This might be set to undefined by validation
319 afform.server_route = afform.server_route || '';
320 $scope.saving = $scope.changesSaved = true;
321 crmApi4('Afform', 'save', {formatWhitespace: true, records: [afform]})
322 .then(function (data) {
323 $scope.saving = false;
324 editor.afform.name = data[0].name;
325 if (editor.mode !== 'edit') {
326 $location.url('/edit/' + data[0].name);
327 }
328 });
329 };
330
331 $scope.$watch('editor.afform.title', function(newTitle, oldTitle) {
332 if (typeof oldTitle === 'string') {
333 _.each($scope.entities, function(entity) {
334 if (entity.data && entity.data.source === oldTitle) {
335 entity.data.source = newTitle;
336 }
337 });
338 }
339 });
340
341 // Force editor panels to a fixed height, to avoid palette scrolling offscreen
342 function fixEditorHeight() {
343 var height = $(window).height() - $('#afGuiEditor').offset().top;
344 $('#afGuiEditor').height(Math.floor(height));
345 }
346
347 // Compress tabs on small screens
348 this.adjustTabWidths = function() {
349 $('#afGuiEditor .panel-heading ul.nav-tabs li.active').css('max-width', '');
350 $('#afGuiEditor .panel-heading ul.nav-tabs').each(function() {
351 var remainingSpace = Math.floor($(this).width()) - 1,
352 inactiveTabs = $(this).children('li.fluid-width-tab').not('.active');
353 $(this).children('.active,:not(.fluid-width-tab)').each(function() {
354 remainingSpace -= $(this).width();
355 });
356 if (inactiveTabs.length) {
357 inactiveTabs.css('max-width', Math.floor(remainingSpace / inactiveTabs.length) + 'px');
358 }
359 });
360 };
361 }
362 });
363
364 })(angular, CRM.$, CRM._);