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