dev/core#530 CiviCase: making roles available in either direction
[civicrm-core.git] / ang / crmCaseType.js
1 (function(angular, $, _) {
2
3 var crmCaseType = angular.module('crmCaseType', CRM.angRequires('crmCaseType'));
4
5 // Note: This template will be passed to cloneDeep(), so don't put any funny stuff in here!
6 var newCaseTypeTemplate = {
7 title: "",
8 name: "",
9 is_active: "1",
10 weight: "1",
11 definition: {
12 activityTypes: [
13 {name: 'Open Case', max_instances: 1},
14 {name: 'Email'},
15 {name: 'Follow up'},
16 {name: 'Meeting'},
17 {name: 'Phone Call'}
18 ],
19 activitySets: [
20 {
21 name: 'standard_timeline',
22 label: 'Standard Timeline',
23 timeline: '1', // Angular won't bind checkbox correctly with numeric 1
24 activityTypes: [
25 {name: 'Open Case', status: 'Completed' }
26 ]
27 }
28 ],
29 caseRoles: [
30 { name: 'Case Coordinator', creator: '1', manager: '1'}
31 ]
32 }
33 };
34
35 crmCaseType.config(['$routeProvider',
36 function($routeProvider) {
37 $routeProvider.when('/caseType', {
38 templateUrl: '~/crmCaseType/list.html',
39 controller: 'CaseTypeListCtrl',
40 resolve: {
41 caseTypes: function($route, crmApi) {
42 return crmApi('CaseType', 'get', {options: {limit: 0}});
43 }
44 }
45 });
46 $routeProvider.when('/caseType/:id', {
47 templateUrl: '~/crmCaseType/edit.html',
48 controller: 'CaseTypeCtrl',
49 resolve: {
50 apiCalls: function($route, crmApi) {
51 var reqs = {};
52 reqs.actStatuses = ['OptionValue', 'get', {
53 option_group_id: 'activity_status',
54 sequential: 1,
55 options: {limit: 0}
56 }];
57 reqs.caseStatuses = ['OptionValue', 'get', {
58 option_group_id: 'case_status',
59 sequential: 1,
60 options: {limit: 0}
61 }];
62 reqs.actTypes = ['OptionValue', 'get', {
63 option_group_id: 'activity_type',
64 sequential: 1,
65 options: {
66 sort: 'name',
67 limit: 0
68 }
69 }];
70 reqs.defaultAssigneeTypes = ['OptionValue', 'get', {
71 option_group_id: 'activity_default_assignee',
72 sequential: 1,
73 options: {
74 limit: 0
75 }
76 }];
77 reqs.relTypes = ['Relationship', 'getoptions', {
78 sequential: 1,
79 field: 'relationship_type_id',
80 context: 'create',
81 is_active: 1,
82 options: {
83 limit: 0
84 }
85 }];
86 reqs.relTypesForm = ['Relationship', 'getoptions', {
87 sequential: 1,
88 field: 'relationship_type_id',
89 context: 'create',
90 isForm: 1,
91 is_active: 1,
92 options: {
93 limit: 0
94 }
95 }];
96 if ($route.current.params.id !== 'new') {
97 reqs.caseType = ['CaseType', 'getsingle', {
98 id: $route.current.params.id
99 }];
100 }
101 return crmApi(reqs);
102 }
103 }
104 });
105 }
106 ]);
107
108 // Add a new record by name.
109 // Ex: <crmAddName crm-options="['Alpha','Beta','Gamma']" crm-var="newItem" crm-on-add="callMyCreateFunction(newItem)" />
110 crmCaseType.directive('crmAddName', function() {
111 return {
112 restrict: 'AE',
113 template: '<input class="add-activity crm-action-menu fa-plus" type="hidden" />',
114 link: function(scope, element, attrs) {
115
116 var input = $('input', element);
117
118 scope._resetSelection = function() {
119 $(input).select2('close');
120 $(input).select2('val', '');
121 scope[attrs.crmVar] = '';
122 };
123
124 $(input).crmSelect2({
125 data: function () {
126 return { results: scope[attrs.crmOptions] };
127 },
128 createSearchChoice: function(term) {
129 return {id: term, text: term + ' (' + ts('new') + ')'};
130 },
131 createSearchChoicePosition: 'bottom',
132 placeholder: attrs.placeholder
133 });
134 $(input).on('select2-selecting', function(e) {
135 scope[attrs.crmVar] = e.val;
136 scope.$evalAsync(attrs.crmOnAdd);
137 scope.$evalAsync('_resetSelection()');
138 e.preventDefault();
139 });
140 }
141 };
142 });
143
144 crmCaseType.directive('crmEditableTabTitle', function($timeout) {
145 return {
146 restrict: 'AE',
147 link: function(scope, element, attrs) {
148 element.addClass('crm-editable crm-editable-enabled');
149 var titleLabel = $(element).find('span');
150 var penIcon = $('<i class="crm-i fa-pencil crm-editable-placeholder"></i>').prependTo(element);
151 var saveButton = $('<button type="button"><i class="crm-i fa-check"></i></button>').appendTo(element);
152 var cancelButton = $('<button type="cancel"><i class="crm-i fa-times"></i></button>').appendTo(element);
153 $('button', element).wrapAll('<div class="crm-editable-form" style="display:none" />');
154 var buttons = $('.crm-editable-form', element);
155 titleLabel.on('click', startEditMode);
156 penIcon.on('click', startEditMode);
157
158 function detectEscapeKeyPress (event) {
159 var isEscape = false;
160
161 if ("key" in event) {
162 isEscape = (event.key == "Escape" || event.key == "Esc");
163 } else {
164 isEscape = (event.keyCode == 27);
165 }
166
167 return isEscape;
168 }
169
170 function detectEnterKeyPress (event) {
171 var isEnter = false;
172
173 if ("key" in event) {
174 isEnter = (event.key == "Enter");
175 } else {
176 isEnter = (event.keyCode == 13);
177 }
178
179 return isEnter;
180 }
181
182 function startEditMode () {
183 if (titleLabel.is(":focus")) {
184 return;
185 }
186
187 penIcon.hide();
188 buttons.show();
189
190 saveButton.click(function () {
191 updateTextValue();
192 stopEditMode();
193 });
194
195 cancelButton.click(function () {
196 revertTextValue();
197 stopEditMode();
198 });
199
200 $(element).addClass('crm-editable-editing');
201
202 titleLabel
203 .attr("contenteditable", "true")
204 .focus()
205 .focusout(function (event) {
206 $timeout(function () {
207 revertTextValue();
208 stopEditMode();
209 }, 500);
210 })
211 .keydown(function(event) {
212 event.stopImmediatePropagation();
213
214 if(detectEscapeKeyPress(event)) {
215 revertTextValue();
216 stopEditMode();
217 } else if(detectEnterKeyPress(event)) {
218 event.preventDefault();
219 updateTextValue();
220 stopEditMode();
221 }
222 });
223 }
224
225 function stopEditMode () {
226 titleLabel.removeAttr("contenteditable").off("focusout");
227 titleLabel.off("keydown");
228 saveButton.off("click");
229 cancelButton.off("click");
230 $(element).removeClass('crm-editable-editing');
231
232 penIcon.show();
233 buttons.hide();
234 }
235
236 function revertTextValue () {
237 titleLabel.text(scope.activitySet.label);
238 }
239
240 function updateTextValue () {
241 var updatedTitle = titleLabel.text();
242
243 scope.$evalAsync(function () {
244 scope.activitySet.label = updatedTitle;
245 });
246 }
247 }
248 };
249 });
250
251 crmCaseType.controller('CaseTypeCtrl', function($scope, crmApi, apiCalls, crmUiHelp) {
252 var defaultAssigneeDefaultValue, ts;
253
254 (function init () {
255
256 ts = $scope.ts = CRM.ts(null);
257 $scope.hs = crmUiHelp({file: 'CRM/Case/CaseType'});
258 $scope.locks = { caseTypeName: true, activitySetName: true };
259 $scope.workflows = { timeline: 'Timeline', sequence: 'Sequence' };
260 defaultAssigneeDefaultValue = _.find(apiCalls.defaultAssigneeTypes.values, { is_default: '1' }) || {};
261
262 storeApiCallsResults();
263 initCaseType();
264 initCaseTypeDefinition();
265 initSelectedStatuses();
266 })();
267
268 /// Stores the api calls results in the $scope object
269 function storeApiCallsResults() {
270 $scope.activityStatuses = apiCalls.actStatuses.values;
271 $scope.caseStatuses = _.indexBy(apiCalls.caseStatuses.values, 'name');
272 $scope.activityTypes = _.indexBy(apiCalls.actTypes.values, 'name');
273 $scope.activityTypeOptions = _.map(apiCalls.actTypes.values, formatActivityTypeOption);
274 $scope.defaultAssigneeTypes = apiCalls.defaultAssigneeTypes.values;
275 $scope.relationshipTypeOptions = _.map(apiCalls.relTypes.values, function(type) {
276 return {id: type.key, text: type.value};
277 });
278 $scope.defaultRelationshipTypeOptions = _.map(apiCalls.relTypesForm.values, function(type) {
279 return {value: type.key, label: type.value};
280 });
281 // stores the default assignee values indexed by their option name:
282 $scope.defaultAssigneeTypeValues = _.chain($scope.defaultAssigneeTypes)
283 .indexBy('name').mapValues('value').value();
284 }
285
286 /// initializes the case type object
287 function initCaseType() {
288 var isNewCaseType = !apiCalls.caseType;
289
290 if (isNewCaseType) {
291 $scope.caseType = _.cloneDeep(newCaseTypeTemplate);
292 } else {
293 $scope.caseType = apiCalls.caseType;
294 }
295 }
296
297 /// initializes the case type definition object
298 function initCaseTypeDefinition() {
299 $scope.caseType.definition = $scope.caseType.definition || [];
300 $scope.caseType.definition.activityTypes = $scope.caseType.definition.activityTypes || [];
301 $scope.caseType.definition.activitySets = $scope.caseType.definition.activitySets || [];
302 $scope.caseType.definition.caseRoles = $scope.caseType.definition.caseRoles || [];
303 $scope.caseType.definition.statuses = $scope.caseType.definition.statuses || [];
304 $scope.caseType.definition.timelineActivityTypes = $scope.caseType.definition.timelineActivityTypes || [];
305 $scope.caseType.definition.restrictActivityAsgmtToCmsUser = $scope.caseType.definition.restrictActivityAsgmtToCmsUser || 0;
306 $scope.caseType.definition.activityAsgmtGrps = $scope.caseType.definition.activityAsgmtGrps || [];
307
308 _.each($scope.caseType.definition.activitySets, function (set) {
309 _.each(set.activityTypes, function (type, name) {
310 var isDefaultAssigneeTypeUndefined = _.isUndefined(type.default_assignee_type);
311 var typeDefinition = $scope.activityTypes[type.name];
312 type.label = (typeDefinition && typeDefinition.label) || type.name;
313
314 if (isDefaultAssigneeTypeUndefined) {
315 type.default_assignee_type = defaultAssigneeDefaultValue.value;
316 }
317 });
318 });
319 }
320
321 /// initializes the selected statuses
322 function initSelectedStatuses() {
323 $scope.selectedStatuses = {};
324
325 _.each(apiCalls.caseStatuses.values, function (status) {
326 $scope.selectedStatuses[status.name] = !$scope.caseType.definition.statuses.length || $scope.caseType.definition.statuses.indexOf(status.name) > -1;
327 });
328 }
329
330 $scope.addActivitySet = function(workflow) {
331 var activitySet = {};
332 activitySet[workflow] = '1';
333 activitySet.activityTypes = [];
334
335 var offset = 1;
336 var names = _.pluck($scope.caseType.definition.activitySets, 'name');
337 while (_.contains(names, workflow + '_' + offset)) offset++;
338 activitySet.name = workflow + '_' + offset;
339 activitySet.label = (offset == 1 ) ? $scope.workflows[workflow] : ($scope.workflows[workflow] + ' #' + offset);
340
341 $scope.caseType.definition.activitySets.push(activitySet);
342 _.defer(function() {
343 $('.crmCaseType-acttab').tabs('refresh').tabs({active: -1});
344 });
345 };
346
347 function formatActivityTypeOption(type) {
348 return {id: type.name, text: type.label, icon: type.icon};
349 }
350
351 function addActivityToSet(activitySet, activityTypeName) {
352 activitySet.activityTypes = activitySet.activityTypes || [];
353 var activity = {
354 name: activityTypeName,
355 label: $scope.activityTypes[activityTypeName].label,
356 status: 'Scheduled',
357 reference_activity: 'Open Case',
358 reference_offset: '1',
359 reference_select: 'newest',
360 default_assignee_type: $scope.defaultAssigneeTypeValues.NONE
361 };
362 activitySet.activityTypes.push(activity);
363 if(typeof activitySet.timeline !== "undefined" && activitySet.timeline == "1") {
364 $scope.caseType.definition.timelineActivityTypes.push(activity);
365 }
366 }
367
368 function resetTimelineActivityTypes() {
369 $scope.caseType.definition.timelineActivityTypes = [];
370 angular.forEach($scope.caseType.definition.activitySets, function(activitySet) {
371 angular.forEach(activitySet.activityTypes, function(activityType) {
372 $scope.caseType.definition.timelineActivityTypes.push(activityType);
373 });
374 });
375 }
376
377 function createActivity(name, callback) {
378 CRM.loadForm(CRM.url('civicrm/admin/options/activity_type', {action: 'add', reset: 1, label: name, component_id: 7}))
379 .on('crmFormSuccess', function(e, data) {
380 $scope.activityTypes[data.optionValue.name] = data.optionValue;
381 $scope.activityTypeOptions.push(formatActivityTypeOption(data.optionValue));
382 callback(data.optionValue);
383 $scope.$digest();
384 });
385 }
386
387 // Add a new activity entry to an activity-set
388 $scope.addActivity = function(activitySet, activityType) {
389 if ($scope.activityTypes[activityType]) {
390 addActivityToSet(activitySet, activityType);
391 } else {
392 createActivity(activityType, function(newActivity) {
393 addActivityToSet(activitySet, newActivity.name);
394 });
395 }
396 };
397
398 /// Add a new top-level activity-type entry
399 $scope.addActivityType = function(activityType) {
400 var names = _.pluck($scope.caseType.definition.activityTypes, 'name');
401 if (!_.contains(names, activityType)) {
402 // Add an activity type that exists
403 if ($scope.activityTypes[activityType]) {
404 $scope.caseType.definition.activityTypes.push({name: activityType});
405 } else {
406 createActivity(activityType, function(newActivity) {
407 $scope.caseType.definition.activityTypes.push({name: newActivity.name});
408 });
409 }
410 }
411 };
412
413 /// Clears the activity's default assignee values for relationship and contact
414 $scope.clearActivityDefaultAssigneeValues = function(activity) {
415 activity.default_assignee_relationship = null;
416 activity.default_assignee_contact = null;
417 };
418
419 /// Add a new role
420 $scope.addRole = function(roles, roleName) {
421 var names = _.pluck($scope.caseType.definition.caseRoles, 'name');
422 if (!_.contains(names, roleName)) {
423 if (_.where($scope.relationshipTypeOptions, {id: roleName}).length) {
424 roles.push({name: roleName});
425 } else {
426 CRM.loadForm(CRM.url('civicrm/admin/reltype', {action: 'add', reset: 1, label_a_b: roleName}))
427 .on('crmFormSuccess', function(e, data) {
428 var newType = _.values(data.relationshipType)[0];
429 roles.push({name: newType.label_a_b});
430 // Assume that the case role should be A-B but add both directions as options.
431 $scope.relationshipTypeOptions.push({id: newType.label_a_b, text: newType.label_a_b});
432 if (newType.label_a_b != newType.label_b_a) {
433 $scope.relationshipTypeOptions.push({id: newType.label_b_a, text: newType.label_b_a});
434 }
435 $scope.$digest();
436 });
437 }
438 }
439 };
440
441 $scope.onManagerChange = function(managerRole) {
442 angular.forEach($scope.caseType.definition.caseRoles, function(caseRole) {
443 if (caseRole != managerRole) {
444 caseRole.manager = '0';
445 }
446 });
447 };
448
449 $scope.removeItem = function(array, item) {
450 var idx = _.indexOf(array, item);
451 if (idx != -1) {
452 array.splice(idx, 1);
453 resetTimelineActivityTypes();
454 }
455 };
456
457 $scope.isForkable = function() {
458 return !$scope.caseType.id || $scope.caseType.is_forkable;
459 };
460
461 $scope.newStatus = function() {
462 CRM.loadForm(CRM.url('civicrm/admin/options/case_status', {action: 'add', reset: 1}))
463 .on('crmFormSuccess', function(e, data) {
464 $scope.caseStatuses[data.optionValue.name] = data.optionValue;
465 $scope.selectedStatuses[data.optionValue.name] = true;
466 $scope.$digest();
467 });
468 };
469
470 $scope.isNewActivitySetAllowed = function(workflow) {
471 switch (workflow) {
472 case 'timeline':
473 return true;
474 case 'sequence':
475 return 0 === _.where($scope.caseType.definition.activitySets, {sequence: '1'}).length;
476 default:
477 CRM.console('warn', 'Denied access to unrecognized workflow: (' + workflow + ')');
478 return false;
479 }
480 };
481
482 $scope.isActivityRemovable = function(activitySet, activity) {
483 return true;
484 };
485
486 $scope.isValidName = function(name) {
487 return !name || name.match(/^[a-zA-Z0-9_]+$/);
488 };
489
490 $scope.getWorkflowName = function(activitySet) {
491 var result = 'Unknown';
492 _.each($scope.workflows, function(value, key) {
493 if (activitySet[key]) result = value;
494 });
495 return result;
496 };
497
498 /**
499 * Determine which HTML partial to use for a particular
500 *
501 * @return string URL of the HTML partial
502 */
503 $scope.activityTableTemplate = function(activitySet) {
504 if (activitySet.timeline) {
505 return '~/crmCaseType/timelineTable.html';
506 } else if (activitySet.sequence) {
507 return '~/crmCaseType/sequenceTable.html';
508 } else {
509 return '';
510 }
511 };
512
513 $scope.dump = function() {
514 console.log($scope.caseType);
515 };
516
517 $scope.save = function() {
518 // Add selected statuses
519 var selectedStatuses = [];
520 _.each($scope.selectedStatuses, function(v, k) {
521 if (v) selectedStatuses.push(k);
522 });
523 // Ignore if ALL or NONE selected
524 $scope.caseType.definition.statuses = selectedStatuses.length == _.size($scope.selectedStatuses) ? [] : selectedStatuses;
525
526 if ($scope.caseType.definition.activityAsgmtGrps) {
527 $scope.caseType.definition.activityAsgmtGrps = $scope.caseType.definition.activityAsgmtGrps.toString().split(",");
528 }
529
530 var result = crmApi('CaseType', 'create', $scope.caseType, true);
531 result.then(function(data) {
532 if (data.is_error === 0 || data.is_error == '0') {
533 $scope.caseType.id = data.id;
534 window.location.href = '#/caseType';
535 }
536 });
537 };
538
539 $scope.$watchCollection('caseType.definition.activitySets', function() {
540 _.defer(function() {
541 $('.crmCaseType-acttab').tabs('refresh');
542 });
543 });
544
545 var updateCaseTypeName = function () {
546 if (!$scope.caseType.id && $scope.locks.caseTypeName) {
547 // Should we do some filtering? Lowercase? Strip whitespace?
548 var t = $scope.caseType.title ? $scope.caseType.title : '';
549 $scope.caseType.name = t.replace(/ /g, '_').replace(/[^a-zA-Z0-9_]/g, '').toLowerCase();
550 }
551 };
552 $scope.$watch('locks.caseTypeName', updateCaseTypeName);
553 $scope.$watch('caseType.title', updateCaseTypeName);
554
555 if (!$scope.isForkable()) {
556 CRM.alert(ts('The CiviCase XML file for this case-type prohibits editing the definition.'));
557 }
558
559 });
560
561 crmCaseType.controller('CaseTypeListCtrl', function($scope, crmApi, caseTypes) {
562 var ts = $scope.ts = CRM.ts(null);
563
564 $scope.caseTypes = caseTypes.values;
565 $scope.toggleCaseType = function (caseType) {
566 caseType.is_active = (caseType.is_active == '1') ? '0' : '1';
567 crmApi('CaseType', 'create', caseType, true)
568 .catch(function (data) {
569 caseType.is_active = (caseType.is_active == '1') ? '0' : '1'; // revert
570 $scope.$digest();
571 });
572 };
573 $scope.deleteCaseType = function (caseType) {
574 crmApi('CaseType', 'delete', {id: caseType.id}, {
575 error: function (data) {
576 CRM.alert(data.error_message, ts('Error'), 'error');
577 }
578 })
579 .then(function (data) {
580 delete caseTypes.values[caseType.id];
581 });
582 };
583 $scope.revertCaseType = function (caseType) {
584 caseType.definition = 'null';
585 caseType.is_forked = '0';
586 crmApi('CaseType', 'create', caseType, true)
587 .catch(function (data) {
588 caseType.is_forked = '1'; // restore
589 $scope.$digest();
590 });
591 };
592 });
593
594 })(angular, CRM.$, CRM._);