dev/core#530 CiviCase: using label instead of name to be consistent and commenting why
[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 = ['RelationshipType', 'get', {
78 sequential: 1,
79 is_active: 1,
80 options: {
81 sort: 'label_a_b',
82 limit: 0
83 }
84 }];
85 if ($route.current.params.id !== 'new') {
86 reqs.caseType = ['CaseType', 'getsingle', {
87 id: $route.current.params.id
88 }];
89 }
90 return crmApi(reqs);
91 }
92 }
93 });
94 }
95 ]);
96
97 // Add a new record by name.
98 // Ex: <crmAddName crm-options="['Alpha','Beta','Gamma']" crm-var="newItem" crm-on-add="callMyCreateFunction(newItem)" />
99 crmCaseType.directive('crmAddName', function() {
100 return {
101 restrict: 'AE',
102 template: '<input class="add-activity crm-action-menu fa-plus" type="hidden" />',
103 link: function(scope, element, attrs) {
104
105 var input = $('input', element);
106
107 scope._resetSelection = function() {
108 $(input).select2('close');
109 $(input).select2('val', '');
110 scope[attrs.crmVar] = '';
111 };
112
113 $(input).crmSelect2({
114 data: function () {
115 return { results: scope[attrs.crmOptions] };
116 },
117 createSearchChoice: function(term) {
118 return {id: term, text: term + ' (' + ts('new') + ')'};
119 },
120 createSearchChoicePosition: 'bottom',
121 placeholder: attrs.placeholder
122 });
123 $(input).on('select2-selecting', function(e) {
124 scope[attrs.crmVar] = e.val;
125 scope.$evalAsync(attrs.crmOnAdd);
126 scope.$evalAsync('_resetSelection()');
127 e.preventDefault();
128 });
129 }
130 };
131 });
132
133 crmCaseType.directive('crmEditableTabTitle', function($timeout) {
134 return {
135 restrict: 'AE',
136 link: function(scope, element, attrs) {
137 element.addClass('crm-editable crm-editable-enabled');
138 var titleLabel = $(element).find('span');
139 var penIcon = $('<i class="crm-i fa-pencil crm-editable-placeholder"></i>').prependTo(element);
140 var saveButton = $('<button type="button"><i class="crm-i fa-check"></i></button>').appendTo(element);
141 var cancelButton = $('<button type="cancel"><i class="crm-i fa-times"></i></button>').appendTo(element);
142 $('button', element).wrapAll('<div class="crm-editable-form" style="display:none" />');
143 var buttons = $('.crm-editable-form', element);
144 titleLabel.on('click', startEditMode);
145 penIcon.on('click', startEditMode);
146
147 function detectEscapeKeyPress (event) {
148 var isEscape = false;
149
150 if ("key" in event) {
151 isEscape = (event.key == "Escape" || event.key == "Esc");
152 } else {
153 isEscape = (event.keyCode == 27);
154 }
155
156 return isEscape;
157 }
158
159 function detectEnterKeyPress (event) {
160 var isEnter = false;
161
162 if ("key" in event) {
163 isEnter = (event.key == "Enter");
164 } else {
165 isEnter = (event.keyCode == 13);
166 }
167
168 return isEnter;
169 }
170
171 function startEditMode () {
172 if (titleLabel.is(":focus")) {
173 return;
174 }
175
176 penIcon.hide();
177 buttons.show();
178
179 saveButton.click(function () {
180 updateTextValue();
181 stopEditMode();
182 });
183
184 cancelButton.click(function () {
185 revertTextValue();
186 stopEditMode();
187 });
188
189 $(element).addClass('crm-editable-editing');
190
191 titleLabel
192 .attr("contenteditable", "true")
193 .focus()
194 .focusout(function (event) {
195 $timeout(function () {
196 revertTextValue();
197 stopEditMode();
198 }, 500);
199 })
200 .keydown(function(event) {
201 event.stopImmediatePropagation();
202
203 if(detectEscapeKeyPress(event)) {
204 revertTextValue();
205 stopEditMode();
206 } else if(detectEnterKeyPress(event)) {
207 event.preventDefault();
208 updateTextValue();
209 stopEditMode();
210 }
211 });
212 }
213
214 function stopEditMode () {
215 titleLabel.removeAttr("contenteditable").off("focusout");
216 titleLabel.off("keydown");
217 saveButton.off("click");
218 cancelButton.off("click");
219 $(element).removeClass('crm-editable-editing');
220
221 penIcon.show();
222 buttons.hide();
223 }
224
225 function revertTextValue () {
226 titleLabel.text(scope.activitySet.label);
227 }
228
229 function updateTextValue () {
230 var updatedTitle = titleLabel.text();
231
232 scope.$evalAsync(function () {
233 scope.activitySet.label = updatedTitle;
234 });
235 }
236 }
237 };
238 });
239
240 crmCaseType.controller('CaseTypeCtrl', function($scope, crmApi, apiCalls, crmUiHelp) {
241 var defaultAssigneeDefaultValue, ts;
242
243 (function init () {
244
245 ts = $scope.ts = CRM.ts(null);
246 $scope.hs = crmUiHelp({file: 'CRM/Case/CaseType'});
247 $scope.locks = { caseTypeName: true, activitySetName: true };
248 $scope.workflows = { timeline: 'Timeline', sequence: 'Sequence' };
249 defaultAssigneeDefaultValue = _.find(apiCalls.defaultAssigneeTypes.values, { is_default: '1' }) || {};
250
251 storeApiCallsResults();
252 initCaseType();
253 initCaseTypeDefinition();
254 initSelectedStatuses();
255 })();
256
257 /// Stores the api calls results in the $scope object
258 function storeApiCallsResults() {
259 $scope.activityStatuses = apiCalls.actStatuses.values;
260 $scope.caseStatuses = _.indexBy(apiCalls.caseStatuses.values, 'name');
261 $scope.activityTypes = _.indexBy(apiCalls.actTypes.values, 'name');
262 $scope.activityTypeOptions = _.map(apiCalls.actTypes.values, formatActivityTypeOption);
263 $scope.defaultAssigneeTypes = apiCalls.defaultAssigneeTypes.values;
264 $scope.relationshipTypeOptions = getRelationshipTypeOptions(false);
265 $scope.defaultRelationshipTypeOptions = getRelationshipTypeOptions(true);
266 // stores the default assignee values indexed by their option name:
267 $scope.defaultAssigneeTypeValues = _.chain($scope.defaultAssigneeTypes)
268 .indexBy('name').mapValues('value').value();
269 }
270
271 // Returns the relationship type options. If the relationship is
272 // bidirectional (Ex: Spouse of) it adds a single option otherwise it adds
273 // two options representing the relationship type directions (Ex: Employee
274 // of, Employer of).
275 //
276 // The default relationship field needs values that are IDs with direction,
277 // while the role field needs values that are names (with implicit
278 // direction).
279 //
280 // At any rate, the labels should follow the convention in the UI of
281 // describing case roles from the perspective of the client, while the
282 // values must follow the convention in the XML of describing case roles
283 // from the perspective of the non-client.
284 function getRelationshipTypeOptions($isDefault) {
285 return _.transform(apiCalls.relTypes.values, function(result, relType) {
286 var isBidirectionalRelationship = relType.label_a_b === relType.label_b_a;
287 if ($isDefault) {
288 result.push({
289 label: relType.label_b_a,
290 value: relType.id + '_a_b'
291 });
292
293 if (!isBidirectionalRelationship) {
294 result.push({
295 label: relType.label_a_b,
296 value: relType.id + '_b_a'
297 });
298 }
299 }
300 // TODO The ids below really should use names not labels see
301 // https://lab.civicrm.org/dev/core/issues/774
302 else {
303 result.push({
304 text: relType.label_b_a,
305 id: relType.label_a_b
306 });
307
308 if (!isBidirectionalRelationship) {
309 result.push({
310 text: relType.label_a_b,
311 id: relType.label_b_a
312 });
313 }
314 }
315 }, []);
316 }
317
318 /// initializes the case type object
319 function initCaseType() {
320 var isNewCaseType = !apiCalls.caseType;
321
322 if (isNewCaseType) {
323 $scope.caseType = _.cloneDeep(newCaseTypeTemplate);
324 } else {
325 $scope.caseType = apiCalls.caseType;
326 }
327 }
328
329 /// initializes the case type definition object
330 function initCaseTypeDefinition() {
331 $scope.caseType.definition = $scope.caseType.definition || [];
332 $scope.caseType.definition.activityTypes = $scope.caseType.definition.activityTypes || [];
333 $scope.caseType.definition.activitySets = $scope.caseType.definition.activitySets || [];
334 $scope.caseType.definition.caseRoles = $scope.caseType.definition.caseRoles || [];
335 $scope.caseType.definition.statuses = $scope.caseType.definition.statuses || [];
336 $scope.caseType.definition.timelineActivityTypes = $scope.caseType.definition.timelineActivityTypes || [];
337 $scope.caseType.definition.restrictActivityAsgmtToCmsUser = $scope.caseType.definition.restrictActivityAsgmtToCmsUser || 0;
338 $scope.caseType.definition.activityAsgmtGrps = $scope.caseType.definition.activityAsgmtGrps || [];
339
340 _.each($scope.caseType.definition.activitySets, function (set) {
341 _.each(set.activityTypes, function (type, name) {
342 var isDefaultAssigneeTypeUndefined = _.isUndefined(type.default_assignee_type);
343 var typeDefinition = $scope.activityTypes[type.name];
344 type.label = (typeDefinition && typeDefinition.label) || type.name;
345
346 if (isDefaultAssigneeTypeUndefined) {
347 type.default_assignee_type = defaultAssigneeDefaultValue.value;
348 }
349 });
350 });
351
352 // go lookup and add client-perspective labels for $scope.caseType.definition.caseRoles
353 _.each($scope.caseType.definition.caseRoles, function (set) {
354 _.each($scope.relationshipTypeOptions, function (relTypes) {
355 if (relTypes.text == set.name) {
356 set.displaylabel = relTypes.id;
357 }
358 });
359 });
360 }
361
362 /// initializes the selected statuses
363 function initSelectedStatuses() {
364 $scope.selectedStatuses = {};
365
366 _.each(apiCalls.caseStatuses.values, function (status) {
367 $scope.selectedStatuses[status.name] = !$scope.caseType.definition.statuses.length || $scope.caseType.definition.statuses.indexOf(status.name) > -1;
368 });
369 }
370
371 $scope.addActivitySet = function(workflow) {
372 var activitySet = {};
373 activitySet[workflow] = '1';
374 activitySet.activityTypes = [];
375
376 var offset = 1;
377 var names = _.pluck($scope.caseType.definition.activitySets, 'name');
378 while (_.contains(names, workflow + '_' + offset)) offset++;
379 activitySet.name = workflow + '_' + offset;
380 activitySet.label = (offset == 1 ) ? $scope.workflows[workflow] : ($scope.workflows[workflow] + ' #' + offset);
381
382 $scope.caseType.definition.activitySets.push(activitySet);
383 _.defer(function() {
384 $('.crmCaseType-acttab').tabs('refresh').tabs({active: -1});
385 });
386 };
387
388 function formatActivityTypeOption(type) {
389 return {id: type.name, text: type.label, icon: type.icon};
390 }
391
392 function addActivityToSet(activitySet, activityTypeName) {
393 activitySet.activityTypes = activitySet.activityTypes || [];
394 var activity = {
395 name: activityTypeName,
396 label: $scope.activityTypes[activityTypeName].label,
397 status: 'Scheduled',
398 reference_activity: 'Open Case',
399 reference_offset: '1',
400 reference_select: 'newest',
401 default_assignee_type: $scope.defaultAssigneeTypeValues.NONE
402 };
403 activitySet.activityTypes.push(activity);
404 if(typeof activitySet.timeline !== "undefined" && activitySet.timeline == "1") {
405 $scope.caseType.definition.timelineActivityTypes.push(activity);
406 }
407 }
408
409 function resetTimelineActivityTypes() {
410 $scope.caseType.definition.timelineActivityTypes = [];
411 angular.forEach($scope.caseType.definition.activitySets, function(activitySet) {
412 angular.forEach(activitySet.activityTypes, function(activityType) {
413 $scope.caseType.definition.timelineActivityTypes.push(activityType);
414 });
415 });
416 }
417
418 function createActivity(name, callback) {
419 CRM.loadForm(CRM.url('civicrm/admin/options/activity_type', {action: 'add', reset: 1, label: name, component_id: 7}))
420 .on('crmFormSuccess', function(e, data) {
421 $scope.activityTypes[data.optionValue.name] = data.optionValue;
422 $scope.activityTypeOptions.push(formatActivityTypeOption(data.optionValue));
423 callback(data.optionValue);
424 $scope.$digest();
425 });
426 }
427
428 // Add a new activity entry to an activity-set
429 $scope.addActivity = function(activitySet, activityType) {
430 if ($scope.activityTypes[activityType]) {
431 addActivityToSet(activitySet, activityType);
432 } else {
433 createActivity(activityType, function(newActivity) {
434 addActivityToSet(activitySet, newActivity.name);
435 });
436 }
437 };
438
439 /// Add a new top-level activity-type entry
440 $scope.addActivityType = function(activityType) {
441 var names = _.pluck($scope.caseType.definition.activityTypes, 'name');
442 if (!_.contains(names, activityType)) {
443 // Add an activity type that exists
444 if ($scope.activityTypes[activityType]) {
445 $scope.caseType.definition.activityTypes.push({name: activityType});
446 } else {
447 createActivity(activityType, function(newActivity) {
448 $scope.caseType.definition.activityTypes.push({name: newActivity.name});
449 });
450 }
451 }
452 };
453
454 /// Clears the activity's default assignee values for relationship and contact
455 $scope.clearActivityDefaultAssigneeValues = function(activity) {
456 activity.default_assignee_relationship = null;
457 activity.default_assignee_contact = null;
458 };
459
460 // TODO roleName passed to addRole is a misnomer, its passed as the
461 // label HOWEVER it should be saved to xml as the name see
462 // https://lab.civicrm.org/dev/core/issues/774
463
464 /// Add a new role
465 $scope.addRole = function(roles, roleName) {
466 var names = _.pluck($scope.caseType.definition.caseRoles, 'name');
467 if (!_.contains(names, roleName)) {
468 var matchingRoles = _.filter($scope.relationshipTypeOptions, {id: roleName});
469 if (matchingRoles.length) {
470 var matchingRole = matchingRoles.shift();
471 roles.push({name: roleName, displaylabel: matchingRole.text});
472 } else {
473 CRM.loadForm(CRM.url('civicrm/admin/reltype', {action: 'add', reset: 1, label_a_b: roleName}))
474 .on('crmFormSuccess', function(e, data) {
475 var newType = _.values(data.relationshipType)[0];
476 roles.push({name: newType.label_a_b, displaylabel: newType.label_b_a});
477 // Assume that the case role should be A-B but add both directions as options.
478 $scope.relationshipTypeOptions.push({id: newType.label_a_b, text: newType.label_a_b});
479 if (newType.label_a_b != newType.label_b_a) {
480 $scope.relationshipTypeOptions.push({id: newType.label_b_a, text: newType.label_b_a});
481 }
482 $scope.$digest();
483 });
484 }
485 }
486 };
487
488 $scope.onManagerChange = function(managerRole) {
489 angular.forEach($scope.caseType.definition.caseRoles, function(caseRole) {
490 if (caseRole != managerRole) {
491 caseRole.manager = '0';
492 }
493 });
494 };
495
496 $scope.removeItem = function(array, item) {
497 var idx = _.indexOf(array, item);
498 if (idx != -1) {
499 array.splice(idx, 1);
500 resetTimelineActivityTypes();
501 }
502 };
503
504 $scope.isForkable = function() {
505 return !$scope.caseType.id || $scope.caseType.is_forkable;
506 };
507
508 $scope.newStatus = function() {
509 CRM.loadForm(CRM.url('civicrm/admin/options/case_status', {action: 'add', reset: 1}))
510 .on('crmFormSuccess', function(e, data) {
511 $scope.caseStatuses[data.optionValue.name] = data.optionValue;
512 $scope.selectedStatuses[data.optionValue.name] = true;
513 $scope.$digest();
514 });
515 };
516
517 $scope.isNewActivitySetAllowed = function(workflow) {
518 switch (workflow) {
519 case 'timeline':
520 return true;
521 case 'sequence':
522 return 0 === _.where($scope.caseType.definition.activitySets, {sequence: '1'}).length;
523 default:
524 CRM.console('warn', 'Denied access to unrecognized workflow: (' + workflow + ')');
525 return false;
526 }
527 };
528
529 $scope.isActivityRemovable = function(activitySet, activity) {
530 return true;
531 };
532
533 $scope.isValidName = function(name) {
534 return !name || name.match(/^[a-zA-Z0-9_]+$/);
535 };
536
537 $scope.getWorkflowName = function(activitySet) {
538 var result = 'Unknown';
539 _.each($scope.workflows, function(value, key) {
540 if (activitySet[key]) result = value;
541 });
542 return result;
543 };
544
545 /**
546 * Determine which HTML partial to use for a particular
547 *
548 * @return string URL of the HTML partial
549 */
550 $scope.activityTableTemplate = function(activitySet) {
551 if (activitySet.timeline) {
552 return '~/crmCaseType/timelineTable.html';
553 } else if (activitySet.sequence) {
554 return '~/crmCaseType/sequenceTable.html';
555 } else {
556 return '';
557 }
558 };
559
560 $scope.dump = function() {
561 console.log($scope.caseType);
562 };
563
564 $scope.save = function() {
565 // Add selected statuses
566 var selectedStatuses = [];
567 _.each($scope.selectedStatuses, function(v, k) {
568 if (v) selectedStatuses.push(k);
569 });
570 // Ignore if ALL or NONE selected
571 $scope.caseType.definition.statuses = selectedStatuses.length == _.size($scope.selectedStatuses) ? [] : selectedStatuses;
572
573 if ($scope.caseType.definition.activityAsgmtGrps) {
574 $scope.caseType.definition.activityAsgmtGrps = $scope.caseType.definition.activityAsgmtGrps.toString().split(",");
575 }
576
577 function dropDisplaylabel (v) {
578 delete v.displaylabel;
579 }
580
581 // strip out labels from $scope.caseType.definition.caseRoles
582 _.map($scope.caseType.definition.caseRoles, dropDisplaylabel);
583
584 var result = crmApi('CaseType', 'create', $scope.caseType, true);
585 result.then(function(data) {
586 if (data.is_error === 0 || data.is_error == '0') {
587 $scope.caseType.id = data.id;
588 window.location.href = '#/caseType';
589 }
590 });
591 };
592
593 $scope.$watchCollection('caseType.definition.activitySets', function() {
594 _.defer(function() {
595 $('.crmCaseType-acttab').tabs('refresh');
596 });
597 });
598
599 var updateCaseTypeName = function () {
600 if (!$scope.caseType.id && $scope.locks.caseTypeName) {
601 // Should we do some filtering? Lowercase? Strip whitespace?
602 var t = $scope.caseType.title ? $scope.caseType.title : '';
603 $scope.caseType.name = t.replace(/ /g, '_').replace(/[^a-zA-Z0-9_]/g, '').toLowerCase();
604 }
605 };
606 $scope.$watch('locks.caseTypeName', updateCaseTypeName);
607 $scope.$watch('caseType.title', updateCaseTypeName);
608
609 if (!$scope.isForkable()) {
610 CRM.alert(ts('The CiviCase XML file for this case-type prohibits editing the definition.'));
611 }
612
613 });
614
615 crmCaseType.controller('CaseTypeListCtrl', function($scope, crmApi, caseTypes) {
616 var ts = $scope.ts = CRM.ts(null);
617
618 $scope.caseTypes = caseTypes.values;
619 $scope.toggleCaseType = function (caseType) {
620 caseType.is_active = (caseType.is_active == '1') ? '0' : '1';
621 crmApi('CaseType', 'create', caseType, true)
622 .catch(function (data) {
623 caseType.is_active = (caseType.is_active == '1') ? '0' : '1'; // revert
624 $scope.$digest();
625 });
626 };
627 $scope.deleteCaseType = function (caseType) {
628 crmApi('CaseType', 'delete', {id: caseType.id}, {
629 error: function (data) {
630 CRM.alert(data.error_message, ts('Error'), 'error');
631 }
632 })
633 .then(function (data) {
634 delete caseTypes.values[caseType.id];
635 });
636 };
637 $scope.revertCaseType = function (caseType) {
638 caseType.definition = 'null';
639 caseType.is_forked = '0';
640 crmApi('CaseType', 'create', caseType, true)
641 .catch(function (data) {
642 caseType.is_forked = '1'; // restore
643 $scope.$digest();
644 });
645 };
646 });
647
648 })(angular, CRM.$, CRM._);