diff options
Diffstat (limited to 'www/crm/wp-content/plugins/civicrm/civicrm/ang')
160 files changed, 9423 insertions, 0 deletions
diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/angularFileUpload.ang.php b/www/crm/wp-content/plugins/civicrm/civicrm/ang/angularFileUpload.ang.php new file mode 100644 index 00000000..68f4aa99 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/angularFileUpload.ang.php @@ -0,0 +1,9 @@ +<?php +// This file declares an Angular module which can be autoloaded +// in CiviCRM. See also: +// http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_angularModules + +return [ + 'ext' => 'civicrm', + 'js' => ['bower_components/angular-file-upload/angular-file-upload.min.js'], +]; diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/checklist-model.ang.php b/www/crm/wp-content/plugins/civicrm/civicrm/ang/checklist-model.ang.php new file mode 100644 index 00000000..ffa8613b --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/checklist-model.ang.php @@ -0,0 +1,10 @@ +<?php +// This file declares an Angular module which can be autoloaded +// in CiviCRM. See also: +// http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_angularModules + +return [ + 'ext' => 'civicrm', + 'basePages' => [], + 'js' => ['bower_components/checklist-model/checklist-model.js'], +]; diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmApp.ang.php b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmApp.ang.php new file mode 100644 index 00000000..d4179315 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmApp.ang.php @@ -0,0 +1,9 @@ +<?php +// This file declares an Angular module which can be autoloaded +// in CiviCRM. See also: +// http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_angularModules + +return [ + 'ext' => 'civicrm', + 'js' => ['ang/crmApp.js'], +]; diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmApp.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmApp.js new file mode 100644 index 00000000..c7bb81e2 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmApp.js @@ -0,0 +1,23 @@ +(function(angular, CRM) { + // crmApp is the default application which aggregates all known modules. + // crmApp should not provide any significant services, and no other + // modules should depend on it. + var crmApp = angular.module('crmApp', CRM.angular.modules); + crmApp.config(['$routeProvider', + function($routeProvider) { + + if (CRM.crmApp.defaultRoute) { + $routeProvider.when('/', { + template: '<div></div>', + controller: function($location) { + $location.path(CRM.crmApp.defaultRoute); + } + }); + } + + $routeProvider.otherwise({ + template: ts('Unknown path') + }); + } + ]); +})(angular, CRM); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmAttachment.ang.php b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmAttachment.ang.php new file mode 100644 index 00000000..e278f75e --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmAttachment.ang.php @@ -0,0 +1,15 @@ +<?php +// This file declares an Angular module which can be autoloaded +// in CiviCRM. See also: +// http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_angularModules + +return [ + 'ext' => 'civicrm', + 'js' => ['ang/crmAttachment.js'], + 'css' => ['ang/crmAttachment.css'], + 'partials' => ['ang/crmAttachment'], + 'settings' => [ + 'token' => \CRM_Core_Page_AJAX_Attachment::createToken(), + ], + 'requires' => ['angularFileUpload', 'crmResource'], +]; diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmAttachment.css b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmAttachment.css new file mode 100644 index 00000000..31c6e2f5 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmAttachment.css @@ -0,0 +1,21 @@ +.crm-attachments { + border: 1px solid transparent; +} + +.crm-attachments.nv-file-over { + border: 1px solid red; +} + +.crm-attachments td.filename { + font-size: 0.8em; + font-family: 'Courier New', monospace; + vertical-align: middle; +} + +.crm-attachments td.filename-new { + font-style: italic; +} + +.crm-attachments td .crm-form-text { + width: 30em; +} diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmAttachment.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmAttachment.js new file mode 100644 index 00000000..b89fe4ca --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmAttachment.js @@ -0,0 +1,167 @@ +/// crmFile: Manage file attachments +(function (angular, $, _) { + + angular.module('crmAttachment', CRM.angRequires('crmAttachment')); + + // crmAttachment manages the list of files which are attached to a given entity + angular.module('crmAttachment').factory('CrmAttachments', function (crmApi, crmStatus, FileUploader, $q) { + // @param target an Object(entity_table:'',entity_id:'') or function which generates an object + function CrmAttachments(target) { + var crmAttachments = this; + this._target = target; + this.files = []; + this.trash = []; + this.uploader = new FileUploader({ + url: CRM.url('civicrm/ajax/attachment'), + onAfterAddingFile: function onAfterAddingFile(item) { + item.crmData = { + description: '' + }; + }, + onSuccessItem: function onSuccessItem(item, response, status, headers) { + crmAttachments.files.push(response.file.values[response.file.id]); + crmAttachments.uploader.removeFromQueue(item); + }, + onErrorItem: function onErrorItem(item, response, status, headers) { + var msg = (response && response.file && response.file.error_message) ? response.file.error_message : ts('Unknown error'); + CRM.alert(item.file.name + ' - ' + msg, ts('Attachment failed')); + crmAttachments.uploader.removeFromQueue(item); + } + }); + } + + angular.extend(CrmAttachments.prototype, { + // @return Object(entity_table:'',entity_id:'') + getTarget: function () { + return (angular.isFunction(this._target) ? this._target() : this._target); + }, + // @return Promise<Attachment> + load: function load() { + var target = this.getTarget(); + var Attachment = this; + + if (target.entity_id) { + var params = { + entity_table: target.entity_table, + entity_id: target.entity_id + }; + return crmApi('Attachment', 'get', params).then(function (apiResult) { + Attachment.files = _.values(apiResult.values); + return Attachment; + }); + } + else { + var dfr = $q.defer(); + Attachment.files = []; + dfr.resolve(Attachment); + return dfr.promise; + } + }, + // @return Promise + save: function save() { + var crmAttachments = this; + var target = this.getTarget(); + if (!target.entity_table || !target.entity_id) { + throw "Cannot save attachments: unknown entity_table or entity_id"; + } + + var params = _.extend({}, target); + params.values = crmAttachments.files; + return crmApi('Attachment', 'replace', params) + .then(function () { + var dfr = $q.defer(); + + var newItems = crmAttachments.uploader.getNotUploadedItems(); + if (newItems.length > 0) { + _.each(newItems, function (item) { + item.formData = [_.extend({crm_attachment_token: CRM.crmAttachment.token}, target, item.crmData)]; + }); + crmAttachments.uploader.onCompleteAll = function onCompleteAll() { + delete crmAttachments.uploader.onCompleteAll; + dfr.resolve(crmAttachments); + }; + crmAttachments.uploader.uploadAll(); + } + else { + dfr.resolve(crmAttachments); + } + + return dfr.promise; + }); + }, + // Compute a digest over the list of files. The signature should change if the attachment list has changed + // (become dirty). + getAutosaveSignature: function getAutosaveSignature() { + var sig = []; + // Attachments have a special lifecycle, and attachments.queue is not properly serializable, so + // it takes some special effort to figure out a suitable signature. Issues which can cause gratuitous saving: + // - Files move from this.uploader.queue to this.files after upload. + // - File names are munged after upload. + // - Deletes are performed immediately (outside the save process). + angular.forEach(this.files, function(item) { + sig.push({f: item.name.replace(/[^a-zA0-Z0-9\.]/, '_'), d: item.description}); + }); + angular.forEach(this.uploader.queue, function(item) { + sig.push({f: item.file.name.replace(/[^a-zA0-Z0-9\.]/, '_'), d: item.crmData.description}); + }); + angular.forEach(this.trash, function(item) { + sig.push({f: item.name.replace(/[^a-zA0-Z0-9\.]/, '_'), d: item.description}); + }); + return _.sortBy(sig, 'name'); + }, + // @param Object file APIv3 attachment record (e.g. id, entity_table, entity_id, description) + deleteFile: function deleteFile(file) { + var crmAttachments = this; + + var idx = _.indexOf(this.files, file); + if (idx != -1) { + this.files.splice(idx, 1); + } + + this.trash.push(file); + + if (file.id) { + var p = crmApi('Attachment', 'delete', {id: file.id}).then( + function () { // success + }, + function (response) { // error; restore the file + var msg = angular.isObject(response) ? response.error_message : ''; + CRM.alert(msg, ts('Deletion failed')); + crmAttachments.files.push(file); + + var trashIdx = _.indexOf(crmAttachments.trash, file); + if (trashIdx != -1) { + crmAttachments.trash.splice(trashIdx, 1); + } + } + ); + return crmStatus({start: ts('Deleting...'), success: ts('Deleted')}, p); + } + } + }); + + return CrmAttachments; + }); + + // example: + // $scope.myAttachments = new CrmAttachments({entity_table: 'civicrm_mailing', entity_id: 123}); + // <div crm-attachments="myAttachments"/> + angular.module('crmAttachment').directive('crmAttachments', function ($parse, $timeout) { + return { + scope: { + crmAttachments: '@' + }, + template: '<div ng-if="ready" ng-include="inclUrl"></div>', + link: function (scope, elm, attr) { + var model = $parse(attr.crmAttachments); + scope.att = model(scope.$parent); + scope.ts = CRM.ts(null); + scope.inclUrl = '~/crmAttachment/attachments.html'; + + // delay rendering of child tree until after model has been populated + scope.ready = true; + } + }; + }); + +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmAttachment/attachments.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmAttachment/attachments.html new file mode 100644 index 00000000..dcd7f866 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmAttachment/attachments.html @@ -0,0 +1,46 @@ +<div nv-file-drop nv-file-over uploader="att.uploader" class="crm-attachments"> + <table> + <tbody> + <!-- Files from DB --> + <tr ng-repeat="file in att.files"> + <td class="filename filename-existing"> + <a ng-href="{{file.url}}" target="_blank">{{file.name}}</a> + </td> + <td> + <input ng-model="file.description" class="crm-form-text" placeholder="{{ts('Description')}}"/> + </td> + <td> + <a + crm-icon="fa-trash" + crm-confirm="{message: ts('Deleting an attachment will completely remove it from server.')}" on-yes="att.deleteFile(file)" + title="{{ts('Delete attachment')}}" + class="crm-hover-button"> + </a> + </td> + </tr> + <!-- Newly selected files --> + <!-- This is fairly minimal. For examples with progress-bars and file-sizes, see https://github.com/nervgh/angular-file-upload/blob/master/examples/simple/index.html --> + <tr ng-repeat="item in att.uploader.queue" ng-class="{nvReady: item.isReady, nvUploading:item.isUploading, nvUploaded:item.isUploaded,nvSuccess:item.isSuccess,nvCancel:item.isCancel,nvError:item.isError}"> + <td class="filename filename-new">{{item.file.name}}</td> + <td> + <input ng-model="item.crmData.description" class="crm-form-text" placeholder="{{ts('Description')}}"/> + <!-- item.isReady item.isUploading item.isUploaded item.isSuccess item.isCancel item.isError --> + </td> + <td> + <a crm-icon="fa-times" ng-click="item.remove()" class="crm-hover-button" title="{{ts('Remove unsaved attachment')}}"></a> + </td> + </tr> + </tbody> + </table> + + <!-- + WISHLIST Improve styling of the 'Add file' / 'Browse' button + e.g. http://www.quirksmode.org/dom/inputfile.html + --> + <div> + {{ts('Add file:')}} <input type="file" nv-file-select uploader="att.uploader" multiple/><br/> + </div> + <div> + {{ts('Alternatively, you may add new files using drag/drop.')}} + </div> +</div> diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmAutosave.ang.php b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmAutosave.ang.php new file mode 100644 index 00000000..6f998782 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmAutosave.ang.php @@ -0,0 +1,10 @@ +<?php +// This file declares an Angular module which can be autoloaded +// in CiviCRM. See also: +// http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_angularModules + +return [ + 'ext' => 'civicrm', + 'js' => ['ang/crmAutosave.js'], + 'requires' => ['crmUtil'], +]; diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmAutosave.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmAutosave.js new file mode 100644 index 00000000..359ccfeb --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmAutosave.js @@ -0,0 +1,118 @@ +/// crmAutosave +(function(angular, $, _) { + + angular.module('crmAutosave', CRM.angRequires('crmAutosave')); + + // usage: + // var autosave = new CrmAutosaveCtrl({ + // save: function -- A function to handle saving. Should return a promise. + // If it's not a promise, then we'll assume that it completes successfully. + // saveIf: function -- Only allow autosave when conditional returns true. Default: !form.$invalid + // model: object|function -- (Re)schedule saves based on observed changes to object. We perform deep + // inspection on the model object. This could be a performance issue you + // had many concurrent autosave forms or a particularly large model, but + // it should be fine with typical usage. + // interval: object -- Interval spec. Default: {poll: 250, save: 5000} + // form: object|function -- FormController or its getter + // }); + // autosave.start(); + // $scope.$on('$destroy', autosave.stop); + // Note: if the save operation itself + angular.module('crmAutosave').service('CrmAutosaveCtrl', function($interval, $timeout, $q) { + function CrmAutosaveCtrl(options) { + var intervals = angular.extend({poll: 250, save: 5000}, options.interval); + var jobs = {poll: null, save: null}; // job handles used ot cancel/reschedule timeouts/intervals + var lastSeenModel = null; + var saving = false; + + // Determine if model has changed; (re)schedule the save. + // This is a bit expensive and doesn't need to be continuous, so we use polling instead of watches. + function checkChanges() { + if (saving) { + return; + } + var currentModel = _.isFunction(options.model) ? options.model() : options.model; + if (!angular.equals(currentModel, lastSeenModel)) { + lastSeenModel = angular.copy(currentModel); + if (jobs.save) { + $timeout.cancel(jobs.save); + } + jobs.save = $timeout(doAutosave, intervals.save); + } + } + + function doAutosave() { + jobs.save = null; + if (saving) { + return; + } + + var form = _.isFunction(options.form) ? options.form() : options.form; + + if (options.saveIf) { + if (!options.saveIf()) { + return; + } + } + else if (form && form.$invalid) { + return; + } + + saving = true; + lastSeenModel = angular.copy(_.isFunction(options.model) ? options.model() : options.model); + + // Set to pristine before saving -- not after saving. + // If an eager user continues editing concurrent with the + // save process, then the form should become dirty again. + if (form) { + form.$setPristine(); + } + var res = options.save(); + if (res && res.then) { + res.then( + function() { + saving = false; + }, + function() { + saving = false; + if (form) { + form.$setDirty(); + } + } + ); + } + else { + saving = false; + } + } + + var self = this; + + this.start = function() { + if (!jobs.poll) { + lastSeenModel = angular.copy(_.isFunction(options.model) ? options.model() : options.model); + jobs.poll = $interval(checkChanges, intervals.poll); + } + }; + + this.stop = function() { + if (jobs.poll) { + $interval.cancel(jobs.poll); + jobs.poll = null; + } + if (jobs.save) { + $timeout.cancel(jobs.save); + jobs.save = null; + } + }; + + this.suspend = function(p) { + self.stop(); + return p.finally(self.start); + }; + } + + return CrmAutosaveCtrl; + }); + +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCaseType.ang.php b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCaseType.ang.php new file mode 100644 index 00000000..c71e9c66 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCaseType.ang.php @@ -0,0 +1,14 @@ +<?php +// This file declares an Angular module which can be autoloaded +// in CiviCRM. See also: +// http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_angularModules + +// ODDITY: This only loads if CiviCase is active. + +return [ + 'ext' => 'civicrm', + 'js' => ['ang/crmCaseType.js'], + 'css' => ['ang/crmCaseType.css'], + 'partials' => ['ang/crmCaseType'], + 'requires' => ['ngRoute', 'ui.utils', 'crmUi', 'unsavedChanges', 'crmUtil', 'crmResource'], +]; diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCaseType.css b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCaseType.css new file mode 100644 index 00000000..6352b3d2 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCaseType.css @@ -0,0 +1,43 @@ +.crmCaseType .grip-n-drag { + vertical-align: middle; + cursor: move; +} + +.crmCaseType .fa-pencil { + margin: 0.2em 0.2em 0 0; + cursor: pointer; +} + +.crmCaseType .fa-trash { + margin: 0.56em 0.2em 0 0; + cursor: pointer; +} + +.crmCaseType .ui-tabs-nav li .crm-i { + float: left; +} + +.crmCaseType .ui-tabs-nav select { + float: right; +} + +.crmCaseType tr.addRow td { + background: #ddddff; + padding: 0.5em 1em; +} + +.crmCaseType input.number { + width: 3.5em; +} + +.crmCaseType .add-activity { + width: 50%; +} + +.crmCaseType table td select { + width: 10em; +} + +tr.forked { + font-weight: bold; +} diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCaseType.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCaseType.js new file mode 100644 index 00000000..a1ff51ae --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCaseType.js @@ -0,0 +1,648 @@ +(function(angular, $, _) { + + var crmCaseType = angular.module('crmCaseType', CRM.angRequires('crmCaseType')); + + // Note: This template will be passed to cloneDeep(), so don't put any funny stuff in here! + var newCaseTypeTemplate = { + title: "", + name: "", + is_active: "1", + weight: "1", + definition: { + activityTypes: [ + {name: 'Open Case', max_instances: 1}, + {name: 'Email'}, + {name: 'Follow up'}, + {name: 'Meeting'}, + {name: 'Phone Call'} + ], + activitySets: [ + { + name: 'standard_timeline', + label: 'Standard Timeline', + timeline: '1', // Angular won't bind checkbox correctly with numeric 1 + activityTypes: [ + {name: 'Open Case', status: 'Completed' } + ] + } + ], + caseRoles: [ + { name: 'Case Coordinator', creator: '1', manager: '1'} + ] + } + }; + + crmCaseType.config(['$routeProvider', + function($routeProvider) { + $routeProvider.when('/caseType', { + templateUrl: '~/crmCaseType/list.html', + controller: 'CaseTypeListCtrl', + resolve: { + caseTypes: function($route, crmApi) { + return crmApi('CaseType', 'get', {options: {limit: 0}}); + } + } + }); + $routeProvider.when('/caseType/:id', { + templateUrl: '~/crmCaseType/edit.html', + controller: 'CaseTypeCtrl', + resolve: { + apiCalls: function($route, crmApi) { + var reqs = {}; + reqs.actStatuses = ['OptionValue', 'get', { + option_group_id: 'activity_status', + sequential: 1, + options: {limit: 0} + }]; + reqs.caseStatuses = ['OptionValue', 'get', { + option_group_id: 'case_status', + sequential: 1, + options: {limit: 0} + }]; + reqs.actTypes = ['OptionValue', 'get', { + option_group_id: 'activity_type', + sequential: 1, + options: { + sort: 'name', + limit: 0 + } + }]; + reqs.defaultAssigneeTypes = ['OptionValue', 'get', { + option_group_id: 'activity_default_assignee', + sequential: 1, + options: { + limit: 0 + } + }]; + reqs.relTypes = ['RelationshipType', 'get', { + sequential: 1, + is_active: 1, + options: { + sort: 'label_a_b', + limit: 0 + } + }]; + if ($route.current.params.id !== 'new') { + reqs.caseType = ['CaseType', 'getsingle', { + id: $route.current.params.id + }]; + } + return crmApi(reqs); + } + } + }); + } + ]); + + // Add a new record by name. + // Ex: <crmAddName crm-options="['Alpha','Beta','Gamma']" crm-var="newItem" crm-on-add="callMyCreateFunction(newItem)" /> + crmCaseType.directive('crmAddName', function() { + return { + restrict: 'AE', + template: '<input class="add-activity crm-action-menu fa-plus" type="hidden" />', + link: function(scope, element, attrs) { + + var input = $('input', element); + + scope._resetSelection = function() { + $(input).select2('close'); + $(input).select2('val', ''); + scope[attrs.crmVar] = ''; + }; + + $(input).crmSelect2({ + data: function () { + return { results: scope[attrs.crmOptions] }; + }, + createSearchChoice: function(term) { + return {id: term, text: term + ' (' + ts('new') + ')'}; + }, + createSearchChoicePosition: 'bottom', + placeholder: attrs.placeholder + }); + $(input).on('select2-selecting', function(e) { + scope[attrs.crmVar] = e.val; + scope.$evalAsync(attrs.crmOnAdd); + scope.$evalAsync('_resetSelection()'); + e.preventDefault(); + }); + } + }; + }); + + crmCaseType.directive('crmEditableTabTitle', function($timeout) { + return { + restrict: 'AE', + link: function(scope, element, attrs) { + element.addClass('crm-editable crm-editable-enabled'); + var titleLabel = $(element).find('span'); + var penIcon = $('<i class="crm-i fa-pencil crm-editable-placeholder"></i>').prependTo(element); + var saveButton = $('<button type="button"><i class="crm-i fa-check"></i></button>').appendTo(element); + var cancelButton = $('<button type="cancel"><i class="crm-i fa-times"></i></button>').appendTo(element); + $('button', element).wrapAll('<div class="crm-editable-form" style="display:none" />'); + var buttons = $('.crm-editable-form', element); + titleLabel.on('click', startEditMode); + penIcon.on('click', startEditMode); + + function detectEscapeKeyPress (event) { + var isEscape = false; + + if ("key" in event) { + isEscape = (event.key == "Escape" || event.key == "Esc"); + } else { + isEscape = (event.keyCode == 27); + } + + return isEscape; + } + + function detectEnterKeyPress (event) { + var isEnter = false; + + if ("key" in event) { + isEnter = (event.key == "Enter"); + } else { + isEnter = (event.keyCode == 13); + } + + return isEnter; + } + + function startEditMode () { + if (titleLabel.is(":focus")) { + return; + } + + penIcon.hide(); + buttons.show(); + + saveButton.click(function () { + updateTextValue(); + stopEditMode(); + }); + + cancelButton.click(function () { + revertTextValue(); + stopEditMode(); + }); + + $(element).addClass('crm-editable-editing'); + + titleLabel + .attr("contenteditable", "true") + .focus() + .focusout(function (event) { + $timeout(function () { + revertTextValue(); + stopEditMode(); + }, 500); + }) + .keydown(function(event) { + event.stopImmediatePropagation(); + + if(detectEscapeKeyPress(event)) { + revertTextValue(); + stopEditMode(); + } else if(detectEnterKeyPress(event)) { + event.preventDefault(); + updateTextValue(); + stopEditMode(); + } + }); + } + + function stopEditMode () { + titleLabel.removeAttr("contenteditable").off("focusout"); + titleLabel.off("keydown"); + saveButton.off("click"); + cancelButton.off("click"); + $(element).removeClass('crm-editable-editing'); + + penIcon.show(); + buttons.hide(); + } + + function revertTextValue () { + titleLabel.text(scope.activitySet.label); + } + + function updateTextValue () { + var updatedTitle = titleLabel.text(); + + scope.$evalAsync(function () { + scope.activitySet.label = updatedTitle; + }); + } + } + }; + }); + + crmCaseType.controller('CaseTypeCtrl', function($scope, crmApi, apiCalls, crmUiHelp) { + var defaultAssigneeDefaultValue, ts; + + (function init () { + + ts = $scope.ts = CRM.ts(null); + $scope.hs = crmUiHelp({file: 'CRM/Case/CaseType'}); + $scope.locks = { caseTypeName: true, activitySetName: true }; + $scope.workflows = { timeline: 'Timeline', sequence: 'Sequence' }; + defaultAssigneeDefaultValue = _.find(apiCalls.defaultAssigneeTypes.values, { is_default: '1' }) || {}; + + storeApiCallsResults(); + initCaseType(); + initCaseTypeDefinition(); + initSelectedStatuses(); + })(); + + /// Stores the api calls results in the $scope object + function storeApiCallsResults() { + $scope.activityStatuses = apiCalls.actStatuses.values; + $scope.caseStatuses = _.indexBy(apiCalls.caseStatuses.values, 'name'); + $scope.activityTypes = _.indexBy(apiCalls.actTypes.values, 'name'); + $scope.activityTypeOptions = _.map(apiCalls.actTypes.values, formatActivityTypeOption); + $scope.defaultAssigneeTypes = apiCalls.defaultAssigneeTypes.values; + $scope.relationshipTypeOptions = getRelationshipTypeOptions(false); + $scope.defaultRelationshipTypeOptions = getRelationshipTypeOptions(true); + // stores the default assignee values indexed by their option name: + $scope.defaultAssigneeTypeValues = _.chain($scope.defaultAssigneeTypes) + .indexBy('name').mapValues('value').value(); + } + + // Returns the relationship type options. If the relationship is + // bidirectional (Ex: Spouse of) it adds a single option otherwise it adds + // two options representing the relationship type directions (Ex: Employee + // of, Employer of). + // + // The default relationship field needs values that are IDs with direction, + // while the role field needs values that are names (with implicit + // direction). + // + // At any rate, the labels should follow the convention in the UI of + // describing case roles from the perspective of the client, while the + // values must follow the convention in the XML of describing case roles + // from the perspective of the non-client. + function getRelationshipTypeOptions($isDefault) { + return _.transform(apiCalls.relTypes.values, function(result, relType) { + var isBidirectionalRelationship = relType.label_a_b === relType.label_b_a; + if ($isDefault) { + result.push({ + label: relType.label_b_a, + value: relType.id + '_a_b' + }); + + if (!isBidirectionalRelationship) { + result.push({ + label: relType.label_a_b, + value: relType.id + '_b_a' + }); + } + } + // TODO The ids below really should use names not labels see + // https://lab.civicrm.org/dev/core/issues/774 + else { + result.push({ + text: relType.label_b_a, + id: relType.label_a_b + }); + + if (!isBidirectionalRelationship) { + result.push({ + text: relType.label_a_b, + id: relType.label_b_a + }); + } + } + }, []); + } + + /// initializes the case type object + function initCaseType() { + var isNewCaseType = !apiCalls.caseType; + + if (isNewCaseType) { + $scope.caseType = _.cloneDeep(newCaseTypeTemplate); + } else { + $scope.caseType = apiCalls.caseType; + } + } + + /// initializes the case type definition object + function initCaseTypeDefinition() { + $scope.caseType.definition = $scope.caseType.definition || []; + $scope.caseType.definition.activityTypes = $scope.caseType.definition.activityTypes || []; + $scope.caseType.definition.activitySets = $scope.caseType.definition.activitySets || []; + $scope.caseType.definition.caseRoles = $scope.caseType.definition.caseRoles || []; + $scope.caseType.definition.statuses = $scope.caseType.definition.statuses || []; + $scope.caseType.definition.timelineActivityTypes = $scope.caseType.definition.timelineActivityTypes || []; + $scope.caseType.definition.restrictActivityAsgmtToCmsUser = $scope.caseType.definition.restrictActivityAsgmtToCmsUser || 0; + $scope.caseType.definition.activityAsgmtGrps = $scope.caseType.definition.activityAsgmtGrps || []; + + _.each($scope.caseType.definition.activitySets, function (set) { + _.each(set.activityTypes, function (type, name) { + var isDefaultAssigneeTypeUndefined = _.isUndefined(type.default_assignee_type); + var typeDefinition = $scope.activityTypes[type.name]; + type.label = (typeDefinition && typeDefinition.label) || type.name; + + if (isDefaultAssigneeTypeUndefined) { + type.default_assignee_type = defaultAssigneeDefaultValue.value; + } + }); + }); + + // go lookup and add client-perspective labels for $scope.caseType.definition.caseRoles + _.each($scope.caseType.definition.caseRoles, function (set) { + _.each($scope.relationshipTypeOptions, function (relTypes) { + if (relTypes.text == set.name) { + set.displaylabel = relTypes.id; + } + }); + }); + } + + /// initializes the selected statuses + function initSelectedStatuses() { + $scope.selectedStatuses = {}; + + _.each(apiCalls.caseStatuses.values, function (status) { + $scope.selectedStatuses[status.name] = !$scope.caseType.definition.statuses.length || $scope.caseType.definition.statuses.indexOf(status.name) > -1; + }); + } + + $scope.addActivitySet = function(workflow) { + var activitySet = {}; + activitySet[workflow] = '1'; + activitySet.activityTypes = []; + + var offset = 1; + var names = _.pluck($scope.caseType.definition.activitySets, 'name'); + while (_.contains(names, workflow + '_' + offset)) offset++; + activitySet.name = workflow + '_' + offset; + activitySet.label = (offset == 1 ) ? $scope.workflows[workflow] : ($scope.workflows[workflow] + ' #' + offset); + + $scope.caseType.definition.activitySets.push(activitySet); + _.defer(function() { + $('.crmCaseType-acttab').tabs('refresh').tabs({active: -1}); + }); + }; + + function formatActivityTypeOption(type) { + return {id: type.name, text: type.label, icon: type.icon}; + } + + function addActivityToSet(activitySet, activityTypeName) { + activitySet.activityTypes = activitySet.activityTypes || []; + var activity = { + name: activityTypeName, + label: $scope.activityTypes[activityTypeName].label, + status: 'Scheduled', + reference_activity: 'Open Case', + reference_offset: '1', + reference_select: 'newest', + default_assignee_type: $scope.defaultAssigneeTypeValues.NONE + }; + activitySet.activityTypes.push(activity); + if(typeof activitySet.timeline !== "undefined" && activitySet.timeline == "1") { + $scope.caseType.definition.timelineActivityTypes.push(activity); + } + } + + function resetTimelineActivityTypes() { + $scope.caseType.definition.timelineActivityTypes = []; + angular.forEach($scope.caseType.definition.activitySets, function(activitySet) { + angular.forEach(activitySet.activityTypes, function(activityType) { + $scope.caseType.definition.timelineActivityTypes.push(activityType); + }); + }); + } + + function createActivity(name, callback) { + CRM.loadForm(CRM.url('civicrm/admin/options/activity_type', {action: 'add', reset: 1, label: name, component_id: 7})) + .on('crmFormSuccess', function(e, data) { + $scope.activityTypes[data.optionValue.name] = data.optionValue; + $scope.activityTypeOptions.push(formatActivityTypeOption(data.optionValue)); + callback(data.optionValue); + $scope.$digest(); + }); + } + + // Add a new activity entry to an activity-set + $scope.addActivity = function(activitySet, activityType) { + if ($scope.activityTypes[activityType]) { + addActivityToSet(activitySet, activityType); + } else { + createActivity(activityType, function(newActivity) { + addActivityToSet(activitySet, newActivity.name); + }); + } + }; + + /// Add a new top-level activity-type entry + $scope.addActivityType = function(activityType) { + var names = _.pluck($scope.caseType.definition.activityTypes, 'name'); + if (!_.contains(names, activityType)) { + // Add an activity type that exists + if ($scope.activityTypes[activityType]) { + $scope.caseType.definition.activityTypes.push({name: activityType}); + } else { + createActivity(activityType, function(newActivity) { + $scope.caseType.definition.activityTypes.push({name: newActivity.name}); + }); + } + } + }; + + /// Clears the activity's default assignee values for relationship and contact + $scope.clearActivityDefaultAssigneeValues = function(activity) { + activity.default_assignee_relationship = null; + activity.default_assignee_contact = null; + }; + + // TODO roleName passed to addRole is a misnomer, its passed as the + // label HOWEVER it should be saved to xml as the name see + // https://lab.civicrm.org/dev/core/issues/774 + + /// Add a new role + $scope.addRole = function(roles, roleName) { + var names = _.pluck($scope.caseType.definition.caseRoles, 'name'); + if (!_.contains(names, roleName)) { + var matchingRoles = _.filter($scope.relationshipTypeOptions, {id: roleName}); + if (matchingRoles.length) { + var matchingRole = matchingRoles.shift(); + roles.push({name: roleName, displaylabel: matchingRole.text}); + } else { + CRM.loadForm(CRM.url('civicrm/admin/reltype', {action: 'add', reset: 1, label_a_b: roleName})) + .on('crmFormSuccess', function(e, data) { + var newType = _.values(data.relationshipType)[0]; + roles.push({name: newType.label_b_a, displaylabel: newType.label_a_b}); + // Assume that the case role should be A-B but add both directions as options. + $scope.relationshipTypeOptions.push({id: newType.label_a_b, text: newType.label_a_b}); + if (newType.label_a_b != newType.label_b_a) { + $scope.relationshipTypeOptions.push({id: newType.label_b_a, text: newType.label_b_a}); + } + $scope.$digest(); + }); + } + } + }; + + $scope.onManagerChange = function(managerRole) { + angular.forEach($scope.caseType.definition.caseRoles, function(caseRole) { + if (caseRole != managerRole) { + caseRole.manager = '0'; + } + }); + }; + + $scope.removeItem = function(array, item) { + var idx = _.indexOf(array, item); + if (idx != -1) { + array.splice(idx, 1); + resetTimelineActivityTypes(); + } + }; + + $scope.isForkable = function() { + return !$scope.caseType.id || $scope.caseType.is_forkable; + }; + + $scope.newStatus = function() { + CRM.loadForm(CRM.url('civicrm/admin/options/case_status', {action: 'add', reset: 1})) + .on('crmFormSuccess', function(e, data) { + $scope.caseStatuses[data.optionValue.name] = data.optionValue; + $scope.selectedStatuses[data.optionValue.name] = true; + $scope.$digest(); + }); + }; + + $scope.isNewActivitySetAllowed = function(workflow) { + switch (workflow) { + case 'timeline': + return true; + case 'sequence': + return 0 === _.where($scope.caseType.definition.activitySets, {sequence: '1'}).length; + default: + CRM.console('warn', 'Denied access to unrecognized workflow: (' + workflow + ')'); + return false; + } + }; + + $scope.isActivityRemovable = function(activitySet, activity) { + return true; + }; + + $scope.isValidName = function(name) { + return !name || name.match(/^[a-zA-Z0-9_]+$/); + }; + + $scope.getWorkflowName = function(activitySet) { + var result = 'Unknown'; + _.each($scope.workflows, function(value, key) { + if (activitySet[key]) result = value; + }); + return result; + }; + + /** + * Determine which HTML partial to use for a particular + * + * @return string URL of the HTML partial + */ + $scope.activityTableTemplate = function(activitySet) { + if (activitySet.timeline) { + return '~/crmCaseType/timelineTable.html'; + } else if (activitySet.sequence) { + return '~/crmCaseType/sequenceTable.html'; + } else { + return ''; + } + }; + + $scope.dump = function() { + console.log($scope.caseType); + }; + + $scope.save = function() { + // Add selected statuses + var selectedStatuses = []; + _.each($scope.selectedStatuses, function(v, k) { + if (v) selectedStatuses.push(k); + }); + // Ignore if ALL or NONE selected + $scope.caseType.definition.statuses = selectedStatuses.length == _.size($scope.selectedStatuses) ? [] : selectedStatuses; + + if ($scope.caseType.definition.activityAsgmtGrps) { + $scope.caseType.definition.activityAsgmtGrps = $scope.caseType.definition.activityAsgmtGrps.toString().split(","); + } + + function dropDisplaylabel (v) { + delete v.displaylabel; + } + + // strip out labels from $scope.caseType.definition.caseRoles + _.map($scope.caseType.definition.caseRoles, dropDisplaylabel); + + var result = crmApi('CaseType', 'create', $scope.caseType, true); + result.then(function(data) { + if (data.is_error === 0 || data.is_error == '0') { + $scope.caseType.id = data.id; + window.location.href = '#/caseType'; + } + }); + }; + + $scope.$watchCollection('caseType.definition.activitySets', function() { + _.defer(function() { + $('.crmCaseType-acttab').tabs('refresh'); + }); + }); + + var updateCaseTypeName = function () { + if (!$scope.caseType.id && $scope.locks.caseTypeName) { + // Should we do some filtering? Lowercase? Strip whitespace? + var t = $scope.caseType.title ? $scope.caseType.title : ''; + $scope.caseType.name = t.replace(/ /g, '_').replace(/[^a-zA-Z0-9_]/g, '').toLowerCase(); + } + }; + $scope.$watch('locks.caseTypeName', updateCaseTypeName); + $scope.$watch('caseType.title', updateCaseTypeName); + + if (!$scope.isForkable()) { + CRM.alert(ts('The CiviCase XML file for this case-type prohibits editing the definition.')); + } + + }); + + crmCaseType.controller('CaseTypeListCtrl', function($scope, crmApi, caseTypes) { + var ts = $scope.ts = CRM.ts(null); + + $scope.caseTypes = caseTypes.values; + $scope.toggleCaseType = function (caseType) { + caseType.is_active = (caseType.is_active == '1') ? '0' : '1'; + crmApi('CaseType', 'create', caseType, true) + .catch(function (data) { + caseType.is_active = (caseType.is_active == '1') ? '0' : '1'; // revert + $scope.$digest(); + }); + }; + $scope.deleteCaseType = function (caseType) { + crmApi('CaseType', 'delete', {id: caseType.id}, { + error: function (data) { + CRM.alert(data.error_message, ts('Error'), 'error'); + } + }) + .then(function (data) { + delete caseTypes.values[caseType.id]; + }); + }; + $scope.revertCaseType = function (caseType) { + caseType.definition = 'null'; + caseType.is_forked = '0'; + crmApi('CaseType', 'create', caseType, true) + .catch(function (data) { + caseType.is_forked = '1'; // restore + $scope.$digest(); + }); + }; + }); + +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCaseType/activityTypesTable.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCaseType/activityTypesTable.html new file mode 100644 index 00000000..a324f895 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCaseType/activityTypesTable.html @@ -0,0 +1,46 @@ +<!-- +Controller: CaseTypeCtrl +Required vars: caseType +--> +<table class="row-highlight"> + <thead> + <tr> + <th></th> + <th>{{ts('Activity Type')}}</th> + <th>{{ts('Max Instances')}}</th> + <th></th> + </tr> + </thead> + + <tbody ui-sortable ng-model="caseType.definition.activityTypes"> + <tr ng-repeat="activityType in caseType.definition.activityTypes"> + <td> + <i class="crm-i fa-arrows grip-n-drag"></i> + </td> + <td> + <i class="crm-i {{ activityTypes[activityType.name].icon }}"></i> + {{ activityType.name }} + </td> + <td> + <input class="crm-form-text number" type="text" ng-pattern="/^[1-9][0-9]*$/" ng-model="activityType.max_instances"> + </td> + <td> + <a crm-icon="fa-trash" class="crm-hover-button" ng-click="removeItem(caseType.definition.activityTypes, activityType)" title="{{ts('Remove')}}"></a> + </td> + </tr> + </tbody> + + <tfoot> + <tr class="addRow"> + <td></td> + <td colspan="3"> + <span crm-add-name + crm-options="activityTypeOptions" + crm-var="newActivity" + crm-on-add="addActivityType(newActivity)" + placeholder="{{ts('Add activity type')}}" + ></span> + </td> + </tr> + </tfoot> +</table> diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCaseType/caseTypeDetails.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCaseType/caseTypeDetails.html new file mode 100644 index 00000000..d11cc913 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCaseType/caseTypeDetails.html @@ -0,0 +1,67 @@ +<!-- +Controller: CaseTypeCtrl +Required vars: caseType + +The original form used table layout; don't know if we have an alternative, CSS-based layout +--> +<div class="crm-block" ng-form="caseTypeDetailForm" crm-ui-id-scope> + <div class="crm-group"> + <div crm-ui-field="{name: 'caseTypeDetailForm.title', title: ts('Title')}"> + <input + crm-ui-id="caseTypeDetailForm.title" + type="text" + name="title" + ng-model="caseType.title" + class="big crm-form-text" + required + /> + </div> + <div crm-ui-field="{name: 'caseTypeDetailForm.caseTypeName', title: ts('Name')}"> + <input + crm-ui-id="caseTypeDetailForm.caseTypeName" + type="text" + name="caseTypeName" + ng-model="caseType.name" + ng-disabled="locks.caseTypeName" + required + class="big crm-form-text"/> + + <a crm-ui-lock binding="locks.caseTypeName"></a> + + <div ng-show="!isValidName(caseType.name)"> + <em>{{ts('WARNING: The case type name includes deprecated characters.')}}</em> + </div> + <div ng-show="caseType.id && !locks.caseTypeName"> + <em>{{ts('WARNING: If any external files or programs reference the old "Name", then they must be updated manually.')}}</em> + </div> + </div> + <div crm-ui-field="{name: 'caseTypeDetailForm.description', title: ts('Description')}"> + <textarea crm-ui-id="caseTypeDetailForm.description" name="description" ng-model="caseType.description" class="big crm-form-textarea"></textarea> + </div> + <div crm-ui-field="{title: ts('Enabled?')}"> + <input name="is_active" type="checkbox" ng-model="caseType.is_active" ng-true-value="'1'" ng-false-value="'0'"/> + </div> + <fieldset class="crm-collapsible"> + <legend class="collapsible-title">{{ ts('Activity assignment settings') }}</legend> + <div> + <div crm-ui-field="{name: 'caseTypeDetailForm.activityAsgmtGrps', title: ts('Restrict to Groups'), help: hs('activityAsgmtGrps')}"> + <input + name="activityAsgmtGrps" + crm-ui-id="caseTypeDetailForm.activityAsgmtGrps" + crm-entityref="{entity: 'Group', api: {params: {is_hidden: 0, is_active: 1}}, select: {allowClear: true, multiple: true, placeholder: ts('Select Group')}}" + ng-model="caseType.definition.activityAsgmtGrps" + /> + </div> + <div crm-ui-field="{title: ts('Restrict to Website Users'), help: hs('restrictActivityAsgmtToCmsUser')}"> + <input + name="restrictActivityAsgmtToCmsUser" + type="checkbox" + ng-model="caseType.definition.restrictActivityAsgmtToCmsUser" + ng-true-value="'1'" + ng-false-value="'0'" + /> + </div> + </div> + </fieldset> + </div> +</div> diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCaseType/edit.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCaseType/edit.html new file mode 100644 index 00000000..55c7faf4 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCaseType/edit.html @@ -0,0 +1,63 @@ +<!-- +Controller: CaseTypeCtrl +Required vars: caseType +--> +<h1 crm-page-title>{{caseType.title || ts('New Case Type')}}</h1> + +<div class="help"> + {{ts('Use this screen to define or update the Case Roles, Activity Types, and Timelines for a case type.')}} <a href="https://docs.civicrm.org/user/en/stable/case-management/set-up/" target="_blank">{{ts('Learn more...')}}</a> +</div> + +<form name="editCaseTypeForm" unsaved-warning-form> +<div class="crm-block crm-form-block crmCaseType"> + + <div ng-include="'~/crmCaseType/caseTypeDetails.html'"></div> + + <div ng-show="isForkable()" class="crmCaseType-acttab" ui-jq="tabs" ui-options="{show: true, hide: true}"> + <ul> + <li><a href="#acttab-roles">{{ts('Case Roles')}}</a></li> + <li><a href="#acttab-statuses">{{ts('Case Statuses')}}</a></li> + <li><a href="#acttab-actType">{{ts('Activity Types')}}</a></li> + <li ng-repeat="activitySet in caseType.definition.activitySets"> + <a href="#acttab-{{$index}}" class="crmCaseType-editable"> + <div crm-editable-tab-title title="{{ts('Click to edit')}}"> + <span>{{ activitySet.label }}</span> + </div> + </a> + <span class="crm-i fa-trash" title="{{ts('Remove')}}" + ng-hide="activitySet.name == 'standard_timeline'" + ng-click="removeItem(caseType.definition.activitySets, activitySet)"></span> + <!-- Weird spacing: + <a class="crm-hover-button" ng-click="removeItem(caseType.definition.activitySets, activitySet)"> + <span class="crm-i fa-trash" title="Remove">Remove</span> + </a> + --> + </li> + <select class="crm-form-select" ng-model="newActivitySetWorkflow" ng-change="addActivitySet(newActivitySetWorkflow); newActivitySetWorkflow='';"> + <option value="">{{ts('Add...')}}</option> + <option value="timeline" ng-show="isNewActivitySetAllowed('timeline')">{{ts('Timeline')}}</option> + <option value="sequence" ng-show="isNewActivitySetAllowed('sequence')">{{ts('Sequence')}}</option> + </select> + </ul> + + <div id="acttab-roles" ng-include="'~/crmCaseType/rolesTable.html'"></div> + + <div id="acttab-actType" ng-include="'~/crmCaseType/activityTypesTable.html'"></div> + + <div id="acttab-statuses" ng-include="'~/crmCaseType/statusTable.html'"></div> + + <div ng-repeat="activitySet in caseType.definition.activitySets" id="acttab-{{$index}}"> + <div ng-include="activityTableTemplate(activitySet)"></div> + </div> + </div> + + <div class="crm-submit-buttons"> + <button crm-icon="fa-check" ng-click="editCaseTypeForm.$setPristine(); save()" ng-disabled="editCaseTypeForm.$invalid"> + {{ts('Save')}} + </button> + <button crm-icon="fa-times" ng-click="editCaseTypeForm.$setPristine(); goto('caseType')"> + {{ts('Cancel')}} + </button> + </div> +</div> +</form> diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCaseType/list.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCaseType/list.html new file mode 100644 index 00000000..a9caecc3 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCaseType/list.html @@ -0,0 +1,78 @@ +<!-- +Controller: CaseTypeListsCtrl +Required vars: caseTypes +--> +<h1 crm-page-title>{{ts('Case Types')}}</h1> + +<div class="help"> + {{ts('A Case Type describes a group of related tasks, interactions, or processes.')}} +</div> + +<div class="crm-content-block crm-block"> + + <table class="display"> + <thead> + <tr> + <th>{{ts('Title')}}</th> + <th>{{ts('Name')}}</th> + <th>{{ts('Description')}}</th> + <th>{{ts('Enabled?')}}</th> + <th></th> + </tr> + </thead> + <tbody> + <tr ng-repeat="caseType in caseTypes" + class="crm-entity" + ng-class-even="'even-row even'" + ng-class-odd="'odd-row odd'" + ng-class="{disabled: 0==caseType.is_active, forked: 1==caseType.is_forked}"> + <td>{{caseType.title}}</td> + <td>{{caseType.name}}</td> + <td>{{caseType.description}}</td> + <td>{{caseType.is_active == 1 ? ts('Yes') : ts('No')}}</td> + <!-- FIXME: Can't figure out how styling in other tables gets the nowrap effect... in absence of a consistent fix, KISS --> + <td style="white-space: nowrap"> + <span> + <a class="action-item crm-hover-button" ng-href="#/caseType/{{caseType.id}}">{{ts('Edit')}}</a> + + <span class="btn-slide crm-hover-button" ng-show="!caseType.is_reserved || (!caseType.is_active || caseType.is_forked)"> + {{ts('more')}} + <ul class="panel" style="display: none;"> + <li ng-hide="caseType.is_active"> + <a class="action-item crm-hover-button" ng-click="toggleCaseType(caseType)"> + {{ts('Enable')}} + </a> + </li> + <li ng-show="caseType.is_active && !caseType.is_reserved"> + <a class="action-item crm-hover-button" + crm-confirm="{type: 'disable', obj: caseType}" + on-yes="toggleCaseType(caseType)"> + {{ts('Disable')}} + </a> + </li> + <li ng-show="caseType.is_forked"> + <a class="action-item crm-hover-button" + crm-confirm="{type: 'revert', obj: caseType}" + on-yes="revertCaseType(caseType)"> + {{ts('Revert')}} + </a> + </li> + <li ng-show="!caseType.is_reserved"> + <a class="action-item crm-hover-button" + crm-confirm="{type: 'delete', obj: caseType}" + on-yes="deleteCaseType(caseType)"> + {{ts('Delete')}} + </a> + </li> + </ul> + </span> + </span> + </td> + </tr> + </tbody> + </table> + + <div class="crm-submit-buttons"> + <a ng-href="#/caseType/new" class="button"><span><i class="crm-i fa-plus-circle"></i> {{ts('New Case Type')}}</span></a> + </div> +</div> diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCaseType/rolesTable.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCaseType/rolesTable.html new file mode 100644 index 00000000..e7edee07 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCaseType/rolesTable.html @@ -0,0 +1,38 @@ +<!-- +Controller: CaseTypeCtrl +Required vars: caseType +--> +<table> + <thead> + <tr> + <th>{{ts('Name')}}</th> + <th>{{ts('Assign to Creator')}}</th> + <th>{{ts('Is Manager')}}</th> + <th></th> + </tr> + </thead> + <tbody> + <tr ng-repeat="relType in caseType.definition.caseRoles | orderBy:'name'" ng-class-even="'crm-entity even-row even'" ng-class-odd="'crm-entity odd-row odd'"> + <!-- display label (client-perspective) --> + <td>{{relType.displaylabel}}</td> + <td><input type="checkbox" ng-model="relType.creator" ng-true-value="'1'" ng-false-value="'0'"></td> + <td><input type="radio" ng-model="relType.manager" value="1" ng-change="onManagerChange(relType)"></td> + <td> + <a crm-icon="fa-trash" class="crm-hover-button" ng-click="removeItem(caseType.definition.caseRoles,relType)" title="{{ts('Remove')}}"></a> + </td> + </tr> + </tbody> + + <tfoot> + <tr class="addRow"> + <td colspan="4"> + <span crm-add-name + crm-options="relationshipTypeOptions" + crm-var="newRole" + crm-on-add="addRole(caseType.definition.caseRoles, newRole)" + placeholder="{{ts('Add role')}}" + ></span> + </td> + </tr> + </tfoot> +</table> diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCaseType/sequenceTable.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCaseType/sequenceTable.html new file mode 100644 index 00000000..e07a11bd --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCaseType/sequenceTable.html @@ -0,0 +1,41 @@ +<!-- +Controller: CaseTypeCtrl +Required vars: activitySet +--> +<table> + <thead> + <tr> + <th></th> + <th>{{ts('Activity')}}</th> + <th></th> + </tr> + </thead> + + <tbody ui-sortable ng-model="activitySet.activityTypes"> + <tr ng-repeat="activity in activitySet.activityTypes"> + <td> + <i class="crm-i fa-arrows grip-n-drag"></i> + </td> + <td> + <i class="crm-i {{ activityTypes[activity.name].icon }}"></i> + {{ activity.name }} + </td> + <td> + <a crm-icon="fa-trash" class="crm-hover-button" ng-click="removeItem(activitySet.activityTypes, activity)" title="{{ts('Remove')}}"></a> + </td> + </tr> + </tbody> + + <tfoot> + <tr class="addRow"> + <td colspan="3"> + <span crm-add-name + crm-options="activityTypeOptions" + crm-var="newActivity" + crm-on-add="addActivity(activitySet, newActivity)" + placeholder="{{ts('Add activity')}}" + ></span> + </td> + </tr> + </tfoot> +</table> diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCaseType/statusTable.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCaseType/statusTable.html new file mode 100644 index 00000000..890989a3 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCaseType/statusTable.html @@ -0,0 +1,35 @@ +<!-- +Controller: CaseTypeCtrl +Required vars: selectedStatuses +--> +<table> + <thead> + <tr> + <th></th> + <th>{{ts('Name')}}</th> + <th>{{ts('Class')}}</th> + </tr> + </thead> + + <tbody ng-model="selectedStatuses"> + <tr ng-repeat="(status,sel) in selectedStatuses"> + <td> + <input class="crm-form-checkbox" type="checkbox" ng-model="selectedStatuses[status]"/> + </td> + <td> + {{ caseStatuses[status].label }} + </td> + <td> + {{ caseStatuses[status].grouping }} + </td> + </tr> + </tbody> + + <tfoot> + <tr> + <td></td> + <td><a class="crm-hover-button action-item" ng-click="newStatus()" href><i class="crm-i fa-plus"></i> {{ ts('New Status') }}</a></td> + <td></td> + </tr> + </tfoot> +</table> diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCaseType/timelineTable.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCaseType/timelineTable.html new file mode 100644 index 00000000..4d044f1b --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCaseType/timelineTable.html @@ -0,0 +1,117 @@ +<!-- +Controller: CaseTypeCtrl +Required vars: activitySet +--> +<table> + <thead> + <tr> + <th></th> + <th>{{ts('Activity')}}</th> + <th>{{ts('Status')}}</th> + <th>{{ts('Reference')}}</th> + <th>{{ts('Offset')}}</th> + <th>{{ts('Select')}}</th> + <th>{{ts('Default assignee')}}</th> + <th></th> + </tr> + </thead> + + <tbody ui-sortable ng-model="activitySet.activityTypes"> + <tr ng-repeat="activity in activitySet.activityTypes"> + <td> + <i class="crm-i fa-arrows grip-n-drag"></i> + </td> + <td> + <i class="crm-i {{activityTypes[activity.name].icon}}"></i> + {{activity.label}} + </td> + <td> + <select + ui-jq="select2" + ui-options="{dropdownAutoWidth: true}" + ng-model="activity.status" + ng-options="actStatus.name as actStatus.label for actStatus in activityStatuses|orderBy:'label'" + > + <option value=""></option> + </select> + </td> + <td> + <select + ui-jq="select2" + ui-options="{dropdownAutoWidth: true}" + ng-model="activity.reference_activity" + ng-options="activityType.name as activityType.label for activityType in caseType.definition.timelineActivityTypes" + > + <option value="">-- Case Start --</option> + </select> + </td> + <td> + <input + class="number crm-form-text" + type="text" + ng-pattern="/^-?[0-9]*$/" + ng-model="activity.reference_offset" + > + </td> + <td> + <select + ui-jq="select2" + ui-options="{dropdownAutoWidth: true}" + ng-model="activity.reference_select" + ng-options="key as value for (key,value) in {newest: ts('Newest'), oldest: ts('Oldest')}" + > + </select> + </td> + <td> + <select + ui-jq="select2" + ui-options="{dropdownAutoWidth: true}" + ng-model="activity.default_assignee_type" + ng-options="option.value as option.label for option in defaultAssigneeTypes" + ng-change="clearActivityDefaultAssigneeValues(activity)" + ></select> + + <p ng-if="activity.default_assignee_type === defaultAssigneeTypeValues.BY_RELATIONSHIP"> + <select + ui-jq="select2" + ui-options="{dropdownAutoWidth: true}" + ng-model="activity.default_assignee_relationship" + ng-options="option.value as option.label for option in defaultRelationshipTypeOptions" + required + ></select> + </p> + + <p ng-if="activity.default_assignee_type === defaultAssigneeTypeValues.SPECIFIC_CONTACT"> + <input + type="text" + ng-model="activity.default_assignee_contact" + placeholder="- Select contact -" + crm-entityref="{ entity: 'Contact' }" + data-create-links="true" + required /> + </p> + </td> + <td> + <a class="crm-hover-button" + crm-icon="fa-trash" + ng-show="isActivityRemovable(activitySet, activity)" + ng-click="removeItem(activitySet.activityTypes, activity)" + title="{{ts('Remove')}}"> + </a> + </td> + </tr> + </tbody> + + <tfoot> + <tr class="addRow"> + <td colspan="8"> + <span crm-add-name="" + crm-options="activityTypeOptions" + crm-var="newActivity" + crm-on-add="addActivity(activitySet, newActivity)" + placeholder="{{ts('Add activity')}}" + ></span> + </td> + </tr> + </tfoot> +</table> diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn.ang.php b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn.ang.php new file mode 100644 index 00000000..ff267991 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn.ang.php @@ -0,0 +1,12 @@ +<?php +// This file declares an Angular module which can be autoloaded +// in CiviCRM. See also: +// http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_angularModules + +return [ + 'ext' => 'civicrm', + 'js' => ['ang/crmCxn.js', 'ang/crmCxn/*.js'], + 'css' => ['ang/crmCxn.css'], + 'partials' => ['ang/crmCxn'], + 'requires' => ['crmUtil', 'ngRoute', 'ngSanitize', 'ui.utils', 'crmUi', 'dialogService', 'crmResource'], +]; diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn.css b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn.css new file mode 100644 index 00000000..11aeb14e --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn.css @@ -0,0 +1,3 @@ +.crmCxn-footer { + text-align: center; +} diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn.js new file mode 100644 index 00000000..244d038f --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn.js @@ -0,0 +1,25 @@ +(function (angular, $, _) { + + angular.module('crmCxn', CRM.angRequires('crmCxn')); + + angular.module('crmCxn').config([ + '$routeProvider', + function ($routeProvider) { + $routeProvider.when('/cxn', { + templateUrl: '~/crmCxn/ManageCtrl.html', + controller: 'CrmCxnManageCtrl', + resolve: { + apiCalls: function(crmApi){ + var reqs = {}; + reqs.cxns = ['Cxn', 'get', {sequential: 1}]; + reqs.appMetas = ['CxnApp', 'get', {sequential: 1, return: ['id', 'title', 'desc', 'appId', 'appUrl', 'links', 'perm']}]; + reqs.cfg = ['Cxn', 'getcfg', {}]; + reqs.sysCheck = ['System', 'check', {}]; // FIXME: filter on checkCxnOverrides + return crmApi(reqs); + } + } + }); + } + ]); + +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn/AboutCtrl.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn/AboutCtrl.html new file mode 100644 index 00000000..8757449d --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn/AboutCtrl.html @@ -0,0 +1,14 @@ +<div ng-controller="CrmCxnConfirmAboutCtrl"> + <div crm-ui-accordion="{title: ts('About'), collapsed: false}"> + <div ng-bind-html="appMeta.desc"></div> + </div> + <div crm-ui-accordion="{title: ts('Permissions: Summary'), collapsed: true}"> + <div ng-bind-html="appMeta.perm.desc"></div> + </div> + <div crm-ui-accordion="{title: ts('Permissions: Details'), collapsed: true}"> + <div crm-cxn-perm-table="{perm: appMeta.perm}"></div> + </div> + <div crm-ui-accordion="{title: ts('Advanced'), collapsed: true}"> + <div crm-cxn-adv-table="{appMeta: appMeta}"></div> + </div> +</div> diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn/AboutCtrl.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn/AboutCtrl.js new file mode 100644 index 00000000..f9b9491e --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn/AboutCtrl.js @@ -0,0 +1,5 @@ +(function(angular, $, _) { + angular.module('crmCxn').controller('CrmCxnConfirmAboutCtrl', function($scope) { + $scope.ts = CRM.ts(null); + }); +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn/AdvTable.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn/AdvTable.html new file mode 100644 index 00000000..7080fdd7 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn/AdvTable.html @@ -0,0 +1,18 @@ +<table> + <thead> + <tr> + <th>{{ts('Property')}}</th> + <th>{{ts('Value')}}</th> + </tr> + </thead> + <tbody> + <tr class="odd-row odd"> + <td>App ID</td> + <td>{{appMeta.appId}}</td> + </tr> + <tr class="even-row even"> + <td>App URL</td> + <td><code>{{appMeta.appUrl}}</code></td> + </tr> + </tbody> +</table> diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn/AdvTable.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn/AdvTable.js new file mode 100644 index 00000000..3ea2bc39 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn/AdvTable.js @@ -0,0 +1,20 @@ +(function(angular, $, _) { + + // This directive formats the data in appMeta as a nice table. + // example: <div crm-cxn-perm-table="{appMeta: cxn.app_meta}"></div> + angular.module('crmCxn').directive('crmCxnAdvTable', function crmCxnAdvTable() { + return { + restrict: 'EA', + scope: { + crmCxnAdvTable: '=' + }, + templateUrl: '~/crmCxn/AdvTable.html', + link: function(scope, element, attrs) { + scope.ts = CRM.ts(null); + scope.$watch('crmCxnAdvTable', function(crmCxnAdvTable){ + scope.appMeta = crmCxnAdvTable.appMeta; + }); + } + }; + }); +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn/CheckAddress.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn/CheckAddress.js new file mode 100644 index 00000000..2313ddd0 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn/CheckAddress.js @@ -0,0 +1,33 @@ +(function (angular, $, _) { + + angular.module('crmCxn').factory('crmCxnCheckAddr', function($q, $timeout) { + var TIMEOUT = 6000, CHECK_ADDR = 'https://mycivi.org/check-addr'; + return function(url) { + var dfr = $q.defer(), result = null; + + function onErr() { + if (result !== null) return; + result = {url: url, valid: false}; + dfr.resolve(result); + } + + $.ajax({ + url: CHECK_ADDR, + data: {url: url}, + jsonp: "callback", + dataType: "jsonp" + }).fail(onErr) + .done(function(response) { + if (result !== null) return; + result = {url: url, valid: response.result}; + dfr.resolve(result); + } + ); + // JSONP may not provide errors directly. + $timeout(onErr, TIMEOUT); + + return dfr.promise; + }; + }); + +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn/ConfirmConnectCtrl.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn/ConfirmConnectCtrl.html new file mode 100644 index 00000000..eadee337 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn/ConfirmConnectCtrl.html @@ -0,0 +1,15 @@ +<div ng-controller="CrmCxnConfirmConnectCtrl"> + <p>{{ts('The application, \"%1\", requests permission to access your system.', {1: appMeta.title})}}</p> + <div crm-ui-accordion="{title: ts('About'), collapsed: true}"> + <div ng-bind-html="appMeta.desc"></div> + </div> + <div crm-ui-accordion="{title: ts('Permissions: Summary'), collapsed: true}"> + <div ng-bind-html="appMeta.perm.desc"></div> + </div> + <div crm-ui-accordion="{title: ts('Permissions: Details'), collapsed: true}"> + <div crm-cxn-perm-table="{perm: appMeta.perm}"></div> + </div> + <div crm-ui-accordion="{title: ts('Advanced'), collapsed: true}"> + <div crm-cxn-adv-table="{appMeta: appMeta}"></div> + </div> +</div> diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn/ConfirmConnectCtrl.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn/ConfirmConnectCtrl.js new file mode 100644 index 00000000..c303d5e9 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn/ConfirmConnectCtrl.js @@ -0,0 +1,5 @@ +(function(angular, $, _) { + angular.module('crmCxn').controller('CrmCxnConfirmConnectCtrl', function($scope) { + $scope.ts = CRM.ts(null); + }); +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn/ConfirmReconnectCtrl.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn/ConfirmReconnectCtrl.html new file mode 100644 index 00000000..0b60bd87 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn/ConfirmReconnectCtrl.html @@ -0,0 +1,23 @@ +<div ng-controller="CrmCxnConfirmReconnectCtrl"> + <p>{{ts('Are you sure you want to reconnect \"%1\"?', {1: appMeta.title})}}</p> + + <p>{{ts('Reconnecting will change the connection details (such as callback URLs and permissions). This can be useful in a few cases, such as:')}}</p> + + <ul> + <li>{{ts('After your site has migrated to a new URL.')}}</li> + <li>{{ts('After the application has migrated to a new URL.')}}</li> + <li>{{ts('After the application has changed permission requirements.')}}</li> + <li>{{ts('After the application has a major failure or reset.')}}</li> + </ul> + + <div crm-ui-accordion="{title: ts('Permissions: Summary'), collapsed: true}"> + <div ng-bind-html="appMeta.perm.desc"></div> + </div> + <div crm-ui-accordion="{title: ts('Permissions: Details'), collapsed: true}"> + <div crm-cxn-perm-table="{perm: appMeta.perm}"></div> + </div> + <div crm-ui-accordion="{title: ts('Advanced'), collapsed: true}"> + <div crm-cxn-adv-table="{appMeta: appMeta}"></div> + </div> + +</div> diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn/ConfirmReconnectCtrl.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn/ConfirmReconnectCtrl.js new file mode 100644 index 00000000..211d415d --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn/ConfirmReconnectCtrl.js @@ -0,0 +1,5 @@ +(function(angular, $, _) { + angular.module('crmCxn').controller('CrmCxnConfirmReconnectCtrl', function($scope) { + $scope.ts = CRM.ts(null); + }); +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn/Connectivity.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn/Connectivity.html new file mode 100644 index 00000000..e8a14d8a --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn/Connectivity.html @@ -0,0 +1,4 @@ +<p>{{ts('There was a problem verifying that this site is available on the public Internet.')}}</p> +<p>{{ts('See also:')}} + <a href="https://civicrm.org/inapp/civiconnect-firewall" target="_blank">{{ts('Firewalls and Proxies')}}</a> +</p>
\ No newline at end of file diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn/LinkDialogCtrl.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn/LinkDialogCtrl.html new file mode 100644 index 00000000..ad656409 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn/LinkDialogCtrl.html @@ -0,0 +1,3 @@ +<div ng-controller="CrmCxnLinkDialogCtrl"> + <iframe crm-ui-iframe crm-ui-iframe-src="model.url"></iframe> +</div> diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn/LinkDialogCtrl.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn/LinkDialogCtrl.js new file mode 100644 index 00000000..d1672d83 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn/LinkDialogCtrl.js @@ -0,0 +1,11 @@ +(function(angular, $, _) { + + // Controller for the "Open Link" dialog + // Scope members: + // - [input] "model": Object + // - "url": string + angular.module('crmCxn').controller('CrmCxnLinkDialogCtrl', function CrmCxnLinkDialogCtrl($scope, dialogService) { + var ts = $scope.ts = CRM.ts(null); + }); + +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn/ManageCtrl.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn/ManageCtrl.html new file mode 100644 index 00000000..891942e6 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn/ManageCtrl.html @@ -0,0 +1,120 @@ +<div crm-ui-debug="appMetas"></div> +<div crm-ui-debug="cxns"></div> +<div crm-ui-debug="alerts"></div> + +<!-- + The merits of this layout: + * On a fresh install, the available connections show up first. + * Once you've made a connection, the extant connections bubble up. + * Extant connections can be portrayed as enabled or disabled. +--> + +<div class="help"> + <p>{{ts('Connections provide a simplified way to link your CiviCRM installation to an external service.')}}</p> +</div> + +<div ng-show="cxns.length > 0"> + <span crm-ui-order="{var: 'cxnOrder', defaults: ['-created_date']}"></span> + <h3>{{ts('Existing Connections')}}</h3> + <table class="display"> + <thead> + <tr> + <th>{{ts('Title')}}</th> <!-- <a crm-ui-order-by="[cxnOrder, 'app_meta.appId']"> --> + <th>{{ts('Description')}}</th> <!-- <a crm-ui-order-by="[cxnOrder, 'desc']"> --> + <th>{{ts('Status')}}</th> + <th></th> + </tr> + </thead> + <tbody> + <tr ng-repeat="cxn in cxns | orderBy:cxnOrder.get()" ng-class-even="'even-row even'" ng-class-odd="'odd-row odd'"> + <td> + <a class="action-item" + crm-confirm='{width: "65%", resizable: true, title:ts("%1: About", {1: cxn.app_meta.title}), templateUrl: "~/crmCxn/AboutCtrl.html", export: {appMeta: cxn.app_meta}}' + >{{cxn.app_meta.title}}</a> + </td> + <td><div ng-bind-html="cxn.app_meta.desc"></div></td> + <td>{{cxn.is_active=="1" ? ts('Enabled') : ts('Disabled')}}</td> + <td> + <span> + <a class="action-item crm-hover-button" ng-click="openLink(cxn.app_meta, 'settings', {title: ts('%1: Settings (External)', {1: cxn.app_meta.title})})" ng-show="cxn.app_meta.links.settings">{{ts('Settings')}}</a> + <span class="btn-slide crm-hover-button">{{ts('more')}} + <ul class="panel" style="display: none;"> + <li ng-show="cxn.app_meta.links.logs"> + <a class="action-item crm-hover-button" ng-click="openLink(cxn.app_meta, 'logs', {title: ts('%1: Logs (External)', {1: cxn.app_meta.title})})"> + {{ts('Logs')}} + </a> + </li> + <li ng-show="cxn.app_meta.links.docs"> + <a class="action-item crm-hover-button" ng-click="openLink(cxn.app_meta, 'docs', {title: ts('%1: Documentation (External)', {1: cxn.app_meta.title})})"> + {{ts('Docs')}} + </a> + </li> + <li ng-show="cxn.app_meta.links.support"> + <a class="action-item crm-hover-button" ng-click="openLink(cxn.app_meta, 'support', {title: ts('%1: Support (External)', {1: cxn.app_meta.title})})"> + {{ts('Support')}} + </a> + </li> + <li> + <a class="action-item crm-hover-button" ng-click="toggleCxn(cxn)">{{ cxn.is_active=="1" ? ts('Disable') : ts('Enable')}}</a> + </li> + <li> + <a class="action-item crm-hover-button" + crm-confirm='{width: "65%", resizable: true, title:ts("%1: Reconnect", {1: cxn.app_meta.title}), templateUrl: "~/crmCxn/ConfirmReconnectCtrl.html", export: {cxn: cxn, appMeta: findAppByAppId(cxn.app_guid)}}' + on-yes="reregister(cxn.app_meta)" + >{{ts('Reconnect')}}</a> + </li> + <li> + <a class="action-item crm-hover-button" + crm-confirm='{width: "65%", resizable: true, title: ts("%1: Disconnect", {1: cxn.app_meta.title}), message: ts("Are you sure you want to disconnect \"%1?\". Doing so may permanently destroy data linkage.", {1: cxn.app_meta.title})}' + on-yes="unregister(cxn.app_meta)"> + {{ts('Disconnect')}} + </a> + </li> + </ul> + </span> + + </span> + </td> + </tr> + </tbody> + </table> + <br/> +</div> + +<div ng-show="hasAvailApps()"> + <span crm-ui-order="{var: 'availOrder', defaults: ['title']}"></span> + +<div class="crm-content-block crm-block crm-connection-block"> + <h3>{{ts('New Connections')}}</h3> + <table class="display"> + <thead> + <tr> + <th><a crm-ui-order-by="[availOrder, 'title']">{{ts('Title')}}</a></th> + <th><a crm-ui-order-by="[availOrder, 'desc']">{{ts('Description')}}</a></th> + <th></th> + </tr> + </thead> + <tbody> + <tr ng-repeat="appMeta in appMetas | orderBy:availOrder.get()" ng-show="!findCxnByAppId(appMeta.appId)" ng-class-even="'even-row even'" ng-class-odd="'odd-row odd'"> + <td> + <a crm-confirm='{width: "65%", resizable: true, title:ts("%1: About", {1: appMeta.title}), templateUrl: "~/crmCxn/AboutCtrl.html", export: {appMeta: appMeta}}' + >{{appMeta.title}}</a> + </td> + <td><div ng-bind-html="appMeta.desc"></div></td> + <td> + <a class="action-item crm-hover-button" + crm-confirm='{width: "65%", resizable: true, title:ts("%1: Connect", {1: appMeta.title}), templateUrl: "~/crmCxn/ConfirmConnectCtrl.html", export: {appMeta: appMeta}}' + on-yes="register(appMeta)" + >{{ts('Connect')}}</a> + </td> + </tr> + </tbody> + </table> +</div> + +</div> + +<div ng-show="appMetas.length === 0" class="messages status no-popup"> + <i class="crm-i fa-info-circle"></i> + {{ts('No available applications')}} +</div> diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn/ManageCtrl.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn/ManageCtrl.js new file mode 100644 index 00000000..cd843c33 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn/ManageCtrl.js @@ -0,0 +1,153 @@ +(function(angular, $, _) { + + angular.module('crmCxn').controller('CrmCxnManageCtrl', function CrmCxnManageCtrl($scope, apiCalls, crmApi, crmUiAlert, crmBlocker, crmStatus, $timeout, dialogService, crmCxnCheckAddr) { + var ts = $scope.ts = CRM.ts(null); + if (apiCalls.appMetas.is_error) { + $scope.appMetas = []; + CRM.alert(apiCalls.appMetas.error_message, ts('Application List Unavailable'), 'error'); + } + else { + $scope.appMetas = apiCalls.appMetas.values; + } + $scope.cxns = apiCalls.cxns.values; + $scope.alerts = _.where(apiCalls.sysCheck.values, {name: 'checkCxnOverrides'}); + + crmCxnCheckAddr(apiCalls.cfg.values.siteCallbackUrl).then(function(response) { + if (response.valid) return; + crmUiAlert({ + type: 'warning', + title: ts('Internet Access Required'), + templateUrl: '~/crmCxn/Connectivity.html', + scope: $scope.$new(), + options: {expires: false} + }); + }); + + $scope.filter = {}; + var block = $scope.block = crmBlocker(); + + _.each($scope.alerts, function(alert){ + crmUiAlert({text: alert.message, title: alert.title, type: 'error'}); + }); + + // Convert array [x] to x|null|error + function asOne(result, msg) { + switch (result.length) { + case 0: + return null; + case 1: + return result[0]; + default: + throw msg; + } + } + + $scope.findCxnByAppId = function(appId) { + var result = _.where($scope.cxns, { + app_guid: appId + }); + return asOne(result, "Error: Too many connections for appId: " + appId); + }; + + $scope.findAppByAppId = function(appId) { + var result = _.where($scope.appMetas, { + appId: appId + }); + return asOne(result, "Error: Too many apps for appId: " + appId); + }; + + $scope.hasAvailApps = function() { + // This should usu return after the 1st or 2nd item, but in testing with small# apps, we may exhaust the list. + for (var i = 0; i< $scope.appMetas.length; i++) { + if (!$scope.findCxnByAppId($scope.appMetas[i].appId)) { + return true; + } + } + return false; + }; + + $scope.refreshCxns = function() { + crmApi('Cxn', 'get', {sequential: 1}).then(function(result) { + $timeout(function(){ + $scope.cxns = result.values; + }); + }); + }; + + $scope.register = function(appMeta) { + var reg = crmApi('Cxn', 'register', {app_guid: appMeta.appId}).then($scope.refreshCxns).then(function() { + if (appMeta.links.welcome) { + return $scope.openLink(appMeta, 'welcome', {title: ts('%1: Welcome (External)', {1: appMeta.title})}); + } + }); + return block(crmStatus({start: ts('Connecting...'), success: ts('Connected')}, reg)); + }; + + $scope.reregister = function(appMeta) { + var reg = crmApi('Cxn', 'register', {app_guid: appMeta.appId}).then($scope.refreshCxns).then(function() { + if (appMeta.links.welcome) { + return $scope.openLink(appMeta, 'welcome', {title: ts('%1: Welcome (External)', {1: appMeta.title})}); + } + }); + return block(crmStatus({start: ts('Reconnecting...'), success: ts('Reconnected')}, reg)); + }; + + $scope.unregister = function(appMeta) { + var reg = crmApi('Cxn', 'unregister', {app_guid: appMeta.appId, debug: 1}).then($scope.refreshCxns); + return block(crmStatus({start: ts('Disconnecting...'), success: ts('Disconnected')}, reg)); + }; + + $scope.toggleCxn = function toggleCxn(cxn) { + var is_active = (cxn.is_active=="1" ? 0 : 1); // we switch the flag + var reg = crmApi('Cxn', 'create', {id: cxn.id, app_guid: cxn.app_meta.appId, is_active: is_active, debug: 1}).then(function(){ + cxn.is_active = is_active; + }); + return block(crmStatus({start: ts('Saving...'), success: ts('Saved')}, reg)); + }; + + $scope.openLink = function openLink(appMeta, page, options) { + var promise = crmApi('Cxn', 'getlink', {app_guid: appMeta.appId, page_name: page}).then(function(result) { + var mode = result.values.mode ? result.values.mode : 'popup'; + switch (result.values.mode) { + case 'iframe': + var passThrus = ['height', 'width']; // Options influenced by remote server. + options = angular.extend(_.pick(result.values, passThrus), options); + $scope.openIframe(result.values.url, options); + break; + case 'popup': + CRM.alert(ts('The page "%1" will open in a popup. If it does not appear automatically, check your browser for notifications.', {1: options.title}), '', 'info'); + window.open(result.values.url, 'cxnSettings', 'resizable,scrollbars,status'); + break; + case 'redirect': + window.location = result.values.url; + break; + default: + CRM.alert(ts('Cannot open link. Unrecognized mode.'), '', 'error'); + } + }); + return block(crmStatus({start: ts('Opening...'), success: ''}, promise)); + }; + + // @param Object options -- see dialogService.open + $scope.openIframe = function openIframe(url, options) { + var model = { + url: url + }; + options = CRM.utils.adjustDialogDefaults(angular.extend( + { + autoOpen: false, + height: 'auto', + width: '40%', + title: ts('External Link') + }, + options + )); + return dialogService.open('cxnLinkDialog', '~/crmCxn/LinkDialogCtrl.html', model, options) + .then(function(item) { + mailing.msg_template_id = item.id; + return item; + }); + }; + }); + +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn/PermTable.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn/PermTable.html new file mode 100644 index 00000000..d2a1eabf --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn/PermTable.html @@ -0,0 +1,42 @@ +<table> + <thead> + <tr> + <th>{{ts('Entity')}}</th> + <th>{{ts('Action(s)')}}</th> + <th>{{ts('Filter(s)')}}</th> + <th>{{ts('Field(s)')}}</th> + </tr> + </thead> + <tbody> + <tr ng-repeat="api in perm.api" + ng-class-even="'even-row even'" + ng-class-odd="'odd-row odd'"> + <td> + <em ng-show="api.entity == '*'">{{ts('Any')}}</em> + <code ng-hide="api.entity == '*'">{{api.entity}}</code> + </td> + <td> + <div ng-switch="isString(api.actions)"> + <span ng-switch-when="true"> + <em ng-show="api.actions == '*'">{{ts('Any')}}</em> + <code ng-hide="api.actions == '*'">{{api.actions}}</code> + </span> + <span ng-switch-default=""> + <span ng-repeat="action in api.actions"><code>{{action}}</code><span ng-show="!$last">, </span></span> + </span> + </div> + </td> + <td> + <em ng-show="!hasRequiredFilters(api)">{{ts('Any')}}</em> + <div ng-repeat="(field,value) in api.required"><code>{{field}}</code> = "<code>{{value}}</code>"<span ng-show="!$last">, </span></div> + </td> + <td> + <em ng-show="api.fields == '*'">{{ts('Any')}}</em> + <span ng-hide="api.fields == '*'" ng-repeat="field in api.fields"><code>{{field}}</code><span ng-show="!$last">, </span></span> + </td> + </tr> + </tbody> +</table> +<div class="crmCxn-footer"> + <em ng-bind-html="ts('For in-depth details about entities and actions, see the <a href=\'%1\' target=\'%2\'>API Explorer</a>.', {1: apiExplorerUrl, 2: '_blank'})"></em> +</div> diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn/PermTable.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn/PermTable.js new file mode 100644 index 00000000..eb7da355 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmCxn/PermTable.js @@ -0,0 +1,27 @@ +(function(angular, $, _) { + + // This directive formats the data in appMeta.perm as a nice table. + // example: <div crm-cxn-perm-table="{perm: cxn.app_meta.perm}"></div> + angular.module('crmCxn').directive('crmCxnPermTable', function crmCxnPermTable() { + return { + restrict: 'EA', + scope: { + crmCxnPermTable: '=' + }, + templateUrl: '~/crmCxn/PermTable.html', + link: function(scope, element, attrs) { + scope.ts = CRM.ts(null); + scope.hasRequiredFilters = function(api) { + return !_.isEmpty(api.required); + }; + scope.isString = function(v) { + return _.isString(v); + }; + scope.apiExplorerUrl = CRM.url('civicrm/api'); + scope.$watch('crmCxnPermTable', function(crmCxnPermTable){ + scope.perm = crmCxnPermTable.perm; + }); + } + }; + }); +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmD3.ang.php b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmD3.ang.php new file mode 100644 index 00000000..0a737341 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmD3.ang.php @@ -0,0 +1,16 @@ +<?php +// This file declares an Angular module which can be autoloaded +// in CiviCRM. See also: +// http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_angularModules + +// ODDITY: Only loads if you have CiviMail permissions. +// ODDITY: Extra resources loaded via CRM_Mailing_Info::getAngularModules. + +return [ + 'ext' => 'civicrm', + 'js' => [ + 'ang/crmD3.js', + 'bower_components/d3/d3.min.js', + ], + 'requires' => [], +]; diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmD3.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmD3.js new file mode 100644 index 00000000..d06eeac9 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmD3.js @@ -0,0 +1,3 @@ +(function (angular, $, _) { + angular.module('crmD3', CRM.angRequires('crmD3')); +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmExample.ang.php b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmExample.ang.php new file mode 100644 index 00000000..763f1eac --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmExample.ang.php @@ -0,0 +1,11 @@ +<?php +// This file declares an Angular module which can be autoloaded +// in CiviCRM. See also: +// http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_angularModules + +return [ + 'ext' => 'civicrm', + 'js' => ['ang/crmExample.js'], + 'partials' => ['ang/crmExample'], + 'requires' => ['crmUtil', 'ngRoute', 'ui.utils', 'crmUi', 'crmResource'], +]; diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmExample.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmExample.js new file mode 100644 index 00000000..138ce8f1 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmExample.js @@ -0,0 +1,45 @@ +(function(angular, $, _) { + + angular.module('crmExample', CRM.angRequires('crmExample')); + + angular.module('crmExample').config([ + '$routeProvider', + function($routeProvider) { + $routeProvider.when('/example', { + templateUrl: '~/crmExample/example.html', + controller: 'ExampleCtrl' + }); + } + ]); + + angular.module('crmExample').controller('ExampleCtrl', function ExampleCtrl($scope) { + $scope.ts = CRM.ts(null); + + //$scope.examples = { + // blank1: {value: '', required: false}, + // blank2: {value: '', required: true}, + // filled1: {value:'2014-01-02', required: false}, + // filled2: {value:'2014-02-03', required: true} + //}; + + //$scope.examples = { + // blank1: {value: '', required: false}, + // blank2: {value: '', required: true}, + // filled1: {value:'12:34', required: false}, + // filled2: {value:'10:09', required: true} + //}; + + $scope.examples = { + blank: {value: '', required: false}, + //blankReq: {value: '', required: true}, + filled: {value:'2014-01-02 03:04', required: false}, + //filledReq: {value:'2014-02-03 05:06', required: true}, + missingDate: {value:' 05:06', required: false}, + //missingDateReq: {value:' 05:06', required: true}, + missingTime: {value:'2014-03-04 ', required: false} + //missingTimeReq: {value:'2014-03-04 ', required: true} + }; + + }); + +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmExample/example.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmExample/example.html new file mode 100644 index 00000000..5393e7c1 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmExample/example.html @@ -0,0 +1,42 @@ +<form name="exampleForm" novalidate> + <table> + <thead> + <tr> + <th>Name</th> + <th>Value</th> + <th>Input</th> + <th>ngModel</th> + </tr> + </thead> + + <tbody> + + <tr ng-repeat="(exName, example) in examples"> + <td>{{exName}}</td> + <td>{{example.value}}</td> + <td> + <div class="crmMailing-schedule-outer" crm-mailing-radio-date="schedule" ng-model="example.value" + name="{{exName}}"> + + <div class="crmMailing-schedule-inner"> + <div> + <input ng-model="schedule.mode" type="radio" name="send_{{exName}}" value="now" id="schedule-send-now"> + <label for="schedule-send-now">{{ts('Send immediately')}}</label> + </div> + <div> + <input ng-model="schedule.mode" type="radio" name="send_{{exName}}" value="at" id="schedule-send-at"> + <label for="schedule-send-at">{{ts('Send at:')}}</label> + <input crm-ui-datepicker ng-model="schedule.datetime" ng-required="schedule.mode == 'at'"> + </div> + </div> + </div> + </td> + <td> + <pre>{{exampleForm[exName]|json}}</pre> + </td> + </tr> + + </tbody> + </table> + +</form> diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing.ang.php b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing.ang.php new file mode 100644 index 00000000..a3e140c6 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing.ang.php @@ -0,0 +1,18 @@ +<?php +// This file declares an Angular module which can be autoloaded +// in CiviCRM. See also: +// http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_angularModules + +// ODDITY: Only loads if you have CiviMail permissions. +// ODDITY: Extra resources loaded via CRM_Mailing_Info::getAngularModules. + +return [ + 'ext' => 'civicrm', + 'js' => [ + 'ang/crmMailing.js', + 'ang/crmMailing/*.js', + ], + 'css' => ['ang/crmMailing.css'], + 'partials' => ['ang/crmMailing'], + 'requires' => ['crmUtil', 'crmAttachment', 'crmAutosave', 'ngRoute', 'ui.utils', 'crmUi', 'dialogService', 'crmResource'], +]; diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing.css b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing.css new file mode 100644 index 00000000..b853cb44 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing.css @@ -0,0 +1,113 @@ +.crmMailing input[name=subject] { + width: 30em; +} +.crmMailing select, .crmMailing input[type=text] { + width: 36em; +} +.crmMailing textarea { + margin: 0.5em; + width: 95%; + height: 20em; +} +.crmMailing input.crm-form-date { + width: 10em; +} + +.crmMailing-recip-est { + background: #ee8; + font-size: small; + padding: 0.33em; + margin: 0 0 0 0.5em; + width: 9em; + text-align: center; +} + +span.crmMailing-include { + color: #060; +} +span.crmMailing-exclude { + color: #600; + text-decoration: line-through; +} +span.crmMailing-mandatory { + color: #866304; +} + +.crmMailing input[name=preview_test_email], .crmMailing-preview select[name=preview_test_group] { + width: 80%; +} + +.crmMailing .preview-popup, .crmMailing .preview-contact, .crmMailing .preview-group { + width: 30%; + height: 4.5em; + margin: 0.5em; + text-align: center; + vertical-align: middle; + float: left; +} +.crmMailing .preview-popup, .crmMailing .preview-contact { + border-right: 1px solid black; +} +.crmMailing .preview-group, .crmMailing .preview-contact { +} + +.crmMailing .crmMailing-schedule-outer { + width: 98% +} +.crmMailing .crmMailing-schedule-inner { + width: 40em; + text-align: left; + margin: auto; +} + +/* Odd: These placeholder directives break if combined */ +input[name=preview_test_email]:-moz-placeholder { + text-align: center; +} +input[name=preview_test_email]::-moz-placeholder { + text-align: center; +} +input[name=preview_test_email]::-webkit-input-placeholder { + text-align: center; +} +input[name=preview_test_email]:-ms-input-placeholder { + text-align: center; +} +.crmMailing-active { +} +.crmMailing-inactive { + text-decoration: line-through; +} +.crm-container a.crmMailing-submit-button { + display: inline-block; + padding: .2em .4em; + margin: 1em auto; + border-radius: 5px; + font-size: 1.2em; + float: none; +} +.crm-container a.crmMailing-submit-button div { + background: url(../i/check.gif) no-repeat left center; + padding-left: 20px; +} +.crm-container a.crmMailing-submit-button.disabled, +.crm-container a.crmMailing-submit-button.blocking { + opacity: .6; + cursor: default; +} +.crm-container a.crmMailing-submit-button.blocking div { + background: url(../i/loading-2f2f2e.gif) no-repeat left center; +} + +.crm-container .crm-form-block label { + font-size: 13px; +} + +.crm-container .ui-widget-content { + background: none; +} + +.crmMailing-error-link { + margin: 0.5em; + color: red; +} diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing.js new file mode 100644 index 00000000..95148bf2 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing.js @@ -0,0 +1,61 @@ +(function (angular, $, _) { + + angular.module('crmMailing', CRM.angRequires('crmMailing')); + + angular.module('crmMailing').config([ + '$routeProvider', + function ($routeProvider) { + $routeProvider.when('/mailing', { + template: '<div></div>', + controller: 'ListMailingsCtrl' + }); + + if (!CRM || !CRM.crmMailing) { + return; + } + + $routeProvider.when('/mailing/new', { + template: '<p>' + ts('Initializing...') + '</p>', + controller: 'CreateMailingCtrl', + resolve: { + selectedMail: function(crmMailingMgr) { + var m = crmMailingMgr.create({ + template_type: CRM.crmMailing.templateTypes[0].name + }); + return crmMailingMgr.save(m); + } + } + }); + + $routeProvider.when('/mailing/new/:templateType', { + template: '<p>' + ts('Initializing...') + '</p>', + controller: 'CreateMailingCtrl', + resolve: { + selectedMail: function($route, crmMailingMgr) { + var m = crmMailingMgr.create({ + template_type: $route.current.params.templateType + }); + return crmMailingMgr.save(m); + } + } + }); + + $routeProvider.when('/mailing/:id', { + templateUrl: '~/crmMailing/EditMailingCtrl/base.html', + controller: 'EditMailingCtrl', + resolve: { + selectedMail: function($route, crmMailingMgr) { + return crmMailingMgr.get($route.current.params.id); + }, + attachments: function($route, CrmAttachments) { + var attachments = new CrmAttachments(function () { + return {entity_table: 'civicrm_mailing', entity_id: $route.current.params.id}; + }); + return attachments.load(); + } + } + }); + } + ]); + +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockApprove.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockApprove.html new file mode 100644 index 00000000..e3971ca7 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockApprove.html @@ -0,0 +1,14 @@ +<div class="crm-block" ng-form="apprForm" crm-ui-id-scope> + <div class="crm-group"> + <div crm-ui-field="{title: ts('Status')}"> + {{mailingFields.approval_status_id.optionsMap[mailing.approval_status_id] || ts('Unreviewed')}} + </div> + <div crm-ui-field="{name: 'apprForm.approval_note', title: ts('Note')}"> + <textarea + crm-ui-id="apprForm.approval_note" + name="approval_note" + ng-model="mailing.approval_note" + ></textarea> + </div> + </div> +</div> diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockApprove.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockApprove.js new file mode 100644 index 00000000..8f0cd599 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockApprove.js @@ -0,0 +1,5 @@ +(function(angular, $, _) { + angular.module('crmMailing').directive('crmMailingBlockApprove', function(crmMailingSimpleDirective) { + return crmMailingSimpleDirective('crmMailingBlockApprove', '~/crmMailing/BlockApprove.html'); + }); +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockHeaderFooter.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockHeaderFooter.html new file mode 100644 index 00000000..249560d7 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockHeaderFooter.html @@ -0,0 +1,32 @@ +<!-- +Controller: EditMailingCtrl +Required vars: mailing, crmMailingConst +--> +<div class="crm-block" ng-form="subform" crm-ui-id-scope> + <div class="crm-group" ng-controller="EmailBodyCtrl"> + <div crm-ui-field="{name: 'subform.header_id', title: ts('Mailing Header'), help: hs('header')}"> + <select + crm-ui-id="subform.header_id" + name="header_id" + ui-jq="select2" + ui-options="{dropdownAutoWidth : true, allowClear: true}" + ng-change="checkTokens(mailing, '*')" + ng-model="mailing.header_id" + ng-options="mc.id as mc.name for mc in crmMailingConst.headerfooterList | filter:{component_type: 'Header'} | orderBy:'name'"> + <option value=""></option> + </select> + </div> + <div crm-ui-field="{name: 'subform.footer_id', title: ts('Mailing Footer'), help: hs('footer')}"> + <select + crm-ui-id="subform.footer_id" + name="footer_id" + ui-jq="select2" + ui-options="{dropdownAutoWidth : true, allowClear: true}" + ng-change="checkTokens(mailing, '*')" + ng-model="mailing.footer_id" + ng-options="mc.id as mc.name for mc in crmMailingConst.headerfooterList | filter:{component_type: 'Footer'} | orderBy:'name'"> + <option value=""></option> + </select> + </div> + </div> +</div> diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockHeaderFooter.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockHeaderFooter.js new file mode 100644 index 00000000..babba177 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockHeaderFooter.js @@ -0,0 +1,5 @@ +(function(angular, $, _) { + angular.module('crmMailing').directive('crmMailingBlockHeaderFooter', function(crmMailingSimpleDirective) { + return crmMailingSimpleDirective('crmMailingBlockHeaderFooter', '~/crmMailing/BlockHeaderFooter.html'); + }); +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockMailing.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockMailing.html new file mode 100644 index 00000000..8e099743 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockMailing.html @@ -0,0 +1,82 @@ +<!-- +Controller: EditMailingCtrl +Required vars: mailing, crmMailingConst +Note: Much of this file is duplicated in crmMailing and crmMailingAB with variations on placement/title/binding. +It could perhaps be thinned by 30-60% by making more directives. +--> +<div class="crm-block" ng-form="subform" crm-ui-id-scope> + <div class="crm-group"> + <div crm-ui-field="{name: 'subform.msg_template_id', title: ts('Template')}"> + <div crm-mailing-block-templates="{name: 'templates', id: 'subform.msg_template_id'}" crm-mailing="mailing"></div> + </div> + <div crm-ui-field="{name: 'subform.fromAddress', title: ts('From'), help: hs('from_email')}"> + <div ng-controller="EmailAddrCtrl" crm-mailing-from-address="fromPlaceholder" crm-mailing="mailing"> + <select + crm-ui-id="subform.fromAddress" + crm-ui-select="{dropdownAutoWidth : true, allowClear: false, placeholder: ts('Email address')}" + name="fromAddress" + ng-model="fromPlaceholder.label" + required> + <option value=""></option> + <option ng-repeat="frm in crmFromAddresses.getAll() | filter:{is_active:1} | orderBy:'weight'" value="{{frm.label}}">{{frm.label}}</option> + </select> + </div> + </div> + <div crm-ui-field="{name: 'subform.replyTo', title: ts('Reply-To')}" ng-show="crmMailingConst.enableReplyTo"> + <div ng-controller="EmailAddrCtrl"> + <select + crm-ui-id="subform.replyTo" + crm-ui-select="{dropdownAutoWidth : true, allowClear: true, placeholder: ts('Email address')}" + name="replyTo" + ng-change="checkReplyToChange(mailing)" + ng-model="mailing.replyto_email" + > + <option value=""></option> + <option ng-repeat="frm in crmFromAddresses.getAll() | filter:{is_active:1} | orderBy:'weight'" value="{{frm.label}}">{{frm.label}}</option> + </select> + </div> + </div> + <div crm-ui-field="{name: 'subform.recipients', title: ts('Recipients'), required: true}"> + <div crm-mailing-block-recipients="{name: 'recipients', id: 'subform.recipients'}" crm-mailing="mailing" cm-ui-id="subform.recipients"></div> + </div> + <span ng-controller="EditUnsubGroupCtrl"> + <div crm-ui-field="{name: 'subform.baseGroup', title: ts('Unsubscribe Group')}" ng-if="isUnsubGroupRequired(mailing)"> + <input + crm-entityref="{entity: 'Group', api: {params: {is_hidden: 0, is_active: 1}}, select: {allowClear:true, minimumInputLength: 0}}" + crm-ui-id="subform.baseGroup" + name="baseGroup" + ng-model="mailing.recipients.groups.base[0]" + ng-required="true" + /> + </div> + </span> + <div crm-ui-field="{name: 'subform.subject', title: ts('Subject')}"> + <div style="float: right;"> + <input crm-mailing-token on-select="$broadcast('insert:subject', token.name)" tabindex="-1"/> + </div> + <input + crm-ui-id="subform.subject" + crm-ui-insert-rx="insert:subject" + type="text" + class="crm-form-text" + ng-model="mailing.subject" + required + placeholder="Subject" + name="subject" /> + </div> + <div ng-if="crmMailingConst.isMultiLingual"> + <div crm-ui-field="{name: 'subform.language', title: ts('Language')}"> + <select + crm-ui-id="subform.language" + crm-ui-select="{dropdownAutoWidth : true, allowClear: false, placeholder: ts('- choose language -')}" + name="language" + ng-model="mailing.language" + required + > + <option value=""></option> + <option ng-repeat="(key,val) in crmMailingConst.enabledLanguages" value="{{key}}">{{val}}</option> + </select> + </div> + </div> + </div> +</div> diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockMailing.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockMailing.js new file mode 100644 index 00000000..a2297d02 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockMailing.js @@ -0,0 +1,5 @@ +(function(angular, $, _) { + angular.module('crmMailing').directive('crmMailingBlockMailing', function(crmMailingSimpleDirective) { + return crmMailingSimpleDirective('crmMailingBlockMailing', '~/crmMailing/BlockMailing.html'); + }); +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockPreview.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockPreview.html new file mode 100644 index 00000000..6315466e --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockPreview.html @@ -0,0 +1,57 @@ +<!-- +Vars: mailing:obj, testContact:obj, testGroup:obj, crmMailing:FormController +--> +<div class="crmMailing-preview"> + <!-- Note: + In Firefox (at least), clicking the preview buttons causes the browser to display validation warnings + for unrelated fields *and* display preview. To avoid this weird UX, we disable preview buttons when the form is incomplete/invalid. + --> + <div class="preview-popup"> + <div ng-show="!mailing.body_html && !mailing.body_text"> + <em>({{ts('No content to preview')}})</em> + </div> + <div ng-hide="!mailing.body_html"> + <a class="crm-hover-button action-item" crm-icon="fa-television" ng-disabled="crmMailing.$invalid" ng-click="doPreview('html')">{{ts('Preview as HTML')}}</a> + </div> + <div ng-hide="!mailing.body_html && !mailing.body_text" style="margin-top: 1em;"> + <a class="crm-hover-button action-item" crm-icon="fa-file-text-o" ng-disabled="crmMailing.$invalid" ng-click="doPreview('text')">{{ts('Preview as Plain Text')}}</a> + </div> + <!-- + <div ng-hide="!mailing.body_html && !mailing.body_text"> + <button ng-disabled="crmMailing.$invalid" ng-click="doPreview('full')">{{ts('Preview')}}</button> + </div> + --> + </div> + <div class="preview-contact" ng-form=""> + <div> + {{ts('Send test email to:')}} + <a crm-ui-help="hs({id: 'test', title: ts('Test Email')})"></a> + </div> + <div> + <input + name="preview_test_email" + type="text" + class="crm-form-text" + ng-model="testContact.email" + placeholder="example@example.org" + crm-multiple-email + /> + </div> + <button crm-icon="fa-paper-plane" title="{{crmMailing.$invalid || !testContact.email ? ts('Complete all required fields first') : ts('Send test message to %1', {1: testContact.email})}}" ng-disabled="crmMailing.$invalid || !testContact.email" ng-click="doSend({email: testContact.email})" class="crmMailing-btn-primary">{{ts('Send test')}}</button> + </div> + <div class="preview-group" ng-form=""> + <div> + {{ts('Send test email to group:')}} + <a crm-ui-help="hs({id: 'test', title: ts('Test Email')})"></a> + </div> + <div> + <input + crm-entityref="{entity: 'Group', api: {params: {is_hidden: 0, is_active: 1}}, select: {allowClear:true, minimumInputLength: 0}}" + ng-model="testGroup.gid" + class="crm-action-menu fa-envelope-o" + /> + </div> + <button crm-icon="fa-paper-plane" title="{{crmMailing.$invalid || !testGroup.gid ? ts('Complete all required fields first') : ts('Send test message to group')}}" ng-disabled="crmMailing.$invalid || !testGroup.gid" crm-confirm="{resizable: true, width: '40%', height: '40%', open: previewTestGroup}" on-yes="doSend({gid: testGroup.gid})" class="crmMailing-btn-primary">{{ts('Send test')}}</button> + </div> + <div class="clear"></div> +</div> diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockPreview.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockPreview.js new file mode 100644 index 00000000..5e582dc0 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockPreview.js @@ -0,0 +1,65 @@ +(function(angular, $, _) { + // example: <div crm-mailing-block-preview crm-mailing="myMailing" on-preview="openPreview(myMailing, preview.mode)" on-send="sendEmail(myMailing,preview.recipient)"> + // note: the directive defines a variable called "preview" with any inputs supplied by the user (e.g. the target recipient for an example mailing) + + angular.module('crmMailing').directive('crmMailingBlockPreview', function(crmUiHelp) { + return { + templateUrl: '~/crmMailing/BlockPreview.html', + link: function(scope, elm, attr) { + scope.$watch(attr.crmMailing, function(newValue) { + scope.mailing = newValue; + }); + scope.crmMailingConst = CRM.crmMailing; + scope.ts = CRM.ts(null); + scope.hs = crmUiHelp({file: 'CRM/Mailing/MailingUI'}); + scope.testContact = {email: CRM.crmMailing.defaultTestEmail}; + scope.testGroup = {gid: null}; + + scope.doPreview = function(mode) { + scope.$eval(attr.onPreview, { + preview: {mode: mode} + }); + }; + scope.doSend = function doSend(recipient) { + recipient = JSON.parse(JSON.stringify(recipient).replace(/\,\s/g, ',')); + scope.$eval(attr.onSend, { + preview: {recipient: recipient} + }); + }; + + scope.previewTestGroup = function(e) { + var $dialog = $(this); + $dialog.html('<div class="crm-loading-element"></div>').parent().find('button[data-op=yes]').prop('disabled', true); + CRM.api3({ + contact: ['contact', 'get', {group: scope.testGroup.gid, options: {limit: 0}, return: 'display_name,email'}], + group: ['group', 'getsingle', {id: scope.testGroup.gid, return: 'title'}] + }).done(function(data) { + $dialog.dialog('option', 'title', ts('Send to %1', {1: data.group.title})); + var count = 0, + // Fixme: should this be in a template? + markup = '<ol>'; + _.each(data.contact.values, function(row) { + // Fixme: contact api doesn't seem capable of filtering out contacts with no email, so we're doing it client-side + if (row.email) { + count++; + markup += '<li>' + row.display_name + ' - ' + row.email + '</li>'; + } + }); + markup += '</ol>'; + markup = '<h4>' + ts('A test message will be sent to %1 people:', {1: count}) + '</h4>' + markup; + if (!count) { + markup = '<div class="messages status"><i class="crm-i fa-exclamation-triangle"></i> ' + + (data.contact.count ? ts('None of the contacts in this group have an email address.') : ts('Group is empty.')) + + '</div>'; + } + $dialog + .html(markup) + .trigger('crmLoad') + .parent().find('button[data-op=yes]').prop('disabled', !count); + }); + }; + } + }; + }); + +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockPublication.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockPublication.html new file mode 100644 index 00000000..6e82b9fa --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockPublication.html @@ -0,0 +1,16 @@ +<div class="crm-block" ng-form="subform" crm-ui-id-scope> + <div class="crm-group"> + <div crm-ui-field="{name: 'subform.visibility', title: ts('Mailing Visibility'), help: hs('visibility')}"> + <select + crm-ui-id="subform.visibility" + name="visibility" + ui-jq="select2" + ui-options="{dropdownAutoWidth : true}" + ng-model="mailing.visibility" + ng-options="v.key as v.value for v in crmMailingConst.visibility" + required + > + </select> + </div> + </div> +</div> diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockPublication.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockPublication.js new file mode 100644 index 00000000..8bb02579 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockPublication.js @@ -0,0 +1,5 @@ +(function(angular, $, _) { + angular.module('crmMailing').directive('crmMailingBlockPublication', function (crmMailingSimpleDirective) { + return crmMailingSimpleDirective('crmMailingBlockPublication', '~/crmMailing/BlockPublication.html'); + }); +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockRecipients.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockRecipients.html new file mode 100644 index 00000000..cedfa6dc --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockRecipients.html @@ -0,0 +1,15 @@ +<div ng-controller="EditRecipCtrl" class="crm-mailing-recipients-row"> + <input + type="hidden" + crm-mailing-recipients + ng-model="mailing.recipients" + crm-mandatory-groups="crmMailingConst.groupNames | filter:{is_hidden:1}" + crm-ui-id="{{crmMailingBlockRecipients.id}}" + name="{{crmMailingBlockRecipients.name}}" + ng-required="true" /> + <a crm-icon="fa-wrench" ng-click="editOptions(mailing)" class="crm-hover-button" title="{{ts('Edit Recipient Options')}}"></a> + <div ng-style="{display: permitRecipientRebuild ? '' : 'inline-block'}"> + <button ng-click="rebuildRecipients()" ng-show="permitRecipientRebuild" class="crm-button" title="{{ts('Click to refresh recipient count')}}">{{getRecipientsEstimate()}}</button> + <a ng-click="previewRecipients()" class="crm-hover-button" title="{{ts('Preview a List of Recipients')}}" style="font-weight: bold;">{{getRecipientCount()}}</a> + </div> +</div> diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockRecipients.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockRecipients.js new file mode 100644 index 00000000..fdb45313 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockRecipients.js @@ -0,0 +1,5 @@ +(function(angular, $, _) { + angular.module('crmMailing').directive('crmMailingBlockRecipients', function(crmMailingSimpleDirective) { + return crmMailingSimpleDirective('crmMailingBlockRecipients', '~/crmMailing/BlockRecipients.html'); + }); +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockResponses.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockResponses.html new file mode 100644 index 00000000..cf56f007 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockResponses.html @@ -0,0 +1,82 @@ +<!-- +Controller: EditMailingCtrl +Required vars: mailing, crmMailingConst +--> +<div class="crm-block" ng-form="responseForm" crm-ui-id-scope> + <div class="crm-group"> + <div crm-ui-field="{title: ts('Track Replies'), help: hs('override_verp')}" crm-layout="checkbox"> + <!-- Comparing data-model and UI of "override_verp", note that true/false are inverted (enabled==0,disabled==1) --> + <span ng-controller="EmailAddrCtrl"> + <input + name="override_verp" + type="checkbox" + ng-change="checkVerpChange(mailing)" + ng-model="mailing.override_verp" + ng-true-value="'0'" + ng-false-value="'1'" + /> + </span> + </div> + <div crm-ui-field="{title: ts('Forward Replies'), help: hs('forward_replies')}" crm-layout="checkbox" ng-show="'0' == mailing.override_verp"> + <input name="forward_replies" type="checkbox" ng-model="mailing.forward_replies" ng-true-value="'1'" ng-false-value="'0'" /> + </div> + <div crm-ui-field="{title: ts('Auto-Respond to Replies'), help: hs('auto_responder')}" crm-layout="checkbox" ng-show="'0' == mailing.override_verp"> + <input name="auto_responder" type="checkbox" ng-model="mailing.auto_responder" ng-true-value="'1'" ng-false-value="'0'" /> + </div> + </div> +</div> + +<hr/> + +<div class="crm-block" ng-form="subform" crm-ui-id-scope> + <div class="crm-group"> + <div crm-ui-field="{name: 'subform.reply_id', title: ts('Auto-Respond Message')}" ng-show="'0' == mailing.override_verp && '1' == mailing.auto_responder"> + <select + crm-ui-id="subform.reply_id" + name="reply_id" + ui-jq="select2" + ui-options="{dropdownAutoWidth : true}" + ng-model="mailing.reply_id" + ng-options="mc.id as mc.name for mc in crmMailingConst.headerfooterList | filter:{component_type: 'Reply'}" + required> + <option value=""></option> + </select> + </div> + <div crm-ui-field="{name: 'subform.optout_id', title: ts('Opt-out Message')}"> + <select + crm-ui-id="subform.optout_id" + name="optout_id" + ui-jq="select2" + ui-options="{dropdownAutoWidth : true}" + ng-model="mailing.optout_id" + ng-options="mc.id as mc.name for mc in crmMailingConst.headerfooterList | filter:{component_type: 'OptOut'}" + required> + <option value=""></option> + </select> + </div> + <div crm-ui-field="{name: 'subform.resubscribe_id', title: ts('Resubscribe Message')}"> + <select + crm-ui-id="subform.resubscribe_id" + name="resubscribe_id" + ui-jq="select2" + ui-options="{dropdownAutoWidth : true}" + ng-model="mailing.resubscribe_id" + ng-options="mc.id as mc.name for mc in crmMailingConst.headerfooterList | filter:{component_type: 'Resubscribe'}" + required> + <option value=""></option> + </select> + </div> + <div crm-ui-field="{name: 'subform.unsubscribe_id', title: ts('Unsubscribe Message')}"> + <select + crm-ui-id="subform.unsubscribe_id" + name="unsubscribe_id" + ui-jq="select2" + ui-options="{dropdownAutoWidth : true}" + ng-model="mailing.unsubscribe_id" + ng-options="mc.id as mc.name for mc in crmMailingConst.headerfooterList | filter:{component_type: 'Unsubscribe'}" + required> + <option value=""></option> + </select> + </div> + </div> +</div> diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockResponses.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockResponses.js new file mode 100644 index 00000000..ba7d7897 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockResponses.js @@ -0,0 +1,5 @@ +(function(angular, $, _) { + angular.module('crmMailing').directive('crmMailingBlockResponses', function(crmMailingSimpleDirective) { + return crmMailingSimpleDirective('crmMailingBlockResponses', '~/crmMailing/BlockResponses.html'); + }); +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockReview.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockReview.html new file mode 100644 index 00000000..5a0021e4 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockReview.html @@ -0,0 +1,61 @@ +<!-- +Controller: EditMailingCtrl +Required vars: mailing, attachments +--> +<div> + <div class="crm-block" ng-form="reviewForm" crm-ui-id-scope> + <div class="crm-group"> + <div crm-ui-field="{title: ts('Mailing Name')}"> + {{mailing.name}} + </div> + <div crm-ui-field="{title: ts('Recipients')}"> + <div ng-controller="ViewRecipCtrl"> + <div ng-controller="EditRecipCtrl"> + <div><a crm-icon="fa-users" class="crm-hover-button action-item" ng-click="previewRecipients()">{{getRecipientCount()}}</a></div> + <div ng-show="getIncludesAsString(mailing)"> + (<strong>{{ts('Include:')}}</strong> {{getIncludesAsString(mailing)}}) + </div> + <div ng-show="getExcludesAsString(mailing)"> + (<strong>{{ts('Exclude:')}}</strong> <s>{{getExcludesAsString(mailing)}}</s>) + </div> + </div> + </div> + </div> + <div crm-ui-field="{title: ts('Content')}"> + <span ng-show="mailing.body_html"><a crm-icon="fa-television" class="crm-hover-button action-item" ng-click="previewMailing(mailing, 'html')">{{ts('HTML')}}</a></span> + <span ng-show="mailing.body_html || mailing.body_text"><a crm-icon="fa-file-text-o" class="crm-hover-button action-item" ng-click="previewMailing(mailing, 'text')">{{ts('Plain Text')}}</a></span> + </div> + <div crm-ui-field="{title: ts('Attachments')}"> + <div ng-repeat="file in attachments.files"> + <a ng-href="{{file.url}}" target="_blank">{{file.name}}</a> + </div> + <div ng-repeat="item in attachments.uploader.queue"> + {{item.file.name}} + </div> + <div ng-show="!attachments.files.length && !attachments.uploader.queue.length"><em>{{ts('None')}}</em></div> + </div> + <div ng-if="crmMailingConst.isMultiLingual" crm-ui-field="{title: ts('Language')}"> + {{crmMailingConst.enabledLanguages[mailing.language]}} + </div> + <div crm-ui-field="{title: ts('Tracking')}"> + <span crm-mailing-review-bool crm-on="mailing.url_tracking=='1'" crm-title="ts('Click-Throughs')"></span> + <span crm-mailing-review-bool crm-on="mailing.open_tracking=='1'" crm-title="ts('Opens')"></span> + </div> + <div crm-ui-field="{title: ts('Responding')}"> + <div> + <span crm-mailing-review-bool crm-on="mailing.override_verp=='0'" crm-title="ts('Track Replies')"></span> + <span crm-mailing-review-bool crm-on="mailing.override_verp=='0' && mailing.forward_replies=='1'" crm-title="ts('Forward Replies')"></span> + </div> + <div ng-controller="PreviewComponentCtrl"> + <span ng-show="mailing.override_verp == '0' && mailing.auto_responder"><a crm-icon="fa-envelope" class="crm-hover-button action-item" ng-click="previewComponent(ts('Auto-Respond'), mailing.reply_id)">{{ts('Auto-Respond')}}</a></span> + <span><a crm-icon="fa-envelope" class="crm-hover-button action-item" ng-click="previewComponent(ts('Opt-out'), mailing.optout_id)">{{ts('Opt-out')}}</a></span> + <span><a crm-icon="fa-envelope" class="crm-hover-button action-item" ng-click="previewComponent(ts('Resubscribe'), mailing.resubscribe_id)">{{ts('Resubscribe')}}</a></span> + <span><a crm-icon="fa-envelope" class="crm-hover-button action-item" ng-click="previewComponent(ts('Unsubscribe'), mailing.unsubscribe_id)">{{ts('Unsubscribe')}}</a></span> + </div> + </div> + <div crm-ui-field="{title: ts('Publication')}"> + {{mailing.visibility}} + </div> + </div> + </div> +</div> diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockReview.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockReview.js new file mode 100644 index 00000000..94968e3e --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockReview.js @@ -0,0 +1,26 @@ +(function(angular, $, _) { + + angular.module('crmMailing').directive('crmMailingBlockReview', function (crmMailingPreviewMgr) { + return { + scope: { + crmMailing: '@', + crmMailingAttachments: '@' + }, + templateUrl: '~/crmMailing/BlockReview.html', + link: function (scope, elm, attr) { + scope.$parent.$watch(attr.crmMailing, function(newValue){ + scope.mailing = newValue; + }); + scope.$parent.$watch(attr.crmMailingAttachments, function(newValue){ + scope.attachments = newValue; + }); + scope.crmMailingConst = CRM.crmMailing; + scope.ts = CRM.ts(null); + scope.previewMailing = function previewMailing(mailing, mode) { + return crmMailingPreviewMgr.preview(mailing, mode); + }; + } + }; + }); + +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockSchedule.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockSchedule.html new file mode 100644 index 00000000..75cf19b2 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockSchedule.html @@ -0,0 +1,13 @@ +<div class="crmMailing-schedule-outer" crm-mailing-radio-date="schedule" ng-model="mailing.scheduled_date"> + <div class="crmMailing-schedule-inner"> + <div> + <input ng-model="schedule.mode" type="radio" name="send" value="now" id="schedule-send-now"/> + <label for="schedule-send-now">{{ts('Send immediately')}}</label> + </div> + <div> + <input ng-model="schedule.mode" type="radio" name="send" value="at" id="schedule-send-at"/> + <label for="schedule-send-at">{{ts('Send at:')}}</label> + <input crm-ui-datepicker ng-model="schedule.datetime" ng-required="schedule.mode == 'at'"/> + </div> + </div> +</div> diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockSchedule.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockSchedule.js new file mode 100644 index 00000000..251449d7 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockSchedule.js @@ -0,0 +1,5 @@ +(function(angular, $, _) { + angular.module('crmMailing').directive('crmMailingBlockSchedule', function(crmMailingSimpleDirective) { + return crmMailingSimpleDirective('crmMailingBlockSchedule', '~/crmMailing/BlockSchedule.html'); + }); +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockSummary.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockSummary.html new file mode 100644 index 00000000..6050e448 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockSummary.html @@ -0,0 +1,29 @@ +<!-- +Controller: EditMailingCtrl +Required vars: mailing, crmMailingConst +FIXME: Don't hardcode table-based layout! +--> +<div class="crm-block" ng-form="subform" crm-ui-id-scope> + <div class="crm-group"> + <div crm-ui-field="{name: 'subform.mailingName', title: ts('Mailing Name'), help: hs('name')}"> + <div> + <input + crm-ui-id="subform.mailingName" + type="text" + class="crm-form-text" + ng-model="mailing.name" + placeholder="Mailing Name" + required + name="mailingName" /> + </div> + </div> + <div crm-ui-field="{name: 'subform.campaign', title: ts('Campaign'), help: hs({id: 'id-campaign_id', file: 'CRM/Campaign/Form/addCampaignToComponent'})}" ng-show="crmMailingConst.campaignEnabled"> + <input + crm-entityref="{entity: 'Campaign', select: {allowClear: true, placeholder: ts('Select Campaign')}}" + crm-ui-id="subform.campaign" + name="campaign" + ng-model="mailing.campaign_id" + /> + </div> + </div> +</div> diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockSummary.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockSummary.js new file mode 100644 index 00000000..d16d9fc3 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockSummary.js @@ -0,0 +1,5 @@ +(function(angular, $, _) { + angular.module('crmMailing').directive('crmMailingBlockSummary', function(crmMailingSimpleDirective) { + return crmMailingSimpleDirective('crmMailingBlockSummary', '~/crmMailing/BlockSummary.html'); + }); +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockTemplates.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockTemplates.html new file mode 100644 index 00000000..38a37a91 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockTemplates.html @@ -0,0 +1,9 @@ +<div ng-controller="MsgTemplateCtrl" class="crm-mailing-templates-row"> + <input + type="hidden" + crm-mailing-templates + ng-model="mailing.msg_template_id" + crm-ui-id="{{crmMailingBlockTemplates.id}}" + name="{{crmMailingBlockTemplates.name}}" /> + <a crm-icon="fa-floppy-o" ng-if="checkPerm('edit message templates')" ng-click="saveTemplate(mailing)" class="crm-hover-button" title="{{ts('Save As')}}"></a> +</div>
\ No newline at end of file diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockTemplates.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockTemplates.js new file mode 100644 index 00000000..9ee2efbf --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockTemplates.js @@ -0,0 +1,5 @@ +(function(angular, $, _) { + angular.module('crmMailing').directive('crmMailingBlockTemplates', function(crmMailingSimpleDirective) { + return crmMailingSimpleDirective('crmMailingBlockTemplates', '~/crmMailing/BlockTemplates.html'); + }); +})(angular, CRM.$, CRM._);
\ No newline at end of file diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockTracking.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockTracking.html new file mode 100644 index 00000000..a4ed95e0 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockTracking.html @@ -0,0 +1,14 @@ +<!-- +Controller: EditMailingCtrl +Required vars: mailing +--> +<div class="crm-block" ng-form="subform" crm-ui-id-scope> + <div class="crm-group"> + <div crm-ui-field="{name: 'subform.url_tracking', title: ts('Track Click-Throughs'), help: hs('url_tracking')}" crm-layout="checkbox"> + <input crm-ui-id="subform.url_tracking" name="url_tracking" type="checkbox" ng-model="mailing.url_tracking" ng-true-value="'1'" ng-false-value="'0'" /> + </div> + <div crm-ui-field="{name: 'subform.open_tracking', title: ts('Track Opens'), help: hs('open_tracking')}" crm-layout="checkbox"> + <input crm-ui-id="subform.open_tracking" name="open_tracking" type="checkbox" ng-model="mailing.open_tracking" ng-true-value="'1'" ng-false-value="'0'" /> + </div> + </div> +</div> diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockTracking.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockTracking.js new file mode 100644 index 00000000..b8502b23 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BlockTracking.js @@ -0,0 +1,5 @@ +(function(angular, $, _) { + angular.module('crmMailing').directive('crmMailingBlockTracking', function(crmMailingSimpleDirective) { + return crmMailingSimpleDirective('crmMailingBlockTracking', '~/crmMailing/BlockTracking.html'); + }); +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BodyHtml.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BodyHtml.html new file mode 100644 index 00000000..b38c28c7 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BodyHtml.html @@ -0,0 +1,26 @@ +<!-- +Required vars: mailing +--> +<div ng-form="htmlForm" crm-ui-id-scope> + <div ng-controller="EmailBodyCtrl"> + <div style="float: right;"> + <input crm-mailing-token on-select="$broadcast('insert:body_html', token.name)" tabindex="-1" style="z-index:1"> + </div> + + <div> + <textarea + crm-ui-id="htmlForm.body_html" + crm-ui-richtext + name="body_html" + crm-ui-insert-rx="insert:body_html" + ng-model="mailing.body_html" + ng-blur="checkTokens(mailing, 'body_html', 'insert:body_html')" + data-preset="civimail" + ></textarea> + <span ng-model="body_html_tokens" crm-ui-validate="hasAllTokens(mailing, 'body_html')"></span> + <div ng-show="htmlForm.$error.crmUiValidate" class="crmMailing-error-link"> + {{ts('Required tokens are missing.')}} <a class="helpicon" ng-click="checkTokens(mailing, 'body_html', 'insert:body_html')"></a> + </div> + </div> + </div> +</div> diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BodyHtml.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BodyHtml.js new file mode 100644 index 00000000..242f530c --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BodyHtml.js @@ -0,0 +1,5 @@ +(function(angular, $, _) { + angular.module('crmMailing').directive('crmMailingBodyHtml', function(crmMailingSimpleDirective) { + return crmMailingSimpleDirective('crmMailingBodyHtml', '~/crmMailing/BodyHtml.html'); + }); +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BodyText.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BodyText.html new file mode 100644 index 00000000..598c7792 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BodyText.html @@ -0,0 +1,24 @@ +<!-- +Required vars: mailing, crmMailingConst +--> +<div ng-form="textForm" crm-ui-id-scope> + <div ng-controller="EmailBodyCtrl"> + <div style="float: right;"> + <input crm-mailing-token on-select="$broadcast('insert:body_text', token.name)" tabindex="-1"/> + </div> + + <div> + <textarea + crm-ui-id="textForm.body_text" + crm-ui-insert-rx="insert:body_text" + name="body_text" + ng-model="mailing.body_text" + ng-blur="checkTokens(mailing, 'body_text', 'insert:body_text')" + ></textarea> + <span ng-model="body_text_tokens" crm-ui-validate="hasAllTokens(mailing, 'body_text')"></span> + <div ng-show="textForm.$error.crmUiValidate" class="crmMailing-error-link"> + {{ts('Required tokens are missing.')}} <a class="helpicon" ng-click="checkTokens(mailing, 'body_text', 'insert:body_text')"></a> + </div> + </div> + </div> +</div> diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BodyText.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BodyText.js new file mode 100644 index 00000000..0f04491c --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/BodyText.js @@ -0,0 +1,5 @@ +(function(angular, $, _) { + angular.module('crmMailing').directive('crmMailingBodyText', function(crmMailingSimpleDirective) { + return crmMailingSimpleDirective('crmMailingBodyText', '~/crmMailing/BodyText.html'); + }); +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/CreateMailingCtrl.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/CreateMailingCtrl.js new file mode 100644 index 00000000..08a61713 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/CreateMailingCtrl.js @@ -0,0 +1,8 @@ +(function(angular, $, _) { + + angular.module('crmMailing').controller('CreateMailingCtrl', function EditMailingCtrl($scope, selectedMail, $location) { + $location.path("/mailing/" + selectedMail.id); + $location.replace(); + }); + +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/EditMailingCtrl.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/EditMailingCtrl.js new file mode 100644 index 00000000..91f6db3f --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/EditMailingCtrl.js @@ -0,0 +1,133 @@ +(function(angular, $, _) { + + angular.module('crmMailing').controller('EditMailingCtrl', function EditMailingCtrl($scope, selectedMail, $location, crmMailingMgr, crmStatus, attachments, crmMailingPreviewMgr, crmBlocker, CrmAutosaveCtrl, $timeout, crmUiHelp) { + var APPROVAL_STATUSES = {'Approved': 1, 'Rejected': 2, 'None': 3}; + + $scope.mailing = selectedMail; + $scope.attachments = attachments; + $scope.crmMailingConst = CRM.crmMailing; + $scope.checkPerm = CRM.checkPerm; + + var ts = $scope.ts = CRM.ts(null); + $scope.hs = crmUiHelp({file: 'CRM/Mailing/MailingUI'}); + var block = $scope.block = crmBlocker(); + var myAutosave = null; + + var templateTypes = _.where(CRM.crmMailing.templateTypes, {name: selectedMail.template_type}); + if (!templateTypes[0]) throw 'Unrecognized template type: ' + selectedMail.template_type; + $scope.mailingEditorUrl = templateTypes[0].editorUrl; + + $scope.isSubmitted = function isSubmitted() { + return _.size($scope.mailing.jobs) > 0; + }; + + // usage: approve('Approved') + $scope.approve = function approve(status, options) { + $scope.mailing.approval_status_id = APPROVAL_STATUSES[status]; + return myAutosave.suspend($scope.submit(options)); + }; + + // @return Promise + $scope.previewMailing = function previewMailing(mailing, mode) { + return crmMailingPreviewMgr.preview(mailing, mode); + }; + + // @return Promise + $scope.sendTest = function sendTest(mailing, attachments, recipient) { + var savePromise = crmMailingMgr.save(mailing) + .then(function() { + return attachments.save(); + }); + return block(crmStatus({start: ts('Saving...'), success: ''}, savePromise) + .then(function() { + crmMailingPreviewMgr.sendTest(mailing, recipient); + })); + }; + + // @return Promise + $scope.submit = function submit(options) { + options = options || {}; + if (block.check()) { + return; + } + + var promise = crmMailingMgr.save($scope.mailing) + .then(function() { + // pre-condition: the mailing exists *before* saving attachments to it + return $scope.attachments.save(); + }) + .then(function() { + return crmMailingMgr.submit($scope.mailing); + }) + .then(function() { + if (!options.stay) { + $scope.leave('scheduled'); + } + }) + ; + return block(crmStatus({start: ts('Submitting...'), success: ts('Submitted')}, promise)); + }; + + // @return Promise + $scope.save = function save() { + return block(crmStatus(null, + crmMailingMgr + .save($scope.mailing) + .then(function() { + // pre-condition: the mailing exists *before* saving attachments to it + return $scope.attachments.save(); + }) + )); + }; + + // @return Promise + $scope.delete = function cancel() { + return block(crmStatus({start: ts('Deleting...'), success: ts('Deleted')}, + crmMailingMgr.delete($scope.mailing) + .then(function() { + $scope.leave('unscheduled'); + }) + )); + }; + + // @param string listingScreen 'archive', 'scheduled', 'unscheduled' + $scope.leave = function leave(listingScreen) { + switch (listingScreen) { + case 'archive': + window.location = CRM.url('civicrm/mailing/browse/archived', { + reset: 1 + }); + break; + case 'scheduled': + window.location = CRM.url('civicrm/mailing/browse/scheduled', { + reset: 1, + scheduled: 'true' + }); + break; + case 'unscheduled': + /* falls through */ + default: + window.location = CRM.url('civicrm/mailing/browse/unscheduled', { + reset: 1, + scheduled: 'false' + }); + } + }; + + myAutosave = new CrmAutosaveCtrl({ + save: $scope.save, + saveIf: function() { + return true; + }, + model: function() { + return [$scope.mailing, $scope.attachments.getAutosaveSignature()]; + }, + form: function() { + return $scope.crmMailing; + } + }); + $timeout(myAutosave.start); + $scope.$on('$destroy', myAutosave.stop); + }); + +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/EditMailingCtrl/2step.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/EditMailingCtrl/2step.html new file mode 100644 index 00000000..cabd6f30 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/EditMailingCtrl/2step.html @@ -0,0 +1,63 @@ +<div ng-form="crmMailingSubform"> + <div class="crm-block crm-form-block crmMailing"> + <div crm-ui-wizard> + <div crm-ui-wizard-step crm-title="ts('Define Mailing')" ng-form="defineForm"> + <div crm-ui-tab-set> + <div crm-ui-tab id="tab-mailing" crm-title="ts('Mailing')"> + <div crm-mailing-block-summary crm-mailing="mailing"></div> + <div crm-mailing-block-mailing crm-mailing="mailing"></div> + <div crm-ui-accordion="{title: ts('HTML'), help: hs('html')}"> + <div crm-mailing-body-html crm-mailing="mailing"></div> + </div> + <div crm-ui-accordion="{title: ts('Plain Text'), collapsed: !mailing.body_text, help: hs('text')}"> + <div crm-mailing-body-text crm-mailing="mailing"></div> + </div> + <span ng-model="placeholder" crm-ui-validate="mailing.body_html || mailing.body_text"></span> + </div> + <div crm-ui-tab id="tab-attachment" crm-title="ts('Attachments')"> + <div crm-attachments="attachments"></div> + </div> + <div crm-ui-tab id="tab-header" crm-title="ts('Header and Footer')"> + <div crm-mailing-block-header-footer crm-mailing="mailing"></div> + </div> + <div crm-ui-tab id="tab-pub" crm-title="ts('Publication')"> + <div crm-mailing-block-publication crm-mailing="mailing"></div> + </div> + <div crm-ui-tab id="tab-response" crm-title="ts('Responses')"> + <div crm-mailing-block-responses crm-mailing="mailing"></div> + </div> + <div crm-ui-tab id="tab-tracking" crm-title="ts('Tracking')"> + <div crm-mailing-block-tracking crm-mailing="mailing"></div> + </div> + </div> + <div crm-ui-accordion="{title: ts('Preview')}"> + <div crm-mailing-block-preview crm-mailing="mailing" on-preview="previewMailing(mailing, preview.mode)" on-send="sendTest(mailing, attachments, preview.recipient)"></div> + </div> + </div> + <div crm-ui-wizard-step crm-title="ts('Review and Schedule')" ng-form="reviewForm"> + <div crm-ui-accordion="{title: ts('Review')}"> + <div crm-mailing-block-review crm-mailing="mailing" crm-mailing-attachments="attachments"></div> + </div> + <div crm-ui-accordion="{title: ts('Schedule')}"> + <div crm-mailing-block-schedule crm-mailing="mailing"></div> + </div> + <center> + <a class="button crmMailing-submit-button crmMailing-btn-primary" ng-click="submit()" ng-class="{blocking: block.check(), disabled: crmMailingSubform.$invalid}"> + <div>{{ts('Submit Mailing')}}</div> + </a> + </center> + </div> + + <span crm-ui-wizard-buttons style="float:right;"> + <button + crm-icon="fa-trash" + ng-show="checkPerm('delete in CiviMail')" + class="crmMailing-btn-danger-outline" + ng-disabled="block.check()" + crm-confirm="{title:ts('Delete Draft'), message:ts('Are you sure you want to permanently delete this mailing?')}" + on-yes="delete()">{{ts('Delete Draft')}}</button> + <button crm-icon="fa-floppy-o" ng-disabled="block.check()" ng-click="save().then(leave)" class="crmMailing-btn-secondary-outline">{{ts('Save Draft')}}</button> + </span> + </div> + </div> +</div> diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/EditMailingCtrl/base.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/EditMailingCtrl/base.html new file mode 100644 index 00000000..92a97b24 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/EditMailingCtrl/base.html @@ -0,0 +1,8 @@ +<div crm-ui-debug="mailing"></div> + +<div ng-show="isSubmitted()"> + {{ts('This mailing has been submitted.')}} +</div> + +<form name="crmMailing" novalidate ng-hide="isSubmitted()" ng-include="mailingEditorUrl"> +</form>
\ No newline at end of file diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/EditMailingCtrl/unified.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/EditMailingCtrl/unified.html new file mode 100644 index 00000000..cc3056fa --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/EditMailingCtrl/unified.html @@ -0,0 +1,50 @@ +<div ng-form="crmMailingSubform"> + <div class="crm-block crm-form-block crmMailing"> + + <div crm-mailing-block-summary crm-mailing="mailing"></div> + <div crm-mailing-block-mailing crm-mailing="mailing"></div> + + <div crm-ui-tab-set> + <div crm-ui-tab id="tab-html" crm-title="ts('HTML')"> + <div crm-mailing-body-html crm-mailing="mailing"></div> + </div> + <div crm-ui-tab id="tab-text" crm-title="ts('Plain Text')"> + <div crm-mailing-body-text crm-mailing="mailing"></div> + </div> + <span ng-model="placeholder" crm-ui-validate="mailing.body_html || mailing.body_text"></span> + <div crm-ui-tab id="tab-attachment" crm-title="ts('Attachments')"> + <div crm-attachments="attachments"></div> + </div> + <div crm-ui-tab id="tab-header" crm-title="ts('Header and Footer')"> + <div crm-mailing-block-header-footer crm-mailing="mailing"></div> + </div> + <div crm-ui-tab id="tab-pub" crm-title="ts('Publication')"> + <div crm-mailing-block-publication crm-mailing="mailing"></div> + </div> + <div crm-ui-tab id="tab-response" crm-title="ts('Responses')"> + <div crm-mailing-block-responses crm-mailing="mailing"></div> + </div> + <div crm-ui-tab id="tab-tracking" crm-title="ts('Tracking')"> + <div crm-mailing-block-tracking crm-mailing="mailing"></div> + </div> + </div> + + <div crm-ui-accordion="{title: ts('Preview')}"> + <div crm-mailing-block-preview crm-mailing="mailing" on-preview="previewMailing(mailing, preview.mode)" on-send="sendTest(mailing, attachments, preview.recipient)"></div> + </div> + + <div crm-ui-accordion="{title: ts('Schedule')}"> + <div crm-mailing-block-schedule crm-mailing="mailing"></div> + </div> + + <button crm-icon="fa-paper-plane" class="crmMailing-btn-primary" ng-disabled="block.check() || crmMailingSubform.$invalid" ng-click="submit()">{{ts('Submit Mailing')}}</button> + <button crm-icon="fa-floppy-o" class="crmMailing-btn-secondary-outline" ng-disabled="block.check()" ng-click="save().then(leave)">{{ts('Save Draft')}}</button> + <button + crm-icon="fa-trash" + ng-show="checkPerm('delete in CiviMail')" + class="crmMailing-btn-danger-outline" + ng-disabled="block.check()" + crm-confirm="{title:ts('Delete Draft'), message:ts('Are you sure you want to permanently delete this mailing?')}" + on-yes="delete()">{{ts('Delete Draft')}}</button> + </div> +</div> diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/EditMailingCtrl/unified2.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/EditMailingCtrl/unified2.html new file mode 100644 index 00000000..1506b8d5 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/EditMailingCtrl/unified2.html @@ -0,0 +1,46 @@ +<div ng-form="crmMailingSubform"> + <div class="crm-block crm-form-block crmMailing"> + + <div crm-mailing-block-summary crm-mailing="mailing"></div> + <div crm-mailing-block-mailing crm-mailing="mailing"></div> + + <div crm-ui-accordion="{title: ts('HTML'), help: hs('html')}"> + <div crm-mailing-body-html crm-mailing="mailing"></div> + </div> + <div crm-ui-accordion="{title: ts('Plain Text'), collapsed: !mailing.body_text, help: hs('text')}"> + <div crm-mailing-body-text crm-mailing="mailing"></div> + </div> + <span ng-model="placeholder" crm-ui-validate="mailing.body_html || mailing.body_text"></span> + <div crm-ui-accordion="{title: ts('Header and Footer'), collapsed: true}" id="tab-header"> + <div crm-mailing-block-header-footer crm-mailing="mailing"></div> + </div> + <div crm-ui-accordion="{title: ts('Attachments'), collapsed: true}" id="tab-attachment"> + <div crm-attachments="attachments"></div> + </div> + <div crm-ui-accordion="{title: ts('Publication'), collapsed: true}" id="tab-pub"> + <div crm-mailing-block-publication crm-mailing="mailing"></div> + </div> + <div crm-ui-accordion="{title: ts('Responses'), collapsed: true}" id="tab-response"> + <div crm-mailing-block-responses crm-mailing="mailing"></div> + </div> + <div crm-ui-accordion="{title: ts('Tracking'), collapsed: true}" id="tab-tracking"> + <div crm-mailing-block-tracking crm-mailing="mailing"></div> + </div> + <div crm-ui-accordion="{title: ts('Preview')}"> + <div crm-mailing-block-preview crm-mailing="mailing" on-preview="previewMailing(mailing, preview.mode)" on-send="sendTest(mailing, attachments, preview.recipient)"></div> + </div> + <div crm-ui-accordion="{title: ts('Schedule')}" id="tab-schedule"> + <div crm-mailing-block-schedule crm-mailing="mailing"></div> + </div> + + <button crm-icon="fa-paper-plane" class="crmMailing-btn-primary" ng-disabled="block.check() || crmMailingSubform.$invalid" ng-click="submit()">{{ts('Submit Mailing')}}</button> + <button crm-icon="fa-floppy-o" class="crmMailing-secondary-outline" ng-disabled="block.check()" ng-click="save().then(leave)">{{ts('Save Draft')}}</button> + <button + crm-icon="fa-trash" + ng-show="checkPerm('delete in CiviMail')" + class="crmMailing-btn-danger-outline" + ng-disabled="block.check()" + crm-confirm="{title:ts('Delete Draft'), message:ts('Are you sure you want to permanently delete this mailing?')}" + on-yes="delete()">{{ts('Delete Draft')}}</button> + </div> +</div> diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/EditMailingCtrl/wizard.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/EditMailingCtrl/wizard.html new file mode 100644 index 00000000..9854cc5c --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/EditMailingCtrl/wizard.html @@ -0,0 +1,66 @@ +<div ng-form="crmMailingSubform"> + <div class="crm-block crm-form-block crmMailing"> + + <div crm-ui-wizard> + + <div crm-ui-wizard-step crm-title="ts('Content')" ng-form="contentForm"> + <div crm-mailing-block-summary crm-mailing="mailing"></div> + <div crm-mailing-block-mailing crm-mailing="mailing"></div> + <div crm-ui-accordion="{title: ts('HTML'), help: hs('html')}"> + <div crm-mailing-body-html crm-mailing="mailing"></div> + </div> + <div crm-ui-accordion="{title: ts('Plain Text'), collapsed: !mailing.body_text, help: hs('text')}"> + <div crm-mailing-body-text crm-mailing="mailing"></div> + </div> + <span ng-model="placeholder" crm-ui-validate="mailing.body_html || mailing.body_text"></span> + <div crm-ui-accordion="{title: ts('Header and Footer'), collapsed: true}"> + <div crm-mailing-block-header-footer crm-mailing="mailing"></div> + </div> + <div crm-ui-accordion="{title: ts('Attachments'), collapsed: true}"> + <div crm-attachments="attachments"></div> + </div> + <div crm-ui-accordion="{title: ts('Preview')}"> + <div crm-mailing-block-preview crm-mailing="mailing" on-preview="previewMailing(mailing, preview.mode)" on-send="sendTest(mailing, attachments, preview.recipient)"></div> + </div> + </div> + + <div crm-ui-wizard-step crm-title="ts('Options')" ng-form="optionsForm"> + <div crm-ui-accordion="{title: ts('Schedule')}"> + <div crm-mailing-block-schedule crm-mailing="mailing"></div> + </div> + + <div crm-ui-accordion="{title: ts('Responses'), collapsed: true}"> + <div crm-mailing-block-responses crm-mailing="mailing"></div> + </div> + <div crm-ui-accordion="{title: ts('Tracking'), collapsed: true}"> + <div crm-mailing-block-tracking crm-mailing="mailing"></div> + </div> + <div crm-ui-accordion="{title: ts('Publication'), collapsed: true}"> + <div crm-mailing-block-publication crm-mailing="mailing"></div> + </div> + </div> + + <div crm-ui-wizard-step crm-title="ts('Review')" ng-form="reviewForm"> + <div crm-ui-accordion="{title: ts('Review')}"> + <div crm-mailing-block-review crm-mailing="mailing" crm-mailing-attachments="attachments"></div> + </div> + <center> + <a class="button crmMailing-submit-button crmMailing-btn-primary" ng-click="submit()" ng-class="{blocking: block.check(), disabled: crmMailingSubform.$invalid}"> + <div>{{ts('Submit Mailing')}}</div> + </a> + </center> + </div> + + <span crm-ui-wizard-buttons style="float:right;"> + <button + crm-icon="fa-trash" + ng-show="checkPerm('delete in CiviMail')" + class="crmMailing-btn-danger-outline" + ng-disabled="block.check()" + crm-confirm="{title:ts('Delete Draft'), message:ts('Are you sure you want to permanently delete this mailing?')}" + on-yes="delete()">{{ts('Delete Draft')}}</button> + <button crm-icon="fa-floppy-o" class="crmMailing-btn-secondary-outline" ng-disabled="block.check()" ng-click="save().then(leave)">{{ts('Save Draft')}}</button> + </span> + </div> + </div> +</div> diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/EditMailingCtrl/workflow.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/EditMailingCtrl/workflow.html new file mode 100644 index 00000000..affa76d8 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/EditMailingCtrl/workflow.html @@ -0,0 +1,73 @@ +<div ng-form="crmMailingSubform"> + <div class="crm-block crm-form-block crmMailing"> + + <div crm-ui-wizard> + + <div crm-ui-wizard-step="10" crm-title="ts('Content')" ng-form="contentForm" ng-if="checkPerm('create mailings') || checkPerm('access CiviMail')"> + <div crm-mailing-block-summary crm-mailing="mailing"></div> + <div crm-mailing-block-mailing crm-mailing="mailing"></div> + <div crm-ui-accordion="{title: ts('HTML'), help: hs('html')}"> + <div crm-mailing-body-html crm-mailing="mailing"></div> + </div> + <div crm-ui-accordion="{title: ts('Plain Text'), collapsed: !mailing.body_text, help: hs('text')}"> + <div crm-mailing-body-text crm-mailing="mailing"></div> + </div> + <span ng-model="placeholder" crm-ui-validate="mailing.body_html || mailing.body_text"></span> + <div crm-ui-accordion="{title: ts('Header and Footer'), collapsed: true}"> + <div crm-mailing-block-header-footer crm-mailing="mailing"></div> + </div> + <div crm-ui-accordion="{title: ts('Attachments'), collapsed: true}"> + <div crm-attachments="attachments"></div> + </div> + <div crm-ui-accordion="{title: ts('Preview')}"> + <div crm-mailing-block-preview crm-mailing="mailing" on-preview="previewMailing(mailing, preview.mode)" on-send="sendTest(mailing, attachments, preview.recipient)"></div> + </div> + </div> + + <div crm-ui-wizard-step="20" crm-title="ts('Options')" ng-form="optionsForm" ng-if="checkPerm('create mailings') || checkPerm('access CiviMail')"> + <div crm-ui-accordion="{title: ts('Responses'), collapsed: true}"> + <div crm-mailing-block-responses crm-mailing="mailing"></div> + </div> + <div crm-ui-accordion="{title: ts('Tracking'), collapsed: true}"> + <div crm-mailing-block-tracking crm-mailing="mailing"></div> + </div> + <div crm-ui-accordion="{title: ts('Publication'), collapsed: true}"> + <div crm-mailing-block-publication crm-mailing="mailing"></div> + </div> + </div> + + <div crm-ui-wizard-step="40" crm-title="ts('Review')" ng-form="schedForm" ng-if="checkPerm('schedule mailings') || checkPerm('access CiviMail')"> + <div crm-ui-accordion="{title: ts('Review')}"> + <div crm-mailing-block-review crm-mailing="mailing" crm-mailing-attachments="attachments"></div> + </div> + <div crm-ui-accordion="{title: ts('Schedule')}"> + <div crm-mailing-block-schedule crm-mailing="mailing"></div> + </div> + <div crm-ui-accordion="{title: ts('Approval')}" ng-if="checkPerm('approve mailings') || checkPerm('access CiviMail')"> + <div crm-mailing-block-approve crm-mailing="mailing"></div> + </div> + <center ng-if="!checkPerm('approve mailings') && !checkPerm('access CiviMail')"> + <a class="button crmMailing-submit-button crmMailing-btn-primary" ng-click="submit()" ng-class="{blocking: block.check(), disabled: crmMailingSubform.$invalid}"> + <div>{{ts('Submit Mailing')}}</div> + </a> + </center> + <center ng-if="checkPerm('approve mailings') || checkPerm('access CiviMail')"> + <a class="button crmMailing-submit-button crmMailing-btn-primary" ng-click="approve('Approved')" ng-class="{blocking: block.check(), disabled: crmMailingSubform.$invalid}"> + <div>{{ts('Submit and Approve Mailing')}}</div> + </a> + </center> + </div> + + <span crm-ui-wizard-buttons style="float:right;"> + <button + crm-icon="fa-trash" + ng-show="checkPerm('delete in CiviMail')" + class="crmMailing-btn-danger-outline" + ng-disabled="block.check()" + crm-confirm="{title:ts('Delete Draft'), message:ts('Are you sure you want to permanently delete this mailing?')}" + on-yes="delete()">{{ts('Delete Draft')}}</button> + <button crm-icon="fa-floppy-o" ng-disabled="block.check()" ng-click="save().then(leave)" class="crmMailing-btn-secondary-outline">{{ts('Save Draft')}}</button> + </span> + </div> + </div> +</div> diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/EditRecipCtrl.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/EditRecipCtrl.js new file mode 100644 index 00000000..9fca637a --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/EditRecipCtrl.js @@ -0,0 +1,136 @@ +(function(angular, $, _) { + + // Controller for the edit-recipients fields ( + // WISHLIST: Move most of this to a (cache-enabled) service + // Scope members: + // - [input] mailing: object + // - [output] recipients: array of recipient records + angular.module('crmMailing').controller('EditRecipCtrl', function EditRecipCtrl($scope, dialogService, crmApi, crmMailingMgr, $q, crmMetadata, crmStatus, crmMailingCache) { + // Time to wait before triggering AJAX update to recipients list + var RECIPIENTS_DEBOUNCE_MS = 100; + var SETTING_DEBOUNCE_MS = 5000; + var RECIPIENTS_PREVIEW_LIMIT = 50; + + var ts = $scope.ts = CRM.ts(null); + + $scope.isMailingList = function isMailingList(group) { + var GROUP_TYPE_MAILING_LIST = '2'; + return _.contains(group.group_type, GROUP_TYPE_MAILING_LIST); + }; + + $scope.recipients = null; + $scope.outdated = null; + $scope.permitRecipientRebuild = null; + + $scope.getRecipientsEstimate = function() { + var ts = $scope.ts; + if ($scope.recipients === null) { + return ts('Estimating...'); + } + if ($scope.recipients === 0) { + return ts('Estimate recipient count'); + } + return ts('Refresh recipient count'); + }; + + $scope.getRecipientCount = function() { + var ts = $scope.ts; + if ($scope.recipients === 0) { + return ts('No Recipients'); + } + else if ($scope.recipients > 0) { + return ts('~%1 recipients', {1 : $scope.recipients}); + } + else if ($scope.outdated) { + return ts('(unknown)'); + } + else { + return $scope.permitRecipientRebuild ? ts('(unknown)') : ts('Estimating...'); + } + }; + + // We monitor four fields -- use debounce so that changes across the + // four fields can settle-down before AJAX. + var refreshRecipients = _.debounce(function() { + $scope.$apply(function() { + if (!$scope.mailing) { + return; + } + crmMailingMgr.previewRecipientCount($scope.mailing, crmMailingCache, !$scope.permitRecipientRebuild).then(function(recipients) { + $scope.outdated = ($scope.permitRecipientRebuild && _.difference($scope.mailing.recipients, crmMailingCache.get('mailing-' + $scope.mailing.id + '-recipient-params')) !== 0); + $scope.recipients = recipients; + }); + }); + }, RECIPIENTS_DEBOUNCE_MS); + $scope.$watchCollection("mailing.dedupe_email", refreshRecipients); + $scope.$watchCollection("mailing.location_type_id", refreshRecipients); + $scope.$watchCollection("mailing.email_selection_method", refreshRecipients); + $scope.$watchCollection("mailing.recipients.groups.include", refreshRecipients); + $scope.$watchCollection("mailing.recipients.groups.exclude", refreshRecipients); + $scope.$watchCollection("mailing.recipients.mailings.include", refreshRecipients); + $scope.$watchCollection("mailing.recipients.mailings.exclude", refreshRecipients); + + // refresh setting at a duration on 5sec + var refreshSetting = _.debounce(function() { + $scope.$apply(function() { + crmApi('Setting', 'getvalue', {"name": 'auto_recipient_rebuild', "return": "value"}).then(function(response) { + $scope.permitRecipientRebuild = (response.result === 0); + }); + }); + }, SETTING_DEBOUNCE_MS); + $scope.$watchCollection("permitRecipientRebuild", refreshSetting); + + $scope.previewRecipients = function previewRecipients() { + var model = { + count: $scope.recipients, + sample: crmMailingCache.get('mailing-' + $scope.mailing.id + '-recipient-list'), + sampleLimit: RECIPIENTS_PREVIEW_LIMIT + }; + var options = CRM.utils.adjustDialogDefaults({ + width: '40%', + autoOpen: false, + title: ts('Preview (%1)', {1: $scope.getRecipientCount()}) + }); + + // don't open preview dialog if there is no recipient to show. + if ($scope.recipients !== 0 && !$scope.outdated) { + if (!_.isEmpty(model.sample)) { + dialogService.open('recipDialog', '~/crmMailing/PreviewRecipCtrl.html', model, options); + } + else { + return crmStatus({start: ts('Previewing...'), success: ''}, crmMailingMgr.previewRecipients($scope.mailing, RECIPIENTS_PREVIEW_LIMIT).then(function(recipients) { + model.sample = recipients; + dialogService.open('recipDialog', '~/crmMailing/PreviewRecipCtrl.html', model, options); + })); + } + } + }; + + $scope.rebuildRecipients = function rebuildRecipients() { + // setting null will put 'Estimating..' text on refresh button + $scope.recipients = null; + return crmMailingMgr.previewRecipientCount($scope.mailing, crmMailingCache, true).then(function(recipients) { + $scope.outdated = (recipients === 0) ? true : false; + $scope.recipients = recipients; + }); + }; + + // Open a dialog for editing the advanced recipient options. + $scope.editOptions = function editOptions(mailing) { + var options = CRM.utils.adjustDialogDefaults({ + autoOpen: false, + width: '40%', + height: 'auto', + title: ts('Edit Options') + }); + $q.when(crmMetadata.getFields('Mailing')).then(function(fields) { + var model = { + fields: fields, + mailing: mailing + }; + dialogService.open('previewComponentDialog', '~/crmMailing/EditRecipOptionsDialogCtrl.html', model, options); + }); + }; + }); + +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/EditRecipOptionsDialogCtrl.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/EditRecipOptionsDialogCtrl.html new file mode 100644 index 00000000..be5e2c2e --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/EditRecipOptionsDialogCtrl.html @@ -0,0 +1,41 @@ +<div ng-controller="EditRecipOptionsDialogCtrl" class="crmMailing"> + <div class="crm-block" ng-form="editRecipOptionsForm" crm-ui-id-scope> + <div class="crm-group"> + + <div crm-ui-field="{title: ts('Dedupe by email'), help: hs('dedupe_email')}" crm-layout="checkbox"> + <input + type="checkbox" + ng-model="model.mailing.dedupe_email" + ng-true-value="'1'" + ng-false-value="'0'" + > + </div> + + <div crm-ui-field="{name: 'editRecipOptionsForm.location_type_id', title: ts('Location Type')}"> + <select + crm-ui-id="editRecipOptionsForm.location_type_id" + crm-ui-select="{dropdownAutoWidth : true}" + name="location_type_id" + ng-model="model.mailing.location_type_id" + > + <option value="">{{ts('Automatic')}}</option> + <option ng-repeat="locType in model.fields.location_type_id.options" + ng-value="locType.key">{{locType.value}}</option> + </select> + </div> + + <div crm-ui-field="{name: 'editRecipOptionsForm.email_selection_method', title: ts('Selection Method')}"> + <select + crm-ui-id="editRecipOptionsForm.email_selection_method" + crm-ui-select="" + name="email_selection_method" + ng-model="model.mailing.email_selection_method" + > + <option ng-repeat="selMet in model.fields.email_selection_method.options" + ng-value="selMet.key">{{selMet.value}}</option> + </select> + </div> + + </div> + </div> +</div> diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/EditRecipOptionsDialogCtrl.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/EditRecipOptionsDialogCtrl.js new file mode 100644 index 00000000..43734e34 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/EditRecipOptionsDialogCtrl.js @@ -0,0 +1,12 @@ +(function(angular, $, _) { + + // Controller for the "Recipients: Edit Options" dialog + // Note: Expects $scope.model to be an object with properties: + // - "mailing" (APIv3 mailing object) + // - "fields" (list of fields) + angular.module('crmMailing').controller('EditRecipOptionsDialogCtrl', function EditRecipOptionsDialogCtrl($scope, crmUiHelp) { + $scope.ts = CRM.ts(null); + $scope.hs = crmUiHelp({file: 'CRM/Mailing/MailingUI'}); + }); + +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/EditUnsubGroupCtrl.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/EditUnsubGroupCtrl.js new file mode 100644 index 00000000..56070e03 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/EditUnsubGroupCtrl.js @@ -0,0 +1,19 @@ +(function(angular, $, _) { + + angular.module('crmMailing').controller('EditUnsubGroupCtrl', function EditUnsubGroupCtrl($scope) { + // CRM.crmMailing.groupNames is a global constant - since it doesn't change, we can digest & cache. + var mandatoryIds = []; + + $scope.isUnsubGroupRequired = function isUnsubGroupRequired(mailing) { + if (!_.isEmpty(CRM.crmMailing.groupNames)) { + _.each(CRM.crmMailing.groupNames, function(grp) { + if (grp.is_hidden == "1") { + mandatoryIds.push(parseInt(grp.id)); + } + }); + return _.intersection(mandatoryIds, mailing.recipients.groups.include).length > 0; + } + }; + }); + +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/EmailAddrCtrl.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/EmailAddrCtrl.js new file mode 100644 index 00000000..942bcfe1 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/EmailAddrCtrl.js @@ -0,0 +1,31 @@ +(function(angular, $, _) { + + angular.module('crmMailing').controller('EmailAddrCtrl', function EmailAddrCtrl($scope, crmFromAddresses, crmUiAlert) { + var ts = CRM.ts(null); + + function changeAlert(winnerField, loserField) { + crmUiAlert({ + title: ts('Conflict'), + text: ts('The "%1" option conflicts with the "%2" option. The "%2" option has been disabled.', { + 1: winnerField, + 2: loserField + }) + }); + } + + $scope.crmFromAddresses = crmFromAddresses; + $scope.checkReplyToChange = function checkReplyToChange(mailing) { + if (!_.isEmpty(mailing.replyto_email) && mailing.override_verp == '0') { + mailing.override_verp = '1'; + changeAlert(ts('Reply-To'), ts('Track Replies')); + } + }; + $scope.checkVerpChange = function checkVerpChange(mailing) { + if (!_.isEmpty(mailing.replyto_email) && mailing.override_verp == '0') { + mailing.replyto_email = ''; + changeAlert(ts('Track Replies'), ts('Reply-To')); + } + }; + }); + +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/EmailBodyCtrl.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/EmailBodyCtrl.js new file mode 100644 index 00000000..7db0b7ec --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/EmailBodyCtrl.js @@ -0,0 +1,52 @@ +(function(angular, $, _) { + + var lastEmailTokenAlert = null; + angular.module('crmMailing').controller('EmailBodyCtrl', function EmailBodyCtrl($scope, crmMailingMgr, crmUiAlert, $timeout) { + var ts = CRM.ts(null); + + // ex: if (!hasAllTokens(myMailing, 'body_text)) alert('Oh noes!'); + $scope.hasAllTokens = function hasAllTokens(mailing, field) { + return _.isEmpty(crmMailingMgr.findMissingTokens(mailing, field)); + }; + + // ex: checkTokens(myMailing, 'body_text', 'insert:body_text') + // ex: checkTokens(myMailing, '*') + $scope.checkTokens = function checkTokens(mailing, field, insertEvent) { + if (lastEmailTokenAlert) { + lastEmailTokenAlert.close(); + } + var missing, insertable; + if (field == '*') { + insertable = false; + missing = angular.extend({}, + crmMailingMgr.findMissingTokens(mailing, 'body_html'), + crmMailingMgr.findMissingTokens(mailing, 'body_text') + ); + } + else { + insertable = !_.isEmpty(insertEvent); + missing = crmMailingMgr.findMissingTokens(mailing, field); + } + if (!_.isEmpty(missing)) { + lastEmailTokenAlert = crmUiAlert({ + type: 'error', + title: ts('Required tokens'), + templateUrl: '~/crmMailing/EmailBodyCtrl/tokenAlert.html', + scope: angular.extend($scope.$new(), { + insertable: insertable, + insertToken: function(token) { + $timeout(function() { + $scope.$broadcast(insertEvent, '{' + token + '}'); + $timeout(function() { + checkTokens(mailing, field, insertEvent); + }); + }); + }, + missing: missing + }) + }); + } + }; + }); + +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/EmailBodyCtrl/tokenAlert.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/EmailBodyCtrl/tokenAlert.html new file mode 100644 index 00000000..46f3eac7 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/EmailBodyCtrl/tokenAlert.html @@ -0,0 +1,76 @@ +<p ng-show="missing['domain.address']"> + {{ts('The mailing must include the street address of the organization. Please insert the %1 token.', {1: + '{domain.address}'})}} +</p> + +<div ng-show="missing['domain.address'] && insertable"> + <a ng-click="insertToken('domain.address')" class="button"><span><i class="crm-i fa-plus-circle"></i> {{ts('Address')}}</span></a> + + <div class="clear"></div> +</div> + +<p ng-show="missing['action.optOut']"> + {{ts('The mailing must allow recipients to (a) unsubscribe from the mailing-list or (b) completely opt-out from all mailings. Please insert an unsubscribe or opt-out token.')}} +</p> + +<div ng-show="missing['action.optOut'] && insertable"> + <table> + <thead> + <tr> + <th>{{ts('Via Web')}}</th> + <th>{{ts('Via Email')}}</th> + </tr> + </thead> + <tbody> + <tr> + <td> + <a ng-click="insertToken('action.unsubscribeUrl')" class="button"><span><i class="crm-i fa-plus-circle"></i> {{ts('Unsubscribe')}}</span></a> + </td> + <td> + <a ng-click="insertToken('action.unsubscribe')" class="button"><span><i class="crm-i fa-plus-circle"></i> {{ts('Unsubscribe')}}</span></a> + </td> + </tr> + <tr> + <td> + <a ng-click="insertToken('action.optOutUrl')" class="button"><span><i class="crm-i fa-plus-circle"></i> {{ts('Opt-out')}}</span></a> + </td> + <td> + <a ng-click="insertToken('action.optOut')" class="button"><span><i class="crm-i fa-plus-circle"></i> {{ts('Opt-out')}}</span></a> + </td> + </tr> + </tbody> + </table> +</div> + +<div ng-show="missing['action.optOut'] && !insertable"> + <table> + <thead> + <tr> + <th>{{ts('Via Web')}}</th> + <th>{{ts('Via Email')}}</th> + </tr> + </thead> + <tbody> + <tr> + <td> + {action.optOutUrl} + </td> + <td> + {action.optOut} + </td> + </tr> + <tr> + <td> + {action.unsubscribeUrl} + </td> + <td> + {action.unsubscribe} + </td> + </tr> + </tbody> + </table> +</div> + +<p> + {{ts('Alternatively, you may select a header or footer which includes the required tokens.')}} +</p> diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/FromAddress.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/FromAddress.js new file mode 100644 index 00000000..aae6499f --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/FromAddress.js @@ -0,0 +1,30 @@ +(function(angular, $, _) { + // Convert between a mailing "From Address" (mailing.from_name,mailing.from_email) and a unified label ("Name" <e@ma.il>) + // example: <span crm-mailing-from-address="myPlaceholder" crm-mailing="myMailing"><select ng-model="myPlaceholder.label"></select></span> + // NOTE: This really doesn't belong in a directive. I've tried (and failed) to make this work with a getterSetter binding, eg + // <select ng-model="mailing.convertFromAddress" ng-model-options="{getterSetter: true}"> + angular.module('crmMailing').directive('crmMailingFromAddress', function(crmFromAddresses) { + return { + link: function(scope, element, attrs) { + var placeholder = attrs.crmMailingFromAddress; + var mailing = null; + scope.$watch(attrs.crmMailing, function(newValue) { + mailing = newValue; + scope[placeholder] = { + label: crmFromAddresses.getByAuthorEmail(mailing.from_name, mailing.from_email, true).label + }; + }); + scope.$watch(placeholder + '.label', function(newValue) { + var addr = crmFromAddresses.getByLabel(newValue); + mailing.from_name = addr.author; + mailing.from_email = addr.email; + // CRM-18364: set replyTo as from_email only if custom replyTo is disabled in mail settings. + if (!CRM.crmMailing.enableReplyTo) { + mailing.replyto_email = crmFromAddresses.getByAuthorEmail(mailing.from_name, mailing.from_email, true).label; + } + }); + // FIXME: Shouldn't we also be watching mailing.from_name and mailing.from_email? + } + }; + }); +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/ListMailingsCtrl.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/ListMailingsCtrl.js new file mode 100644 index 00000000..e60ffe54 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/ListMailingsCtrl.js @@ -0,0 +1,10 @@ +(function(angular, $, _) { + + angular.module('crmMailing').controller('ListMailingsCtrl', ['crmLegacy', 'crmNavigator', function ListMailingsCtrl(crmLegacy, crmNavigator) { + // We haven't implemented this in Angular, but some users may get clever + // about typing URLs, so we'll provide a redirect. + var new_url = crmLegacy.url('civicrm/mailing/browse/unscheduled', {reset: 1, scheduled: 'false'}); + crmNavigator.redirect(new_url); + }]); + +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/MsgTemplateCtrl.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/MsgTemplateCtrl.js new file mode 100644 index 00000000..5d200c3f --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/MsgTemplateCtrl.js @@ -0,0 +1,44 @@ +(function(angular, $, _) { + + // Controller for the in-place msg-template management + angular.module('crmMailing').controller('MsgTemplateCtrl', function MsgTemplateCtrl($scope, crmMsgTemplates, dialogService) { + var ts = $scope.ts = CRM.ts(null); + $scope.crmMsgTemplates = crmMsgTemplates; + $scope.checkPerm = CRM.checkPerm; + // @return Promise MessageTemplate (per APIv3) + $scope.saveTemplate = function saveTemplate(mailing) { + var model = { + selected_id: mailing.msg_template_id, + tpl: { + msg_title: '', + msg_subject: mailing.subject, + msg_text: mailing.body_text, + msg_html: mailing.body_html + } + }; + var options = CRM.utils.adjustDialogDefaults({ + autoOpen: false, + height: 'auto', + width: '40%', + title: ts('Save Template') + }); + return dialogService.open('saveTemplateDialog', '~/crmMailing/SaveMsgTemplateDialogCtrl.html', model, options) + .then(function(item) { + mailing.msg_template_id = item.id; + return item; + }); + }; + + // @param int id + // @return Promise + $scope.loadTemplate = function loadTemplate(mailing, id) { + return crmMsgTemplates.get(id).then(function(tpl) { + mailing.msg_template_id = tpl.id; + mailing.subject = tpl.msg_subject; + mailing.body_text = tpl.msg_text; + mailing.body_html = tpl.msg_html; + }); + }; + }); + +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/PreviewComponentCtrl.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/PreviewComponentCtrl.js new file mode 100644 index 00000000..3fd928a1 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/PreviewComponentCtrl.js @@ -0,0 +1,24 @@ +(function(angular, $, _) { + + // Controller for the "Preview Mailing Component" segment + // which displays header/footer/auto-responder + angular.module('crmMailing').controller('PreviewComponentCtrl', function PreviewComponentCtrl($scope, dialogService) { + var ts = $scope.ts = CRM.ts(null); + + $scope.previewComponent = function previewComponent(title, componentId) { + var component = _.where(CRM.crmMailing.headerfooterList, {id: "" + componentId}); + if (!component || !component[0]) { + CRM.alert(ts('Invalid component ID (%1)', { + 1: componentId + })); + return; + } + var options = CRM.utils.adjustDialogDefaults({ + autoOpen: false, + title: title // component[0].name + }); + dialogService.open('previewComponentDialog', '~/crmMailing/PreviewComponentDialogCtrl.html', component[0], options); + }; + }); + +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/PreviewComponentDialogCtrl.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/PreviewComponentDialogCtrl.html new file mode 100644 index 00000000..71db7027 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/PreviewComponentDialogCtrl.html @@ -0,0 +1,28 @@ +<div ng-controller="PreviewComponentDialogCtrl"> + <div class="crm-block"> + <div class="crm-group"> + <div class="crm-section" ng-show="model.name"> + <div class="label">{{ts('Name')}}</div> + <div class="content"> + {{model.name}} + </div> + <div class="clear"></div> + </div> + <div class="crm-section" ng-show="model.subject"> + <div class="label">{{ts('Subject')}}</div> + <div class="content"> + {{model.subject}} + </div> + <div class="clear"></div> + </div> + </div> + </div> + <div crm-ui-tab-set> + <div crm-ui-tab id="preview-html" crm-title="ts('HTML')"> + <iframe crm-ui-iframe="model.body_html"></iframe> + </div> + <div crm-ui-tab id="preview-text" crm-title="ts('Plain Text')"> + <pre>{{model.body_text}}</pre> + </div> + </div> +</div> diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/PreviewComponentDialogCtrl.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/PreviewComponentDialogCtrl.js new file mode 100644 index 00000000..2b1d9f2c --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/PreviewComponentDialogCtrl.js @@ -0,0 +1,13 @@ +(function(angular, $, _) { + + // Controller for the "Preview Mailing Component" dialog + // Note: Expects $scope.model to be an object with properties: + // - "name" + // - "subject" + // - "body_html" + // - "body_text" + angular.module('crmMailing').controller('PreviewComponentDialogCtrl', function PreviewComponentDialogCtrl($scope) { + $scope.ts = CRM.ts(null); + }); + +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/PreviewMailingDialogCtrl.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/PreviewMailingDialogCtrl.js new file mode 100644 index 00000000..9e339b53 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/PreviewMailingDialogCtrl.js @@ -0,0 +1,12 @@ +(function(angular, $, _) { + + // Controller for the "Preview Mailing" dialog + // Note: Expects $scope.model to be an object with properties: + // - "subject" + // - "body_html" + // - "body_text" + angular.module('crmMailing').controller('PreviewMailingDialogCtrl', function PreviewMailingDialogCtrl($scope) { + $scope.ts = CRM.ts(null); + }); + +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/PreviewMgr/full.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/PreviewMgr/full.html new file mode 100644 index 00000000..0e257a11 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/PreviewMgr/full.html @@ -0,0 +1,10 @@ +<div ng-controller="PreviewMailingDialogCtrl"> + <div crm-ui-tab-set> + <div crm-ui-tab id="preview-html" crm-title="ts('HTML')"> + <iframe crm-ui-iframe="model.body_html"></iframe> + </div> + <div crm-ui-tab id="preview-text" crm-title="ts('Plain Text')"> + <pre>{{model.body_text}}</pre> + </div> + </div> +</div>
\ No newline at end of file diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/PreviewMgr/html.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/PreviewMgr/html.html new file mode 100644 index 00000000..c47b1c02 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/PreviewMgr/html.html @@ -0,0 +1,3 @@ +<div ng-controller="PreviewMailingDialogCtrl"> + <iframe crm-ui-iframe="model.body_html"></iframe> +</div>
\ No newline at end of file diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/PreviewMgr/text.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/PreviewMgr/text.html new file mode 100644 index 00000000..246add4b --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/PreviewMgr/text.html @@ -0,0 +1,3 @@ +<div ng-controller="PreviewMailingDialogCtrl"> + <pre>{{model.body_text}}</pre> +</div>
\ No newline at end of file diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/PreviewRecipCtrl.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/PreviewRecipCtrl.html new file mode 100644 index 00000000..6eb6459a --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/PreviewRecipCtrl.html @@ -0,0 +1,32 @@ +<div ng-controller="PreviewRecipCtrl"> + <!-- + Controller: PreviewRecipCtrl + Required vars: model.sample + --> + + <div class="help"> + <p>{{ts('Based on current data, approximately %1 contacts will receive a copy of the mailing.', {1: model.count})}}</p> + + <p ng-show="model.sample.length == model.sampleLimit">{{ts('Below is a sample of the first %1 recipients.', {1: model.sampleLimit})}}</p> + + <p>{{ts('If individual contacts are separately modified, added, or removed, then the final list may change.')}}</p> + </div> + + <div ng-show="model.sample == 0"> + {{ts('No recipients')}} + </div> + <table ng-show="model.sample.length > 0"> + <thead> + <tr> + <th>{{ts('Name')}}</th> + <th>{{ts('Email')}}</th> + </tr> + </thead> + <tbody> + <tr ng-repeat="recipient in model.sample"> + <td>{{recipient['api.contact.getvalue']}}</td> + <td>{{recipient['api.email.getvalue']}}</td> + </tr> + </tbody> + </table> +</div> diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/PreviewRecipCtrl.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/PreviewRecipCtrl.js new file mode 100644 index 00000000..371fb8ef --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/PreviewRecipCtrl.js @@ -0,0 +1,10 @@ +(function(angular, $, _) { + + // Controller for the "Preview Recipients" dialog + // Note: Expects $scope.model to be an object with properties: + // - recipients: array of contacts + angular.module('crmMailing').controller('PreviewRecipCtrl', function($scope) { + $scope.ts = CRM.ts(null); + }); + +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/RadioDate.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/RadioDate.js new file mode 100644 index 00000000..037b6e22 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/RadioDate.js @@ -0,0 +1,116 @@ +(function(angular, $, _) { + // "YYYY-MM-DD hh:mm:ss" => Date() + function parseYmdHms(d) { + var parts = d.split(/[\-: ]/); + return new Date(parts[0], parts[1]-1, parts[2], parts[3], parts[4], parts[5]); + } + + function isDateBefore(tgt, cutoff, tolerance) { + var ad = parseYmdHms(tgt), bd = parseYmdHms(cutoff); + // We'll allow a little leeway, where tgt is considered before cutoff + // even if technically misses the cutoff by a little. + return ad < bd-tolerance; + } + + // Represent a datetime field as if it were a radio ('schedule.mode') and a datetime ('schedule.datetime'). + // example: <div crm-mailing-radio-date="mySchedule" ng-model="mailing.scheduled_date">...</div> + angular.module('crmMailing').directive('crmMailingRadioDate', function(crmUiAlert) { + return { + require: 'ngModel', + link: function($scope, element, attrs, ngModel) { + var lastAlert = null; + + var schedule = $scope[attrs.crmMailingRadioDate] = { + mode: 'now', + datetime: '' + }; + + ngModel.$render = function $render() { + var sched = ngModel.$viewValue; + if (!_.isEmpty(sched)) { + schedule.mode = 'at'; + schedule.datetime = sched; + } + else { + schedule.mode = 'now'; + schedule.datetime = ''; + } + }; + + var updateParent = (function() { + switch (schedule.mode) { + case 'now': + ngModel.$setViewValue(null); + schedule.datetime = ''; + break; + case 'at': + schedule.datetime = schedule.datetime || '?'; + ngModel.$setViewValue(schedule.datetime); + break; + default: + throw 'Unrecognized schedule mode: ' + schedule.mode; + } + }); + + element + // Open datepicker when clicking "At" radio + .on('click', ':radio[value=at]', function() { + $('.crm-form-date', element).focus(); + }) + // Reset mode if user entered an invalid date + .on('change', '.crm-hidden-date', function(e, context) { + if (context === 'userInput' && $(this).val() === '' && $(this).siblings('.crm-form-date').val().length) { + schedule.mode = 'at'; + schedule.datetime = '?'; + } else { + var d = new Date(), + month = '' + (d.getMonth() + 1), + day = '' + d.getDate(), + year = d.getFullYear(), + hours = '' + d.getHours(), + minutes = '' + d.getMinutes(); + var submittedDate = $(this).val(); + if (month.length < 2) month = '0' + month; + if (day.length < 2) day = '0' + day; + if (hours.length < 2) hours = '0' + hours; + if (minutes.length < 2) minutes = '0' + minutes; + date = [year, month, day].join('-'); + time = [hours, minutes, "00"].join(':'); + currentDate = date + ' ' + time; + var isInPast = (submittedDate.length && submittedDate.match(/^[0-9\-]+ [0-9\:]+$/) && isDateBefore(submittedDate, currentDate, 4*60*60*1000)); + ngModel.$setValidity('dateTimeInThePast', !isInPast); + if (lastAlert && lastAlert.isOpen) { + lastAlert.close(); + } + if (isInPast) { + lastAlert = crmUiAlert({ + text: ts('The scheduled date and time is in the past'), + title: ts('Error') + }); + } + } + }); + + $scope.$watch(attrs.crmMailingRadioDate + '.mode', updateParent); + $scope.$watch(attrs.crmMailingRadioDate + '.datetime', function(newValue, oldValue) { + // automatically switch mode based on datetime entry + if (typeof oldValue === 'undefined') { + oldValue = ''; + } + if (typeof newValue === 'undefined') { + newValue = ''; + } + if (oldValue !== newValue) { + if (_.isEmpty(newValue)) { + schedule.mode = 'now'; + } + else { + schedule.mode = 'at'; + } + } + updateParent(); + }); + } + }; + }); +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/Recipients.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/Recipients.js new file mode 100644 index 00000000..4203d4a3 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/Recipients.js @@ -0,0 +1,341 @@ +(function(angular, $, _) { + // example: <select multiple crm-mailing-recipients crm-mailing="mymailing" crm-avail-groups="myGroups" crm-avail-mailings="myMailings"></select> + // FIXME: participate in ngModel's validation cycle + angular.module('crmMailing').directive('crmMailingRecipients', function(crmUiAlert) { + return { + restrict: 'AE', + require: 'ngModel', + scope: { + ngRequired: '@' + }, + link: function(scope, element, attrs, ngModel) { + scope.recips = ngModel.$viewValue; + scope.groups = scope.$parent.$eval(attrs.crmAvailGroups); + scope.mailings = scope.$parent.$eval(attrs.crmAvailMailings); + refreshMandatory(); + + var ts = scope.ts = CRM.ts(null); + + /// Convert MySQL date ("yyyy-mm-dd hh:mm:ss") to JS date object + scope.parseDate = function(date) { + if (!angular.isString(date)) { + return date; + } + var p = date.split(/[\- :]/); + return new Date(parseInt(p[0]), parseInt(p[1]) - 1, parseInt(p[2]), parseInt(p[3]), parseInt(p[4]), parseInt(p[5])); + }; + + /// Remove {value} from {array} + function arrayRemove(array, value) { + var idx = array.indexOf(value); + if (idx >= 0) { + array.splice(idx, 1); + } + } + + // @param string id an encoded string like "4 civicrm_mailing include" + // @return Object keys: entity_id, entity_type, mode + function convertValueToObj(id) { + var a = id.split(" "); + return {entity_id: parseInt(a[0]), entity_type: a[1], mode: a[2]}; + } + + // @param Object mailing + // @return array list of values like "4 civicrm_mailing include" + function convertMailingToValues(recipients) { + var r = []; + angular.forEach(recipients.groups.include, function(v) { + r.push(v + " civicrm_group include"); + }); + angular.forEach(recipients.groups.exclude, function(v) { + r.push(v + " civicrm_group exclude"); + }); + angular.forEach(recipients.mailings.include, function(v) { + r.push(v + " civicrm_mailing include"); + }); + angular.forEach(recipients.mailings.exclude, function(v) { + r.push(v + " civicrm_mailing exclude"); + }); + return r; + } + + function refreshMandatory() { + if (ngModel.$viewValue && ngModel.$viewValue.groups) { + scope.mandatoryGroups = _.filter(scope.$parent.$eval(attrs.crmMandatoryGroups), function(grp) { + return _.contains(ngModel.$viewValue.groups.include, parseInt(grp.id)); + }); + scope.mandatoryIds = _.map(_.pluck(scope.$parent.$eval(attrs.crmMandatoryGroups), 'id'), function(n) { + return parseInt(n); + }); + } + else { + scope.mandatoryGroups = []; + scope.mandatoryIds = []; + } + } + + function isMandatory(grpId) { + return _.contains(scope.mandatoryIds, parseInt(grpId)); + } + + var refreshUI = ngModel.$render = function refresuhUI() { + scope.recips = ngModel.$viewValue; + if (ngModel.$viewValue) { + $(element).select2('val', convertMailingToValues(ngModel.$viewValue)); + validate(); + refreshMandatory(); + } + }; + + // @return string HTML representing an option + function formatItem(item) { + if (!item.id) { + // return `text` for optgroup + return item.text; + } + var option = convertValueToObj(item.id); + var icon = (option.entity_type === 'civicrm_mailing') ? 'fa-envelope' : 'fa-users'; + var spanClass = (option.mode == 'exclude') ? 'crmMailing-exclude' : 'crmMailing-include'; + if (option.entity_type != 'civicrm_mailing' && isMandatory(option.entity_id)) { + spanClass = 'crmMailing-mandatory'; + } + return '<i class="crm-i '+icon+'"></i> <span class="' + spanClass + '">' + item.text + '</span>'; + } + + function validate() { + if (scope.$parent.$eval(attrs.ngRequired)) { + var empty = (_.isEmpty(ngModel.$viewValue.groups.include) && _.isEmpty(ngModel.$viewValue.mailings.include)); + ngModel.$setValidity('empty', !empty); + } + else { + ngModel.$setValidity('empty', true); + } + } + + var rcpAjaxState = { + input: '', + entity: 'civicrm_group', + type: 'include', + page_n: 0, + page_i: 0, + }; + + $(element).select2({ + width: '36em', + dropdownAutoWidth: true, + placeholder: "Groups or Past Recipients", + formatResult: formatItem, + formatSelection: formatItem, + escapeMarkup: function(m) { + return m; + }, + multiple: true, + initSelection: function(el, cb) { + var values = el.val().split(','); + + var gids = []; + var mids = []; + + for (var i = 0; i < values.length; i++) { + var dv = convertValueToObj(values[i]); + if (dv.entity_type == 'civicrm_group') { + gids.push(dv.entity_id); + } + else if (dv.entity_type == 'civicrm_mailing') { + mids.push(dv.entity_id); + } + } + // push non existant 0 group/mailing id in order when no recipents group or prior mailing is selected + // this will allow to resuse the below code to handle datamap + if (gids.length === 0) { + gids.push(0); + } + if (mids.length === 0) { + mids.push(0); + } + + CRM.api3('Group', 'getlist', { params: { id: { IN: gids }, options: { limit: 0 } }, extra: ["is_hidden"] } ).then( + function(glist) { + CRM.api3('Mailing', 'getlist', { params: { id: { IN: mids }, options: { limit: 0 } } }).then( + function(mlist) { + var datamap = []; + + var groupNames = []; + var civiMails = []; + + $(glist.values).each(function (idx, group) { + var key = group.id + ' civicrm_group include'; + groupNames.push({id: parseInt(group.id), title: group.label, is_hidden: group.extra.is_hidden}); + + if (values.indexOf(key) >= 0) { + datamap.push({id: key, text: group.label}); + } + + key = group.id + ' civicrm_group exclude'; + if (values.indexOf(key) >= 0) { + datamap.push({id: key, text: group.label}); + } + }); + + $(mlist.values).each(function (idx, group) { + var key = group.id + ' civicrm_mailing include'; + civiMails.push({id: parseInt(group.id), name: group.label}); + + if (values.indexOf(key) >= 0) { + datamap.push({id: key, text: group.label}); + } + + key = group.id + ' civicrm_mailing exclude'; + if (values.indexOf(key) >= 0) { + datamap.push({id: key, text: group.label}); + } + }); + + scope.$parent.crmMailingConst.groupNames = groupNames; + scope.$parent.crmMailingConst.civiMails = civiMails; + + refreshMandatory(); + + cb(datamap); + }); + }); + }, + ajax: { + url: CRM.url('civicrm/ajax/rest'), + quietMillis: 300, + data: function(input, page_num) { + if (page_num <= 1) { + rcpAjaxState = { + input: input, + entity: 'civicrm_group', + type: 'include', + page_n: 0, + }; + } + + rcpAjaxState.page_i = page_num - rcpAjaxState.page_n; + var filterParams = {}; + switch(rcpAjaxState.entity) { + case 'civicrm_group': + filterParams = { is_hidden: 0, is_active: 1, group_type: {"LIKE": "%2%"} }; + break; + + case 'civicrm_mailing': + filterParams = { is_hidden: 0, is_active: 1 }; + break; + } + var params = { + input: input, + page_num: rcpAjaxState.page_i, + params: filterParams, + }; + + if('civicrm_mailing' === rcpAjaxState.entity) { + params["api.MailingRecipients.getcount"] = {}; + } + + return params; + }, + transport: function(params) { + switch(rcpAjaxState.entity) { + case 'civicrm_group': + CRM.api3('Group', 'getlist', params.data).then(params.success, params.error); + break; + + case 'civicrm_mailing': + params.data.params.options = { sort: "is_archived asc, scheduled_date desc" }; + CRM.api3('Mailing', 'getlist', params.data).then(params.success, params.error); + break; + } + }, + results: function(data) { + results = { + children: $.map(data.values, function(obj) { + if('civicrm_mailing' === rcpAjaxState.entity) { + return obj["api.MailingRecipients.getcount"] > 0 ? { id: obj.id + ' ' + rcpAjaxState.entity + ' ' + rcpAjaxState.type, + text: obj.label } : ''; + } + else { + return { id: obj.id + ' ' + rcpAjaxState.entity + ' ' + rcpAjaxState.type, + text: obj.label }; + } + }) + }; + + if (rcpAjaxState.page_i == 1 && data.count && results.children.length > 0) { + results.text = ts((rcpAjaxState.type == 'include'? 'Include ' : 'Exclude ') + + (rcpAjaxState.entity == 'civicrm_group'? 'Group' : 'Mailing')); + } + + more = data.more_results || !(rcpAjaxState.entity == 'civicrm_mailing' && rcpAjaxState.type == 'exclude'); + + if (more && !data.more_results) { + if (rcpAjaxState.type == 'include') { + rcpAjaxState.type = 'exclude'; + } else { + rcpAjaxState.type = 'include'; + rcpAjaxState.entity = 'civicrm_mailing'; + } + rcpAjaxState.page_n += rcpAjaxState.page_i; + } + + return { more: more, results: [ results ] }; + }, + }, + }); + + $(element).on('select2-selecting', function(e) { + var option = convertValueToObj(e.val); + var typeKey = option.entity_type == 'civicrm_mailing' ? 'mailings' : 'groups'; + if (option.mode == 'exclude') { + ngModel.$viewValue[typeKey].exclude.push(option.entity_id); + arrayRemove(ngModel.$viewValue[typeKey].include, option.entity_id); + } + else { + ngModel.$viewValue[typeKey].include.push(option.entity_id); + arrayRemove(ngModel.$viewValue[typeKey].exclude, option.entity_id); + } + scope.$apply(); + $(element).select2('close'); + validate(); + e.preventDefault(); + }); + + $(element).on("select2-removing", function(e) { + var option = convertValueToObj(e.val); + var typeKey = option.entity_type == 'civicrm_mailing' ? 'mailings' : 'groups'; + if (typeKey == 'groups' && isMandatory(option.entity_id)) { + crmUiAlert({ + text: ts('This mailing was generated based on search results. The search results cannot be removed.'), + title: ts('Required') + }); + e.preventDefault(); + return; + } + scope.$parent.$apply(function() { + arrayRemove(ngModel.$viewValue[typeKey][option.mode], option.entity_id); + }); + validate(); + e.preventDefault(); + }); + + scope.$watchCollection("recips.groups.include", refreshUI); + scope.$watchCollection("recips.groups.exclude", refreshUI); + scope.$watchCollection("recips.mailings.include", refreshUI); + scope.$watchCollection("recips.mailings.exclude", refreshUI); + setTimeout(refreshUI, 50); + + scope.$watchCollection(attrs.crmAvailGroups, function() { + scope.groups = scope.$parent.$eval(attrs.crmAvailGroups); + }); + scope.$watchCollection(attrs.crmAvailMailings, function() { + scope.mailings = scope.$parent.$eval(attrs.crmAvailMailings); + }); + scope.$watchCollection(attrs.crmMandatoryGroups, function() { + refreshMandatory(); + }); + } + }; + }); + +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/ReviewBool.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/ReviewBool.js new file mode 100644 index 00000000..98ec85c5 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/ReviewBool.js @@ -0,0 +1,28 @@ +(function(angular, $, _) { + angular.module('crmMailing').directive('crmMailingReviewBool', function() { + return { + scope: { + crmOn: '@', + crmTitle: '@' + }, + template: '<span ng-class="spanClasses"><i class="crm-i" ng-class="iconClasses"></i> {{evalTitle}} </span>', + link: function(scope, element, attrs) { + function refresh() { + if (scope.$parent.$eval(attrs.crmOn)) { + scope.spanClasses = {'crmMailing-active': true}; + scope.iconClasses = {'fa-check': true}; + } + else { + scope.spanClasses = {'crmMailing-inactive': true}; + scope.iconClasses = {'fa-times': true}; + } + scope.evalTitle = scope.$parent.$eval(attrs.crmTitle); + } + + refresh(); + scope.$parent.$watch(attrs.crmOn, refresh); + scope.$parent.$watch(attrs.crmTitle, refresh); + } + }; + }); +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/SaveMsgTemplateDialogCtrl.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/SaveMsgTemplateDialogCtrl.html new file mode 100644 index 00000000..1e5c723f --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/SaveMsgTemplateDialogCtrl.html @@ -0,0 +1,17 @@ +<div ng-controller="SaveMsgTemplateDialogCtrl"> + <p><em>{{ts('Save the current mailing as a template.')}}</em></p> + + <div ng-hide="!selected"> + <label for="saveopt-mode-update"> + <input type="radio" name="mode" ng-model="saveOpt.mode" value="update" id="saveopt-mode-update"> + {{ts('Update "%1"', {1: selected.msg_title})}} + </label> + </div> + <div> + <label type="radio" for="saveopt-mode-add"> + <input type="radio" name="mode" ng-model="saveOpt.mode" value="add" id="saveopt-mode-add"> + {{ts('Save as:')}} + </label> + <input type="text" ng-model="saveOpt.newTitle" ng-click="saveOpt.mode='add'" ng-change="saveOpt.mode='add'" /> + </div> +</div> diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/SaveMsgTemplateDialogCtrl.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/SaveMsgTemplateDialogCtrl.js new file mode 100644 index 00000000..ea80522e --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/SaveMsgTemplateDialogCtrl.js @@ -0,0 +1,83 @@ +(function(angular, $, _) { + + // Controller for the "Save Message Template" dialog + // Scope members: + // - [input] "model": Object + // - "selected_id": int + // - "tpl": Object + // - "msg_subject": string + // - "msg_text": string + // - "msg_html": string + angular.module('crmMailing').controller('SaveMsgTemplateDialogCtrl', function SaveMsgTemplateDialogCtrl($scope, crmMsgTemplates, dialogService) { + var ts = $scope.ts = CRM.ts(null); + $scope.saveOpt = {mode: '', newTitle: ''}; + $scope.selected = null; + + $scope.save = function save() { + var tpl = _.extend({}, $scope.model.tpl); + switch ($scope.saveOpt.mode) { + case 'add': + tpl.msg_title = $scope.saveOpt.newTitle; + break; + case 'update': + tpl.id = $scope.selected.id; + tpl.msg_title = $scope.selected.msg_title; + break; + default: + throw 'SaveMsgTemplateDialogCtrl: Unrecognized mode: ' + $scope.saveOpt.mode; + } + return crmMsgTemplates.save(tpl) + .then(function (item) { + CRM.status(ts('Saved')); + return item; + }); + }; + + function scopeApply(f) { + return function () { + var args = arguments; + $scope.$apply(function () { + f.apply(args); + }); + }; + } + + function init() { + crmMsgTemplates.get($scope.model.selected_id).then( + function (tpl) { + $scope.saveOpt.mode = 'update'; + $scope.selected = tpl; + }, + function () { + $scope.saveOpt.mode = 'add'; + $scope.selected = null; + } + ); + // When using dialogService with a button bar, the major button actions + // need to be registered with the dialog widget (and not embedded in + // the body of the dialog). + var buttons = [ + { + text: ts('Save'), + icons: {primary: 'fa-check'}, + click: function () { + $scope.save().then(function (item) { + dialogService.close('saveTemplateDialog', item); + }); + } + }, + { + text: ts('Cancel'), + icons: {primary: 'fa-times'}, + click: function () { + dialogService.cancel('saveTemplateDialog'); + } + } + ]; + dialogService.setButtons('saveTemplateDialog', buttons); + } + + setTimeout(scopeApply(init), 0); + }); + +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/Templates.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/Templates.js new file mode 100644 index 00000000..0c2b93dc --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/Templates.js @@ -0,0 +1,131 @@ +(function(angular, $, _) { + // example <select crm-mailing-templates crm-mailing="mymailing"></select> + angular.module('crmMailing').directive('crmMailingTemplates', function(crmUiAlert) { + return { + restrict: 'AE', + require: 'ngModel', + scope: { + ngRequired: '@' + }, + link: function(scope, element, attrs, ngModel) { + scope.template = ngModel.$viewValue; + + var refreshUI = ngModel.$render = function refresuhUI() { + scope.template = ngModel.$viewValue; + if (ngModel.$viewValue) { + $(element).select2('val', ngModel.$viewValue); + } + }; + + // @return string HTML representing an option + function formatItem(item) { + if (!item.id) { + // return `text` for optgroup + return item.text; + } + return '<span class="crmMailing-template">' + item.text + '</span>'; + } + + var rcpAjaxState = { + input: '', + entity: 'civicrm_msg_templates', + page_n: 0, + page_i: 0, + }; + + $(element).select2({ + width: '36em', + placeholder: "<i class='fa fa-clipboard'></i> Mailing Templates", + formatResult: formatItem, + escapeMarkup: function(m) { + return m; + }, + multiple: false, + initSelection: function(el, cb) { + + var value = el.val(); + + CRM.api3('MessageTemplate', 'getlist', { params: { id: value }, label_field: 'msg_title' }).then(function(tlist) { + + var template = {}; + + if (tlist.count) { + $(tlist.values).each(function(id, val) { + template.id = val.id; + template.text = val.label; + }); + } + + cb(template); + }); + }, + ajax: { + url: CRM.url('civicrm/ajax/rest'), + quietMillis: 300, + data: function(input, page_num) { + if (page_num <= 1) { + rcpAjaxState = { + input: input, + entity: 'civicrm_msg_templates', + page_n: 0, + }; + } + + rcpAjaxState.page_i = page_num - rcpAjaxState.page_n; + var filterParams = { is_active: 1, workflow_id: { "IS NULL": 1 } }; + + var params = { + input: input, + page_num: rcpAjaxState.page_i, + label_field: 'msg_title', + search_field: 'msg_title', + params: filterParams, + }; + return params; + }, + transport: function(params) { + CRM.api3('MessageTemplate', 'getlist', params.data).then(params.success, params.error); + }, + results: function(data) { + + results = { + children: $.map(data.values, function(obj) { + return { id: obj.id, text: obj.label }; + }) + }; + + if (rcpAjaxState.page_i == 1 && data.count) { + results.text = ts('Message Templates'); + } + + more = data.more_results; + + if (more && !data.more_results) { + rcpAjaxState.page_n += rcpAjaxState.page_i; + } + + return { more: more, results: [ results ] }; + }, + } + }); + + $(element).on('select2-selecting', function(e) { + // in here is where the template HTML should be loaded + var entity_id = parseInt(e.val); + ngModel.$viewValue = entity_id; + + scope.$parent.loadTemplate(scope.$parent.$parent.mailing, entity_id); + scope.$apply(); + $(element).select2('close'); + e.preventDefault(); + }); + + + scope.$watchCollection("template", refreshUI); + setTimeout(refreshUI, 50); + } + }; + + + }); +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/Token.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/Token.js new file mode 100644 index 00000000..71131d21 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/Token.js @@ -0,0 +1,28 @@ +(function(angular, $, _) { + // example: <input name="subject" /> <input crm-mailing-token on-select="doSomething(token.name)" /> + // WISHLIST: Instead of global CRM.crmMailing.mailTokens, accept token list as an input + angular.module('crmMailing').directive('crmMailingToken', function() { + return { + require: '^crmUiIdScope', + scope: { + onSelect: '@' + }, + template: '<input type="text" class="crmMailingToken" />', + link: function(scope, element, attrs, crmUiIdCtrl) { + $(element).addClass('crm-action-menu fa-code').crmSelect2({ + width: "12em", + dropdownAutoWidth: true, + data: CRM.crmMailing.mailTokens, + placeholder: ts('Tokens') + }); + $(element).on('select2-selecting', function(e) { + e.preventDefault(); + $(element).select2('close').select2('val', ''); + scope.$parent.$eval(attrs.onSelect, { + token: {name: e.val} + }); + }); + } + }; + }); +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/ViewRecipCtrl.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/ViewRecipCtrl.js new file mode 100644 index 00000000..d72793fd --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/ViewRecipCtrl.js @@ -0,0 +1,128 @@ +(function(angular, $, _) { + + angular.module('crmMailing').controller('ViewRecipCtrl', function ViewRecipCtrl($scope) { + var mids = []; + var gids = []; + var groupNames = []; + var mailings = []; + var civimailings = []; + var civimails = []; + + function getGroupNames(mailing) { + if (-1 == mailings.indexOf(mailing.id)) { + mailings.push(mailing.id); + _.each(mailing.recipients.groups.include, function(id) { + if (-1 == gids.indexOf(id)) { + gids.push(id); + } + }); + _.each(mailing.recipients.groups.exclude, function(id) { + if (-1 == gids.indexOf(id)) { + gids.push(id); + } + }); + _.each(mailing.recipients.groups.base, function(id) { + if (-1 == gids.indexOf(id)) { + gids.push(id); + } + }); + if (!_.isEmpty(gids)) { + CRM.api3('Group', 'get', {'id': {"IN": gids}}).then(function(result) { + _.each(result.values, function(grp) { + if (_.isEmpty(_.where(groupNames, {id: parseInt(grp.id)}))) { + groupNames.push({id: parseInt(grp.id), title: grp.title, is_hidden: grp.is_hidden}); + } + }); + CRM.crmMailing.groupNames = groupNames; + $scope.$parent.crmMailingConst.groupNames = groupNames; + }); + } + } + } + + function getCiviMails(mailing) { + if (-1 == civimailings.indexOf(mailing.id)) { + civimailings.push(mailing.id); + _.each(mailing.recipients.mailings.include, function(id) { + if (-1 == mids.indexOf(id)) { + mids.push(id); + } + }); + _.each(mailing.recipients.mailings.exclude, function(id) { + if (-1 == mids.indexOf(id)) { + mids.push(id); + } + }); + if (!_.isEmpty(mids)) { + CRM.api3('Mailing', 'get', {'id': {"IN": mids}}).then(function(result) { + _.each(result.values, function(mail) { + if (_.isEmpty(_.where(civimails, {id: parseInt(mail.id)}))) { + civimails.push({id: parseInt(mail.id), name: mail.label}); + } + }); + CRM.crmMailing.civiMails = civimails; + $scope.$parent.crmMailingConst.civiMails = civimails; + }); + } + } + } + + $scope.getIncludesAsString = function(mailing) { + var first = true; + var names = ''; + if (_.isEmpty(CRM.crmMailing.groupNames)) { + getGroupNames(mailing); + } + if (_.isEmpty(CRM.crmMailing.civiMails)) { + getCiviMails(mailing); + } + _.each(mailing.recipients.groups.include, function(id) { + var group = _.where(CRM.crmMailing.groupNames, {id: parseInt(id)}); + if (group.length) { + if (!first) { + names = names + ', '; + } + names = names + group[0].title; + first = false; + } + }); + _.each(mailing.recipients.mailings.include, function(id) { + var oldMailing = _.where(CRM.crmMailing.civiMails, {id: parseInt(id)}); + if (oldMailing.length) { + if (!first) { + names = names + ', '; + } + names = names + oldMailing[0].name; + first = false; + } + }); + return names; + }; + $scope.getExcludesAsString = function(mailing) { + var first = true; + var names = ''; + _.each(mailing.recipients.groups.exclude, function(id) { + var group = _.where(CRM.crmMailing.groupNames, {id: parseInt(id)}); + if (group.length) { + if (!first) { + names = names + ', '; + } + names = names + group[0].title; + first = false; + } + }); + _.each(mailing.recipients.mailings.exclude, function(id) { + var oldMailing = _.where(CRM.crmMailing.civiMails, {id: parseInt(id)}); + if (oldMailing.length) { + if (!first) { + names = names + ', '; + } + names = names + oldMailing[0].name; + first = false; + } + }); + return names; + }; + }); + +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/services.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/services.js new file mode 100644 index 00000000..45c20637 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailing/services.js @@ -0,0 +1,582 @@ +(function (angular, $, _) { + + // The representation of from/reply-to addresses is inconsistent in the mailing data-model, + // so the UI must do some adaptation. The crmFromAddresses provides a richer way to slice/dice + // the available "From:" addrs. Records are like the underlying OptionValues -- but add "email" + // and "author". + angular.module('crmMailing').factory('crmFromAddresses', function ($q, crmApi) { + var emailRegex = /^"(.*)" *<([^@>]*@[^@>]*)>$/; + var addrs = _.map(CRM.crmMailing.fromAddress, function (addr) { + var match = emailRegex.exec(addr.label); + return angular.extend({}, addr, { + email: match ? match[2] : '(INVALID)', + author: match ? match[1] : '(INVALID)' + }); + }); + + function first(array) { + return (array.length === 0) ? null : array[0]; + } + + return { + getAll: function getAll() { + return addrs; + }, + getByAuthorEmail: function getByAuthorEmail(author, email, autocreate) { + var result = null; + _.each(addrs, function (addr) { + if (addr.author == author && addr.email == email) { + result = addr; + } + }); + if (!result && autocreate) { + result = { + label: '(INVALID) "' + author + '" <' + email + '>', + author: author, + email: email + }; + addrs.push(result); + } + return result; + }, + getByEmail: function getByEmail(email) { + return first(_.where(addrs, {email: email})); + }, + getByLabel: function (label) { + return first(_.where(addrs, {label: label})); + }, + getDefault: function getDefault() { + return first(_.where(addrs, {is_default: "1"})); + } + }; + }); + + angular.module('crmMailing').factory('crmMsgTemplates', function ($q, crmApi) { + var tpls = _.map(CRM.crmMailing.mesTemplate, function (tpl) { + return angular.extend({}, tpl, { + //id: tpl parseInt(tpl.id) + }); + }); + window.tpls = tpls; + var lastModifiedTpl = null; + return { + // Get a template + // @param id MessageTemplate id (per APIv3) + // @return Promise MessageTemplate (per APIv3) + get: function get(id) { + return crmApi('MessageTemplate', 'getsingle', { + "return": "id,msg_subject,msg_html,msg_title,msg_text", + "id": id + }); + }, + // Save a template + // @param tpl MessageTemplate (per APIv3) For new templates, omit "id" + // @return Promise MessageTemplate (per APIv3) + save: function (tpl) { + return crmApi('MessageTemplate', 'create', tpl).then(function (response) { + if (!tpl.id) { + tpl.id = '' + response.id; //parseInt(response.id); + tpls.push(tpl); + } + lastModifiedTpl = tpl; + return tpl; + }); + }, + // @return Object MessageTemplate (per APIv3) + getLastModifiedTpl: function () { + return lastModifiedTpl; + }, + getAll: function getAll() { + return tpls; + } + }; + }); + + // The crmMailingMgr service provides business logic for loading, saving, previewing, etc + angular.module('crmMailing').factory('crmMailingMgr', function ($q, crmApi, crmFromAddresses, crmQueue) { + var qApi = crmQueue(crmApi); + var pickDefaultMailComponent = function pickDefaultMailComponent(type) { + var mcs = _.where(CRM.crmMailing.headerfooterList, { + component_type: type, + is_default: "1" + }); + return (mcs.length >= 1) ? mcs[0].id : null; + }; + + return { + // @param scalar idExpr a number or the literal string 'new' + // @return Promise|Object Mailing (per APIv3) + getOrCreate: function getOrCreate(idExpr) { + return (idExpr == 'new') ? this.create() : this.get(idExpr); + }, + // @return Promise Mailing (per APIv3) + get: function get(id) { + var crmMailingMgr = this; + var mailing; + return qApi('Mailing', 'getsingle', {id: id}) + .then(function (getResult) { + mailing = getResult; + return $q.all([ + crmMailingMgr._loadGroups(mailing), + crmMailingMgr._loadJobs(mailing) + ]); + }) + .then(function () { + return mailing; + }); + }, + // Call MailingGroup.get and merge results into "mailing" + _loadGroups: function (mailing) { + return crmApi('MailingGroup', 'get', {mailing_id: mailing.id}) + .then(function (groupResult) { + mailing.recipients = {}; + mailing.recipients.groups = {include: [], exclude: [], base: []}; + mailing.recipients.mailings = {include: [], exclude: []}; + _.each(groupResult.values, function (mailingGroup) { + var bucket = (/^civicrm_group/.test(mailingGroup.entity_table)) ? 'groups' : 'mailings'; + var entityId = parseInt(mailingGroup.entity_id); + mailing.recipients[bucket][mailingGroup.group_type.toLowerCase()].push(entityId); + }); + }); + }, + // Call MailingJob.get and merge results into "mailing" + _loadJobs: function (mailing) { + return crmApi('MailingJob', 'get', {mailing_id: mailing.id, is_test: 0}) + .then(function (jobResult) { + mailing.jobs = mailing.jobs || {}; + angular.extend(mailing.jobs, jobResult.values); + }); + }, + // @return Object Mailing (per APIv3) + create: function create(params) { + var defaults = { + jobs: {}, // {jobId: JobRecord} + recipients: { + groups: {include: [], exclude: [], base: []}, + mailings: {include: [], exclude: []} + }, + template_type: "traditional", + // Workaround CRM-19756 w/template_options.nonce + template_options: {nonce: 1}, + name: "", + campaign_id: null, + replyto_email: "", + subject: "", + body_html: "", + body_text: "" + }; + return angular.extend({}, defaults, params); + }, + + // @param mailing Object (per APIv3) + // @return Promise + 'delete': function (mailing) { + if (mailing.id) { + return qApi('Mailing', 'delete', {id: mailing.id}); + } + else { + var d = $q.defer(); + d.resolve(); + return d.promise; + } + }, + + // Search the body, header, and footer for required tokens. + // ex: var msgs = findMissingTokens(mailing, 'body_html'); + findMissingTokens: function(mailing, field) { + var missing = {}; + if (!_.isEmpty(mailing[field]) && !CRM.crmMailing.disableMandatoryTokensCheck) { + var body = ''; + if (mailing.footer_id) { + var footer = _.where(CRM.crmMailing.headerfooterList, {id: mailing.footer_id}); + body = body + footer[0][field]; + + } + body = body + mailing[field]; + if (mailing.header_id) { + var header = _.where(CRM.crmMailing.headerfooterList, {id: mailing.header_id}); + body = body + header[0][field]; + } + + angular.forEach(CRM.crmMailing.requiredTokens, function(value, token) { + if (!_.isObject(value)) { + if (body.indexOf('{' + token + '}') < 0) { + missing[token] = value; + } + } + else { + var count = 0; + angular.forEach(value, function(nestedValue, nestedToken) { + if (body.indexOf('{' + nestedToken + '}') >= 0) { + count++; + } + }); + if (count === 0) { + angular.extend(missing, value); + } + } + }); + } + return missing; + }, + + // Copy all data fields in (mailingFrom) to (mailingTgt) -- except for (excludes) + // ex: crmMailingMgr.mergeInto(newMailing, mailingTemplate, ['subject']); + mergeInto: function mergeInto(mailingTgt, mailingFrom, excludes) { + var MAILING_FIELDS = [ + // always exclude: 'id' + 'name', + 'campaign_id', + 'from_name', + 'from_email', + 'replyto_email', + 'subject', + 'dedupe_email', + 'recipients', + 'body_html', + 'body_text', + 'footer_id', + 'header_id', + 'visibility', + 'url_tracking', + 'dedupe_email', + 'forward_replies', + 'auto_responder', + 'open_tracking', + 'override_verp', + 'optout_id', + 'reply_id', + 'resubscribe_id', + 'unsubscribe_id' + ]; + if (!excludes) { + excludes = []; + } + _.each(MAILING_FIELDS, function (field) { + if (!_.contains(excludes, field)) { + mailingTgt[field] = mailingFrom[field]; + } + }); + }, + + // @param mailing Object (per APIv3) + // @return Promise an object with "subject", "body_text", "body_html" + preview: function preview(mailing) { + return this.getPreviewContent(qApi, mailing); + }, + + // @param backend + // @param mailing Object (per APIv3) + // @return preview content + getPreviewContent: function getPreviewContent(backend, mailing) { + if (CRM.crmMailing.workflowEnabled && !CRM.checkPerm('create mailings') && !CRM.checkPerm('access CiviMail')) { + return backend('Mailing', 'preview', {id: mailing.id}).then(function(result) { + return result.values; + }); + } + else { + var params = angular.extend({}, mailing); + delete params.id; + return backend('Mailing', 'preview', params).then(function(result) { + // changes rolled back, so we don't care about updating mailing + return result.values; + }); + } + }, + + // @param mailing Object (per APIv3) + // @param int previewLimit + // @return Promise for a list of recipients (mailing_id, contact_id, api.contact.getvalue, api.email.getvalue) + previewRecipients: function previewRecipients(mailing, previewLimit) { + // To get list of recipients, we tentatively save the mailing and + // get the resulting recipients -- then rollback any changes. + var params = angular.extend({}, mailing.recipients, { + id: mailing.id, + 'api.MailingRecipients.get': { + mailing_id: '$value.id', + options: {limit: previewLimit}, + 'api.contact.getvalue': {'return': 'display_name'}, + 'api.email.getvalue': {'return': 'email'} + } + }); + delete params.scheduled_date; + delete params.recipients; // the content was merged in + return qApi('Mailing', 'create', params).then(function (recipResult) { + // changes rolled back, so we don't care about updating mailing + mailing.modified_date = recipResult.values[recipResult.id].modified_date; + return recipResult.values[recipResult.id]['api.MailingRecipients.get'].values; + }); + }, + + previewRecipientCount: function previewRecipientCount(mailing, crmMailingCache, rebuild) { + var cachekey = 'mailing-' + mailing.id + '-recipient-count'; + var recipientCount = crmMailingCache.get(cachekey); + if (rebuild || _.isEmpty(recipientCount)) { + // To get list of recipients, we tentatively save the mailing and + // get the resulting recipients -- then rollback any changes. + var params = angular.extend({}, mailing, mailing.recipients, { + id: mailing.id, + 'api.MailingRecipients.getcount': { + mailing_id: '$value.id' + } + }); + // if this service is executed on rebuild then also fetch the recipients list + if (rebuild) { + params = angular.extend(params, { + 'api.MailingRecipients.get': { + mailing_id: '$value.id', + options: {limit: 50}, + 'api.contact.getvalue': {'return': 'display_name'}, + 'api.email.getvalue': {'return': 'email'} + } + }); + crmMailingCache.put('mailing-' + mailing.id + '-recipient-params', params.recipients); + } + delete params.scheduled_date; + delete params.recipients; // the content was merged in + recipientCount = qApi('Mailing', 'create', params).then(function (recipResult) { + // changes rolled back, so we don't care about updating mailing + mailing.modified_date = recipResult.values[recipResult.id].modified_date; + if (rebuild) { + crmMailingCache.put('mailing-' + mailing.id + '-recipient-list', recipResult.values[recipResult.id]['api.MailingRecipients.get'].values); + } + return recipResult.values[recipResult.id]['api.MailingRecipients.getcount']; + }); + crmMailingCache.put(cachekey, recipientCount); + } + + return recipientCount; + }, + + // Save a (draft) mailing + // @param mailing Object (per APIv3) + // @return Promise + save: function(mailing) { + var params = angular.extend({}, mailing, mailing.recipients); + + // Angular ngModel sometimes treats blank fields as undefined. + angular.forEach(mailing, function(value, key) { + if (value === undefined || value === null) { + mailing[key] = ''; + } + }); + + // WORKAROUND: Mailing.create (aka CRM_Mailing_BAO_Mailing::create()) interprets scheduled_date + // as an *intent* to schedule and creates tertiary records. Saving a draft with a scheduled_date + // is therefore not allowed. Remove this after fixing Mailing.create's contract. + delete params.scheduled_date; + + delete params.jobs; + + delete params.recipients; // the content was merged in + params._skip_evil_bao_auto_recipients_ = 1; // skip recipient rebuild on simple save + return qApi('Mailing', 'create', params).then(function(result) { + if (result.id && !mailing.id) { + mailing.id = result.id; + } // no rollback, so update mailing.id + // Perhaps we should reload mailing based on result? + mailing.modified_date = result.values[result.id].modified_date; + return mailing; + }); + }, + + // Schedule/send the mailing + // @param mailing Object (per APIv3) + // @return Promise + submit: function (mailing) { + var crmMailingMgr = this; + var params = { + id: mailing.id, + approval_date: 'now', + scheduled_date: mailing.scheduled_date ? mailing.scheduled_date : 'now' + }; + return qApi('Mailing', 'submit', params) + .then(function (result) { + angular.extend(mailing, result.values[result.id]); // Perhaps we should reload mailing based on result? + return crmMailingMgr._loadJobs(mailing); + }) + .then(function () { + return mailing; + }); + }, + + // Immediately send a test message + // @param mailing Object (per APIv3) + // @param to Object with either key "email" (string) or "gid" (int) + // @return Promise for a list of delivery reports + sendTest: function (mailing, recipient) { + var params = angular.extend({}, mailing, mailing.recipients, { + // options: {force_rollback: 1}, // Test mailings include tracking features, so the mailing must be persistent + 'api.Mailing.send_test': { + mailing_id: '$value.id', + test_email: recipient.email, + test_group: recipient.gid + } + }); + + // WORKAROUND: Mailing.create (aka CRM_Mailing_BAO_Mailing::create()) interprets scheduled_date + // as an *intent* to schedule and creates tertiary records. Saving a draft with a scheduled_date + // is therefore not allowed. Remove this after fixing Mailing.create's contract. + delete params.scheduled_date; + + delete params.jobs; + + delete params.recipients; // the content was merged in + + params._skip_evil_bao_auto_recipients_ = 1; // skip recipient rebuild while sending test mail + + return qApi('Mailing', 'create', params).then(function (result) { + if (result.id && !mailing.id) { + mailing.id = result.id; + } // no rollback, so update mailing.id + mailing.modified_date = result.values[result.id].modified_date; + return result.values[result.id]['api.Mailing.send_test'].values; + }); + } + }; + }); + + // The preview manager performs preview actions while putting up a visible UI (e.g. dialogs & status alerts) + angular.module('crmMailing').factory('crmMailingPreviewMgr', function (dialogService, crmMailingMgr, crmStatus) { + return { + // @param mode string one of 'html', 'text', or 'full' + // @return Promise + preview: function preview(mailing, mode) { + var templates = { + html: '~/crmMailing/PreviewMgr/html.html', + text: '~/crmMailing/PreviewMgr/text.html', + full: '~/crmMailing/PreviewMgr/full.html' + }; + var result = null; + var p = crmMailingMgr + .getPreviewContent(CRM.api3, mailing) + .then(function (content) { + var options = CRM.utils.adjustDialogDefaults({ + autoOpen: false, + title: ts('Subject: %1', { + 1: content.subject + }) + }); + result = dialogService.open('previewDialog', templates[mode], content, options); + }); + crmStatus({start: ts('Previewing...'), success: ''}, p); + return result; + }, + + // @param to Object with either key "email" (string) or "gid" (int) + // @return Promise + sendTest: function sendTest(mailing, recipient) { + var promise = crmMailingMgr.sendTest(mailing, recipient) + .then(function (deliveryInfos) { + var count = Object.keys(deliveryInfos).length; + if (count === 0) { + CRM.alert(ts('Could not identify any recipients. Perhaps the group is empty?')); + } + }) + ; + return crmStatus({start: ts('Sending...'), success: ts('Sent')}, promise); + } + }; + }); + + angular.module('crmMailing').factory('crmMailingStats', function (crmApi, crmLegacy) { + var statTypes = [ + // {name: 'Recipients', title: ts('Intended Recipients'), searchFilter: '', eventsFilter: '&event=queue', reportType: 'detail', reportFilter: ''}, + {name: 'Delivered', title: ts('Successful Deliveries'), searchFilter: '&mailing_delivery_status=Y', eventsFilter: '&event=delivered', reportType: 'detail', reportFilter: '&delivery_status_value=successful'}, + {name: 'Opened', title: ts('Tracked Opens'), searchFilter: '&mailing_open_status=Y', eventsFilter: '&event=opened', reportType: 'opened', reportFilter: ''}, + {name: 'Unique Clicks', title: ts('Click-throughs'), searchFilter: '&mailing_click_status=Y', eventsFilter: '&event=click&distinct=1', reportType: 'clicks', reportFilter: ''}, + // {name: 'Forward', title: ts('Forwards'), searchFilter: '&mailing_forward=1', eventsFilter: '&event=forward', reportType: 'detail', reportFilter: '&is_forwarded_value=1'}, + // {name: 'Replies', title: ts('Replies'), searchFilter: '&mailing_reply_status=Y', eventsFilter: '&event=reply', reportType: 'detail', reportFilter: '&is_replied_value=1'}, + {name: 'Bounces', title: ts('Bounces'), searchFilter: '&mailing_delivery_status=N', eventsFilter: '&event=bounce', reportType: 'bounce', reportFilter: ''}, + {name: 'Unsubscribers', title: ts('Unsubscribes'), searchFilter: '&mailing_unsubscribe=1', eventsFilter: '&event=unsubscribe', reportType: 'detail', reportFilter: '&is_unsubscribed_value=1'}, + // {name: 'OptOuts', title: ts('Opt-Outs'), searchFilter: '&mailing_optout=1', eventsFilter: '&event=optout', reportType: 'detail', reportFilter: ''} + ]; + + return { + getStatTypes: function() { + return statTypes; + }, + + /** + * @param mailingIds object + * List of mailing IDs ({a: 123, b: 456}) + * @return Promise + * List of stats for each mailing + * ({a: ...object..., b: ...object...}) + */ + getStats: function(mailingIds) { + var params = {}; + angular.forEach(mailingIds, function(mailingId, name) { + params[name] = ['Mailing', 'stats', {mailing_id: mailingId, is_distinct: 0}]; + }); + return crmApi(params).then(function(result) { + var stats = {}; + angular.forEach(mailingIds, function(mailingId, name) { + stats[name] = result[name].values[mailingId]; + }); + return stats; + }); + }, + + /** + * Determine the legacy URL for a report about a given mailing and stat. + * + * @param mailing object + * @param statType object (see statTypes above) + * @param view string ('search', 'event', 'report') + * @param returnPath string|null Return path (relative to Angular base) + * @return string|null + */ + getUrl: function getUrl(mailing, statType, view, returnPath) { + switch (view) { + case 'events': + var retParams = returnPath ? '&context=angPage&angPage=' + returnPath : ''; + return crmLegacy.url('civicrm/mailing/report/event', + 'reset=1&mid=' + mailing.id + statType.eventsFilter + retParams); + case 'search': + return crmLegacy.url('civicrm/contact/search/advanced', + 'force=1&mailing_id=' + mailing.id + statType.searchFilter); + case 'report': + var reportIds = CRM.crmMailing.reportIds; + return crmLegacy.url('civicrm/report/instance/' + reportIds[statType.reportType], + 'reset=1&mailing_id_value=' + mailing.id + statType.reportFilter); + default: + return null; + } + } + }; + }); + + // crmMailingSimpleDirective is a template/factory-function for constructing very basic + // directives that accept a "mailing" argument. Please don't overload it. If one continues building + // this, it risks becoming a second system that violates Angular architecture (and prevents one + // from using standard Angular docs+plugins). So this really shouldn't do much -- it is really + // only for simple directives. For something complex, suck it up and write 10 lines of boilerplate. + angular.module('crmMailing').factory('crmMailingSimpleDirective', function ($q, crmMetadata, crmUiHelp) { + return function crmMailingSimpleDirective(directiveName, templateUrl) { + return { + scope: { + crmMailing: '@' + }, + templateUrl: templateUrl, + link: function (scope, elm, attr) { + scope.$parent.$watch(attr.crmMailing, function(newValue){ + scope.mailing = newValue; + }); + scope.crmMailingConst = CRM.crmMailing; + scope.ts = CRM.ts(null); + scope.hs = crmUiHelp({file: 'CRM/Mailing/MailingUI'}); + scope[directiveName] = attr[directiveName] ? scope.$parent.$eval(attr[directiveName]) : {}; + $q.when(crmMetadata.getFields('Mailing'), function(fields) { + scope.mailingFields = fields; + }); + } + }; + }; + }); + + angular.module('crmMailing').factory('crmMailingCache', ['$cacheFactory', function($cacheFactory) { + return $cacheFactory('crmMailingCache'); + }]); + +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB.ang.php b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB.ang.php new file mode 100644 index 00000000..6741399e --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB.ang.php @@ -0,0 +1,19 @@ +<?php +// This file declares an Angular module which can be autoloaded +// in CiviCRM. See also: +// http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_angularModules + +// ODDITY: Only loads if you have CiviMail permissions. +// ODDITY: Extra resources loaded via CRM_Mailing_Info::getAngularModules. + +return [ + 'ext' => 'civicrm', + 'js' => [ + 'ang/crmMailingAB.js', + 'ang/crmMailingAB/*.js', + 'ang/crmMailingAB/*/*.js', + ], + 'css' => ['ang/crmMailingAB.css'], + 'partials' => ['ang/crmMailingAB'], + 'requires' => ['ngRoute', 'ui.utils', 'crmUi', 'crmAttachment', 'crmMailing', 'crmD3', 'crmResource'], +]; diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB.css b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB.css new file mode 100644 index 00000000..cfee8b95 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB.css @@ -0,0 +1,25 @@ +.crm-mailing-ab-slider .slider-test .ui-slider-range { + background: #5050b0; +} + +.crm-mailing-ab-slider .slider-win .ui-slider-range { + background: #50b050; +} + +.crm-mailing-ab-stats .series { + fill: none; +} + +.crm-mailing-ab-stats .axis path, .crm-mailing-ab-stats .axis line { + fill: none; + stroke: #000; + shape-rendering: crispEdges; +} + +.crm-mailing-ab-col { + width: 18em; +} + +.crm-mailing-ab-table tbody tr:last-child td { + padding-bottom: 2em; +} diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB.js new file mode 100644 index 00000000..49261ff9 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB.js @@ -0,0 +1,44 @@ +(function(angular, $, _) { + + angular.module('crmMailingAB', CRM.angRequires('crmMailingAB')); + angular.module('crmMailingAB').config([ + '$routeProvider', + function($routeProvider) { + $routeProvider.when('/abtest', { + templateUrl: '~/crmMailingAB/ListCtrl.html', + controller: 'CrmMailingABListCtrl', + resolve: { + mailingABList: function($route, crmApi) { + return crmApi('MailingAB', 'get', {rowCount: 0}); + }, + fields: function(crmMetadata) { + return crmMetadata.getFields('MailingAB'); + } + } + }); + $routeProvider.when('/abtest/new', { + template: '<p>' + ts('Initializing...') + '</p>', + controller: 'CrmMailingABNewCtrl', + resolve: { + abtest: function($route, CrmMailingAB) { + var abtest = new CrmMailingAB(null); + return abtest.load().then(function() { + return abtest.save(); + }); + } + } + }); + $routeProvider.when('/abtest/:id', { + templateUrl: '~/crmMailingAB/EditCtrl/main.html', + controller: 'CrmMailingABEditCtrl', + resolve: { + abtest: function($route, CrmMailingAB) { + var abtest = new CrmMailingAB($route.current.params.id == 'new' ? null : $route.current.params.id); + return abtest.load(); + } + } + }); + } + ]); + +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB/BlockMailing.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB/BlockMailing.html new file mode 100644 index 00000000..92448156 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB/BlockMailing.html @@ -0,0 +1,196 @@ +<!-- +Required vars: abtest, fields + +Note: Much of this file is duplicated in crmMailing and crmMailingAB with variations on placement/title/binding. +It could perhaps be thinned by 30-60% by making more directives. + +This template follows a basic pattern. For each included field, there are three variants, as in this example: + - fromAddress: The default From: address shared by both mailings (representatively mapped to mailing A) + - fromAddressA: The From: address for mailing A + - fromAddressB: The From: address for mailing B +Each variant is guarded with "ng-if='fields.fieldName'"; if true, the field will be displayed and +processed by Angular; if false, the field will be hidden and completely ignored by Angular. +--> +<div class="crm-block" ng-form="subform" crm-ui-id-scope> + <div class="crm-group"> + + + <div crm-ui-field="{name: 'subform.msg_template_id', title: ts('Template')}" ng-if="fields.msg_template_id"> + <div ng-controller="MsgTemplateCtrl"> + <select + crm-ui-id="subform.msg_template_id" + name="msg_template_id" + class="fa-clipboard" + crm-ui-select="{dropdownAutoWidth : true, allowClear: true, placeholder: ts('Message Template')}" + ng-model="abtest.mailings.a.msg_template_id" + ng-change="loadTemplate(abtest.mailings.a, abtest.mailings.a.msg_template_id)" + > + <option value=""></option> + <option ng-repeat="frm in crmMsgTemplates.getAll() | orderBy:'msg_title'" ng-value="frm.id">{{frm.msg_title}}</option> + </select> + <a crm-icon="fa-floppy-o" ng-click="saveTemplate(abtest.mailings.a)" class="crm-hover-button" title="{{ts('Save As')}}"></a> + </div> + </div> + <div crm-ui-field="{name: 'subform.msg_template_idA', title: ts('Template (A)')}" ng-if="fields.msg_template_idA"> + <div ng-controller="MsgTemplateCtrl"> + <select + crm-ui-id="subform.msg_template_idA" + name="msg_template_idA" + class="fa-clipboard" + crm-ui-select="{dropdownAutoWidth : true, allowClear: true, placeholder: ts('Message Template')}" + ng-model="abtest.mailings.a.msg_template_id" + ng-change="loadTemplate(abtest.mailings.a, abtest.mailings.a.msg_template_id)" + > + <option value=""></option> + <option ng-repeat="frm in crmMsgTemplates.getAll() | orderBy:'msg_title'" ng-value="frm.id">{{frm.msg_title}}</option> + </select> + <a crm-icon="fa-floppy-o" ng-click="saveTemplate(abtest.mailings.a)" class="crm-hover-button" title="{{ts('Save As')}}"></a> + </div> + </div> + <div crm-ui-field="{name: 'subform.msg_template_idB', title: ts('Template (B)')}" ng-if="fields.msg_template_idB"> + <div ng-controller="MsgTemplateCtrl"> + <select + crm-ui-id="subform.msg_template_idB" + name="msg_template_idB" + class="fa-clipboard" + crm-ui-select="{dropdownAutoWidth : true, allowClear: true, placeholder: ts('Message Template')}" + ng-model="abtest.mailings.b.msg_template_id" + ng-change="loadTemplate(abtest.mailings.b, abtest.mailings.b.msg_template_id)" + > + <option value=""></option> + <option ng-repeat="frm in crmMsgTemplates.getAll() | orderBy:'msg_title'" ng-value="frm.id">{{frm.msg_title}}</option> + </select> + <a crm-icon="fa-floppy-o" ng-click="saveTemplate(abtest.mailings.b)" class="crm-hover-button" title="{{ts('Save As')}}"></a> + </div> + </div> + + + <div crm-ui-field="{name: 'subform.fromAddress', title: ts('From'), help: hs('from_email')}" ng-if="fields.fromAddress"> + <span ng-controller="EmailAddrCtrl" crm-mailing-from-address="fromPlaceholder" crm-mailing="abtest.mailings.a"> + <select + crm-ui-id="subform.fromAddress" + crm-ui-select="{dropdownAutoWidth : true, allowClear: false, placeholder: ts('Email address')}" + name="fromAddress" + ng-model="fromPlaceholder.label" + required> + <option value=""></option> + <option ng-repeat="frm in crmFromAddresses.getAll() | filter:{is_active:1} | orderBy:'weight'" value="{{frm.label}}">{{frm.label}}</option> + </select> + </span> + </div> + <div crm-ui-field="{name: 'subform.fromAddressA', title: ts('From (A)'), help: hs('from_email')}" ng-if="fields.fromAddressA"> + <span ng-controller="EmailAddrCtrl" crm-mailing-from-address="fromPlaceholder" crm-mailing="abtest.mailings.a"> + <select + crm-ui-id="subform.fromAddressA" + crm-ui-select="{dropdownAutoWidth : true, allowClear: false, placeholder: ts('Email address')}" + name="fromAddressA" + ng-model="fromPlaceholder.label" + required> + <option value=""></option> + <option ng-repeat="frm in crmFromAddresses.getAll() | filter:{is_active:1} | orderBy:'weight'" value="{{frm.label}}">{{frm.label}}</option> + </select> + </span> + </div> + <div crm-ui-field="{name: 'subform.fromAddressB', title: ts('From (B)'), help: hs('from_email')}" ng-if="fields.fromAddressB"> + <span ng-controller="EmailAddrCtrl" crm-mailing-from-address="fromPlaceholder" crm-mailing="abtest.mailings.b"> + <select + crm-ui-id="subform.fromAddressB" + crm-ui-select="{dropdownAutoWidth : true, allowClear: false, placeholder: ts('Email address')}" + name="fromAddressB" + ng-model="fromPlaceholder.label" + required> + <option value=""></option> + <option ng-repeat="frm in crmFromAddresses.getAll() | filter:{is_active:1} | orderBy:'weight'" value="{{frm.label}}">{{frm.label}}</option> + </select> + </span> + </div> + + + <div crm-ui-field="{name: 'subform.replyTo', title: ts('Reply-To')}" ng-show="crmMailingConst.enableReplyTo" ng-if="fields.replyTo"> + <span ng-controller="EmailAddrCtrl"> + <select + crm-ui-id="subform.replyTo" + crm-ui-select="{dropdownAutoWidth : true, allowClear: true, placeholder: ts('Email address')}" + name="replyTo" + ng-change="checkReplyToChange(abtest.mailings.a)" + ng-model="abtest.mailings.a.replyto_email" + > + <option value=""></option> + <option ng-repeat="frm in crmFromAddresses.getAll() | filter:{is_active:1} | orderBy:'weight'" value="{{frm.label}}">{{frm.label}}</option> + </select> + </span> + </div> + <div crm-ui-field="{name: 'subform.replyToA', title: ts('Reply-To (A)')}" ng-show="crmMailingConst.enableReplyTo" ng-if="fields.replyToA"> + <span ng-controller="EmailAddrCtrl"> + <select + crm-ui-id="subform.replyToA" + crm-ui-select="{dropdownAutoWidth : true, allowClear: true, placeholder: ts('Email address')}" + name="replyToA" + ng-change="checkReplyToChange(abtest.mailings.a)" + ng-model="abtest.mailings.a.replyto_email" + > + <option value=""></option> + <option ng-repeat="frm in crmFromAddresses.getAll() | filter:{is_active:1} | orderBy:'weight'" value="{{frm.label}}">{{frm.label}}</option> + </select> + </span> + </div> + <div crm-ui-field="{name: 'subform.replyToB', title: ts('Reply-To (B)')}" ng-show="crmMailingConst.enableReplyTo" ng-if="fields.replyToB"> + <span ng-controller="EmailAddrCtrl"> + <select + crm-ui-id="subform.replyToB" + crm-ui-select="{dropdownAutoWidth : true, allowClear: true, placeholder: ts('Email address')}" + name="replyToB" + ng-change="checkReplyToChange(abtest.mailings.b)" + ng-model="abtest.mailings.b.replyto_email" + > + <option value=""></option> + <option ng-repeat="frm in crmFromAddresses.getAll() | filter:{is_active:1} | orderBy:'weight'" value="{{frm.label}}">{{frm.label}}</option> + </select> + </span> + </div> + + + <div crm-ui-field="{name: 'subform.subject', title: ts('Subject')}" ng-if="fields.subject"> + <div style="float: right;"> + <input crm-mailing-token on-select="$broadcast('insert:subject', token.name)" tabindex="-1"> + </div> + <input + crm-ui-id="subform.subject" + crm-ui-insert-rx="insert:subject" + type="text" + class="crm-form-text" + ng-model="abtest.mailings.a.subject" + required + placeholder="Subject" + name="subject" > + </div> + <div crm-ui-field="{name: 'subform.subjectA', title: ts('Subject (A)')}" ng-if="fields.subjectA"> + <div style="float: right;"> + <input crm-mailing-token on-select="$broadcast('insert:subjectA', token.name)" tabindex="-1"> + </div> + <input + crm-ui-id="subform.subjectA" + crm-ui-insert-rx="insert:subjectA" + type="text" + class="crm-form-text" + ng-model="abtest.mailings.a.subject" + required + placeholder="Subject" + name="subjectA" > + </div> + <div crm-ui-field="{name: 'subform.subjectB', title: ts('Subject (B)')}" ng-if="fields.subjectB"> + <div style="float: right;"> + <input crm-mailing-token on-select="$broadcast('insert:subjectB', token.name)" tabindex="-1"> + </div> + <input + crm-ui-id="subform.subjectB" + crm-ui-insert-rx="insert:subjectB" + type="text" + class="crm-form-text" + ng-model="abtest.mailings.b.subject" + required + placeholder="Subject" + name="subjectB" > + </div> + </div> +</div> diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB/BlockMailing.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB/BlockMailing.js new file mode 100644 index 00000000..d738ab6e --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB/BlockMailing.js @@ -0,0 +1,32 @@ +(function(angular, $, _) { + + // example: + // scope.myAbtest = new CrmMailingAB(); + // <crm-mailing-ab-block-mailing="{fromAddressA: 1, fromAddressB: 1}" crm-abtest="myAbtest" /> + var simpleDirectives = { + crmMailingAbBlockMailing: '~/crmMailingAB/BlockMailing.html' + }; + _.each(simpleDirectives, function(templateUrl, directiveName) { + angular.module('crmMailingAB').directive(directiveName, function($parse, crmMailingABCriteria, crmUiHelp) { + var scopeDesc = {crmAbtest: '@'}; + scopeDesc[directiveName] = '@'; + + return { + scope: scopeDesc, + templateUrl: templateUrl, + link: function(scope, elm, attr) { + var model = $parse(attr.crmAbtest); + scope.abtest = model(scope.$parent); + scope.crmMailingConst = CRM.crmMailing; + scope.crmMailingABCriteria = crmMailingABCriteria; + scope.ts = CRM.ts(null); + scope.hs = crmUiHelp({file: 'CRM/Mailing/MailingUI'}); + + var fieldsModel = $parse(attr[directiveName]); + scope.fields = fieldsModel(scope.$parent); + } + }; + }); + }); + +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB/BlockSetup.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB/BlockSetup.html new file mode 100644 index 00000000..7d124cc0 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB/BlockSetup.html @@ -0,0 +1,67 @@ +<div class="crm-block" ng-form="setupForm" crm-ui-id-scope> + <div class="crm-group"> + <div class="help" ng-if="fields.help"> + {{ts('A/B testing allows you to send two test mailings to a random subset of your recipients. After collecting and comparing metrics, the more successful mailing will be sent to the remaining recipients.')}} + </div> + <div crm-ui-field="{name: 'setupForm.abName', title: ts('Name'), help: hs('name')}" ng-if="fields.abName"> + <input type="text" + crm-ui-id="setupForm.abName" + name="abName" + ng-model="abtest.ab.name" + class="crm-form-text" + placeholder="A/B Test Name" + required/> + </div> + <div crm-ui-field="{name: 'setupForm.campaign', title: ts('Campaign'), help: hs({id: 'id-campaign_id', file: 'CRM/Campaign/Form/addCampaignToComponent'})}" ng-show="crmMailingConst.campaignEnabled" + ng-if="fields.campaign"> + <input + crm-entityref="{entity: 'Campaign', select: {allowClear: true, placeholder: ts('Select Campaign')}}" + crm-ui-id="setupForm.campaign" + name="campaign" + ng-model="abtest.mailings.a.campaign_id" + ng-change="abtest.mailings.b.campaign_id=abtest.mailings.a.campaign_id" + /> + </div> + <div crm-ui-field="{title: ts('Test Type')}" ng-if="fields.testing_criteria"> + <div ng-repeat="criteria in crmMailingABCriteria.getAll()"> + <label> + <input name="testing_criteria" ng-model="abtest.ab.testing_criteria" type="radio" + value="{{criteria.value}}" required/> + {{criteria.label}} + </label> + </div> + </div> + <div crm-ui-field="{name: 'setupForm.recipients', title: ts('Recipients')}" ng-if="fields.recipients"> + <div crm-mailing-block-recipients="{name: 'recipients', id: 'setupForm.recipients'}" crm-mailing="abtest.mailings.a"></div> + </div> + <div crm-ui-field="{title: ts('Distribution')}" ng-if="fields.group_percentage"> + <div crm-mailing-ab-slider ng-model="abtest.ab.group_percentage"></div> + </div> + <div crm-ui-field="{title: ts('Send')}" ng-if="fields.scheduled_date"> + <div crm-mailing-radio-date="schedule" ng-model="abtest.mailings.a.scheduled_date"> + <div> + <input ng-model="schedule.mode" type="radio" name="send" value="now" id="schedule-send-now"/> + <label for="schedule-send-now">{{ts('Send A/B test immediately')}}</label> + </div> + <div> + <input ng-model="schedule.mode" type="radio" name="send" value="at" id="schedule-send-at"/> + <label for="schedule-send-at">{{ts('Send A/B test at:')}}</label> + <input crm-ui-datepicker ng-model="schedule.datetime"/> + </div> + </div> + </div> + <div crm-ui-field="{title: ts('Assess')}" ng-if="fields.declare_winning_time"> + <div crm-mailing-radio-date="assessSched" ng-model="abtest.ab.declare_winning_time"> + <div> + <input ng-model="assessSched.mode" type="radio" name="assess" value="now" id="schedule-assess-now"/> + <label for="schedule-assess-now">{{ts('Assess A/B results on an on-going basis')}}</label> + </div> + <div> + <input ng-model="assessSched.mode" type="radio" name="assess" value="at" id="schedule-assess-at"/> + <label for="schedule-assess-at">{{ts('Assess A/B test at:')}}</label> + <input crm-ui-datepicker ng-model="assessSched.datetime"/> + </div> + </div> + </div> + </div> +</div> diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB/BlockSetup.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB/BlockSetup.js new file mode 100644 index 00000000..5809cdd0 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB/BlockSetup.js @@ -0,0 +1,32 @@ +(function(angular, $, _) { + + // example: + // scope.myAbtest = new CrmMailingAB(); + // <crm-mailing-ab-block-setup="{abName: 1, group_percentage: 1}" crm-abtest="myAbtest" /> + var simpleDirectives = { + crmMailingAbBlockSetup: '~/crmMailingAB/BlockSetup.html' + }; + _.each(simpleDirectives, function(templateUrl, directiveName) { + angular.module('crmMailingAB').directive(directiveName, function($parse, crmMailingABCriteria, crmUiHelp) { + var scopeDesc = {crmAbtest: '@'}; + scopeDesc[directiveName] = '@'; + + return { + scope: scopeDesc, + templateUrl: templateUrl, + link: function(scope, elm, attr) { + var model = $parse(attr.crmAbtest); + scope.abtest = model(scope.$parent); + scope.crmMailingConst = CRM.crmMailing; + scope.crmMailingABCriteria = crmMailingABCriteria; + scope.ts = CRM.ts(null); + scope.hs = crmUiHelp({file: 'CRM/Mailing/MailingUI'}); + + var fieldsModel = $parse(attr[directiveName]); + scope.fields = fieldsModel(scope.$parent); + } + }; + }); + }); + +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB/EditCtrl.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB/EditCtrl.js new file mode 100644 index 00000000..b189bafc --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB/EditCtrl.js @@ -0,0 +1,149 @@ +(function(angular, $, _) { + + angular.module('crmMailingAB').controller('CrmMailingABEditCtrl', function($scope, abtest, crmMailingABCriteria, crmMailingMgr, crmMailingPreviewMgr, crmStatus, $q, $location, crmBlocker, $interval, $timeout, CrmAutosaveCtrl, dialogService) { + $scope.abtest = abtest; + var ts = $scope.ts = CRM.ts(null); + var block = $scope.block = crmBlocker(); + $scope.crmUrl = CRM.url; + var myAutosave = null; + $scope.crmMailingABCriteria = crmMailingABCriteria; + $scope.crmMailingConst = CRM.crmMailing; + $scope.checkPerm = CRM.checkPerm; + + $scope.isSubmitted = function isSubmitted() { + return _.size(abtest.mailings.a.jobs) > 0 || _.size(abtest.mailings.b.jobs) > 0; + }; + + $scope.sync = function sync() { + abtest.mailings.a.name = ts('Test A (%1)', {1: abtest.ab.name}); + abtest.mailings.b.name = ts('Test B (%1)', {1: abtest.ab.name}); + abtest.mailings.c.name = ts('Final (%1)', {1: abtest.ab.name}); + + if (abtest.ab.testing_criteria) { + // TODO review fields exposed in UI and make sure the sync rules match + switch (abtest.ab.testing_criteria) { + case 'subject': + var exclude_subject = [ + 'name', + 'recipients', + 'subject' + ]; + crmMailingMgr.mergeInto(abtest.mailings.b, abtest.mailings.a, exclude_subject); + crmMailingMgr.mergeInto(abtest.mailings.c, abtest.mailings.a, exclude_subject); + break; + case 'from': + var exclude_from = [ + 'name', + 'recipients', + 'from_name', + 'from_email' + ]; + crmMailingMgr.mergeInto(abtest.mailings.b, abtest.mailings.a, exclude_from); + crmMailingMgr.mergeInto(abtest.mailings.c, abtest.mailings.a, exclude_from); + break; + case 'full_email': + var exclude_full_email = [ + 'name', + 'recipients', + 'subject', + 'from_name', + 'from_email', + 'replyto_email', + 'override_verp', // keep override_verp and replyto_Email linked + 'body_html', + 'body_text' + ]; + crmMailingMgr.mergeInto(abtest.mailings.b, abtest.mailings.a, exclude_full_email); + crmMailingMgr.mergeInto(abtest.mailings.c, abtest.mailings.a, exclude_full_email); + break; + default: + throw "Unrecognized testing_criteria"; + } + } + return true; + }; + + // @return Promise + $scope.save = function save() { + return block(crmStatus({start: ts('Saving...'), success: ts('Saved')}, abtest.save())); + }; + + // @return Promise + $scope.previewMailing = function previewMailing(mailingName, mode) { + return crmMailingPreviewMgr.preview(abtest.mailings[mailingName], mode); + }; + + // @return Promise + $scope.sendTest = function sendTest(mailingName, recipient) { + return block(crmStatus({start: ts('Saving...'), success: ''}, abtest.save()) + .then(function() { + crmMailingPreviewMgr.sendTest(abtest.mailings[mailingName], recipient); + })); + }; + + // @return Promise + $scope.delete = function() { + return block(crmStatus({start: ts('Deleting...'), success: ts('Deleted')}, abtest.delete().then($scope.leave))); + }; + + // @return Promise + $scope.submit = function submit() { + if (block.check() || $scope.crmMailingAB.$invalid) { + return; + } + return block(crmStatus({start: ts('Saving...'), success: ''}, abtest.save()) + .then(function() { + return crmStatus({ + start: ts('Submitting...'), + success: ts('Submitted') + }, myAutosave.suspend(abtest.submitTest())); + // Note: We're going to leave, so we don't care that submit() modifies several server-side records. + // If we stayed on this page, then we'd care about updating and call: abtest.submitTest().then(...abtest.load()...) + }) + ); + }; + + $scope.leave = function leave() { + $location.path('abtest'); + $location.replace(); + }; + + $scope.selectWinner = function selectWinner(mailingName) { + var model = { + abtest: $scope.abtest, + mailingName: mailingName + }; + var options = CRM.utils.adjustDialogDefaults({ + autoOpen: false, + height: 'auto', + width: '40%', + title: ts('Select Final Mailing (Test %1)', { + 1: mailingName.toUpperCase() + }) + }); + return myAutosave.suspend(dialogService.open('selectWinnerDialog', '~/crmMailingAB/WinnerDialogCtrl.html', model, options)); + }; + + // initialize + var syncJob = $interval($scope.sync, 333); + $scope.$on('$destroy', function() { + $interval.cancel(syncJob); + }); + + myAutosave = new CrmAutosaveCtrl({ + save: $scope.save, + saveIf: function() { + return abtest.ab.status == 'Draft' && $scope.sync(); + }, + model: function() { + return abtest.getAutosaveSignature(); + }, + form: function() { + return $scope.crmMailingAB; + } + }); + $timeout(myAutosave.start); + $scope.$on('$destroy', myAutosave.stop); + }); + +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB/EditCtrl/edit.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB/EditCtrl/edit.html new file mode 100644 index 00000000..825f3a57 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB/EditCtrl/edit.html @@ -0,0 +1,178 @@ +<!-- + Implicit Controller: CrmMailingABEditCtrl + + An ABTest includes two mailings, but we don't require the user to enter two complete mailings. For + simplicity, the email composition UI generally displays A (unless we specifically decided to expose an + individual field from B). At the end of the composition process, the controller's "sync" operation will + merge shared settings from "A" into "B". +--> +<div ng-form="crmMailingABEdit"> + <div class="crm-block crm-form-block crmMailing"> + <div crm-ui-wizard> + <div crm-ui-wizard-step="10" crm-title="ts('Setup')" ng-form="setupForm"> + <div + crm-mailing-ab-block-setup="{ + help: 1, + abName: 1, + campaign: 1, + testing_criteria: 1 + }" + crm-abtest="abtest"></div> + </div> + <div crm-ui-wizard-step="11" crm-title="ts('Target')" ng-form="targetForm"> + <div + crm-mailing-ab-block-setup="{ + recipients: 1, + group_percentage: 1 + }" + crm-abtest="abtest"></div> + </div> + <div crm-ui-wizard-step="20" crm-title="ts('Compose')" ng-if="abtest.ab.testing_criteria != 'full_email'" ng-form="composeForm"> + <div crm-ui-tab-set> + <div crm-ui-tab id="tab-mailing" crm-title="ts('Mailing')"> + <div + ng-if="abtest.ab.testing_criteria == 'from'" + crm-mailing-ab-block-mailing="{ + msg_template_id: 1, + fromAddressA: 1, + fromAddressB: 1, + subject: 1 + }" + crm-abtest="abtest"></div> + <div + ng-if="abtest.ab.testing_criteria == 'subject'" + crm-mailing-ab-block-mailing="{ + msg_template_id: 1, + fromAddress: 1, + replyTo: 1, + subjectA: 1, + subjectB: 1 + }" + crm-abtest="abtest"></div> + <div crm-ui-accordion="{title: ts('HTML')}"> + <div crm-mailing-body-html crm-mailing="abtest.mailings.a"></div> + </div> + <div crm-ui-accordion="{title: ts('Plain Text'), collapsed: !abtest.mailings.a.body_text}"> + <div crm-mailing-body-text crm-mailing="abtest.mailings.a"></div> + </div> + </div> + <!-- + FIXME: Attachment UI works, but we haven't implemented backend logic for copying/sharing + of attachments among mailings A/B/C. + <div crm-ui-tab id="tab-attachment" crm-title="ts('Attachments')"> + <div crm-attachments="abtest.attachments.a"></div> + </div> + --> + <div crm-ui-tab id="tab-header" crm-title="ts('Header and Footer')"> + <div crm-mailing-block-header-footer crm-mailing="abtest.mailings.a"></div> + </div> + <div crm-ui-tab id="tab-pub" crm-title="ts('Publication')"> + <div crm-mailing-block-publication crm-mailing="abtest.mailings.a"></div> + </div> + <div crm-ui-tab id="tab-response" crm-title="ts('Responses')"> + <div crm-mailing-block-responses crm-mailing="abtest.mailings.a"></div> + </div> + </div> + <div crm-ui-accordion="{title: ts('Preview (A)')}"> + <div crm-mailing-block-preview crm-mailing="abtest.mailings.a" on-preview="previewMailing('a', preview.mode)" on-send="sendTest('a', preview.recipient)"></div> + </div> + <div crm-ui-accordion="{title: ts('Preview (B)')}"> + <div crm-mailing-block-preview crm-mailing="abtest.mailings.b" on-preview="previewMailing('b', preview.mode)" on-send="sendTest('b', preview.recipient)"></div> + </div> + </div> + <div crm-ui-wizard-step="21" crm-title="ts('Compose (A)')" ng-if="abtest.ab.testing_criteria == 'full_email'" ng-form="composeAForm"> + <div crm-ui-tab-set> + <div crm-ui-tab id="tab-mailingA" crm-title="ts('Mailing')"> + <div + crm-mailing-ab-block-mailing="{ + msg_template_idA: 1, + fromAddressA: 1, + replyToA: 1, + subjectA: 1 + }" + crm-abtest="abtest"></div> + <div crm-ui-accordion="{title: ts('HTML')}"> + <div crm-mailing-body-html crm-mailing="abtest.mailings.a"></div> + </div> + <div crm-ui-accordion="{title: ts('Plain Text'), collapsed: !abtest.mailings.a.body_text}"> + <div crm-mailing-body-text crm-mailing="abtest.mailings.a"></div> + </div> + </div> + <div crm-ui-tab id="tab-attachmentA" crm-title="ts('Attachments')"> + <div crm-attachments="abtest.attachments.a"></div> + </div> + <div crm-ui-tab id="tab-headerA" crm-title="ts('Header and Footer')"> + <div crm-mailing-block-header-footer crm-mailing="abtest.mailings.a"></div> + </div> + <div crm-ui-tab id="tab-pubA" crm-title="ts('Publication')"> + <div crm-mailing-block-publication crm-mailing="abtest.mailings.a"></div> + </div> + <div crm-ui-tab id="tab-responseA" crm-title="ts('Responses')"> + <div crm-mailing-block-responses crm-mailing="abtest.mailings.a"></div> + </div> + </div> + <div crm-ui-accordion="{title: ts('Preview')}"> + <div crm-mailing-block-preview crm-mailing="abtest.mailings.a" on-preview="previewMailing('a', preview.mode)" on-send="sendTest('a', preview.recipient)"></div> + </div> + </div> + <div crm-ui-wizard-step="22" crm-title="ts('Compose (B)')" ng-if="abtest.ab.testing_criteria == 'full_email'" ng-form="composeBForm"> + <div crm-ui-tab-set> + <div crm-ui-tab id="tab-mailingB" crm-title="ts('Mailing')"> + <div + crm-mailing-ab-block-mailing="{ + msg_template_idB: 1, + fromAddressB: 1, + replyToB: 1, + subjectB: 1 + }" + crm-abtest="abtest"></div> + <div crm-ui-accordion="{title: ts('HTML')}"> + <div crm-mailing-body-html crm-mailing="abtest.mailings.b"></div> + </div> + <div crm-ui-accordion="{title: ts('Plain Text'), collapsed: !abtest.mailings.b.body_text}"> + <div crm-mailing-body-text crm-mailing="abtest.mailings.b"></div> + </div> + </div> + <div crm-ui-tab id="tab-attachmentB" crm-title="ts('Attachments')"> + <div crm-attachments="abtest.attachments.b"></div> + </div> + <div crm-ui-tab id="tab-headerB" crm-title="ts('Header and Footer')"> + <div crm-mailing-block-header-footer crm-mailing="abtest.mailings.b"></div> + </div> + <div crm-ui-tab id="tab-pubB" crm-title="ts('Publication')"> + <div crm-mailing-block-publication crm-mailing="abtest.mailings.b"></div> + </div> + <div crm-ui-tab id="tab-responseB" crm-title="ts('Responses')"> + <div crm-mailing-block-responses crm-mailing="abtest.mailings.b"></div> + </div> + </div> + <div crm-ui-accordion="{title: ts('Preview')}"> + <div crm-mailing-block-preview crm-mailing="abtest.mailings.b" on-preview="previewMailing('b', preview.mode)" on-send="sendTest('b', preview.recipient)"></div> + </div> + </div> + <div crm-ui-wizard-step="30" crm-title="ts('Schedule')" ng-form="schedForm"> + <div + crm-mailing-ab-block-setup="{ + scheduled_date: 1, + declare_winning_time: 1 + }" + crm-abtest="abtest"></div> + <center> + <a class="button crmMailing-submit-button" ng-click="submit()" ng-class="{blocking: block.check(), disabled: crmMailingAB.$invalid}"> + <div>{{ts('Submit Mailing')}}</div> + </a> + </center> + </div> + <span crm-ui-wizard-buttons style="float:right;"> + <button + crm-icon="fa-trash" + ng-show="checkPerm('delete in CiviMail')" + ng-disabled="block.check()" + crm-confirm="{title:ts('Delete Draft'), message:ts('Are you sure you want to permanently delete this mailing?')}" + on-yes="delete()">{{ts('Delete Draft')}} + </button> + <button crm-icon="fa-floppy-o" ng-disabled="block.check()" ng-click="save().then(leave) ">{{ts('Save Draft')}}</button> + </span> + </div> + </div> +</div> diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB/EditCtrl/main.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB/EditCtrl/main.html new file mode 100644 index 00000000..15822f6e --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB/EditCtrl/main.html @@ -0,0 +1,10 @@ +<!-- + Implicit Controller: CrmMailingABEditCtrl +--> +<div crm-ui-debug="abtest.ab"></div> +<div crm-ui-debug="abtest.mailings"></div> + +<form name="crmMailingAB" novalidate> + <div ng-include="'~/crmMailingAB/EditCtrl/edit.html'" ng-if="!isSubmitted()"></div> + <div ng-include="'~/crmMailingAB/EditCtrl/report.html'" ng-if="isSubmitted()"></div> +</form> diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB/EditCtrl/report.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB/EditCtrl/report.html new file mode 100644 index 00000000..68b79245 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB/EditCtrl/report.html @@ -0,0 +1,194 @@ +<!-- + Implicit Controller: CrmMailingABEditCtrl +--> +<div class="messages help"> + <div class="msg-title crm-title">{{ts('A/B Test Results')}}: {{abtest.ab.name}}</div> + {{ts('This report displays the current results for your A/B test. You can return to this page to view the latest statistics by navigating to "Manage A/B Tests" and clicking "Results".')}} +</div> +<div ng-controller="CrmMailingABReportCtrl"> + <table class="crm-mailing-ab-table"> + <thead> + <tr ng-show="abtest.ab.status == 'Testing'"> + <td></td> + <td ng-repeat="am in getActiveMailings()"> + <button crm-icon="fa-trophy" ng-click="selectWinner(am.name)">{{ts('Select as Final')}}</button> + </td> + <td></td> + </tr> + </thead> + + <thead> + <tr> + <th>{{ts('Delivery')}}</th> + <th ng-repeat="am in getActiveMailings()" class="crm-mailing-ab-col">{{am.title}}</th> + <th ng-show="abtest.ab.status == 'Testing'">{{ts('Final')}}</th> + </tr> + </thead> + + <tbody> + <tr> + <td>{{ts('Status')}}</td> + <td ng-repeat="am in getActiveMailings()"> + <span ng-repeat="job in am.mailing.jobs" ng-hide="job.is_test == 1 || job.parent_id != null">{{job.status}}</span> + </td> + <td ng-show="abtest.ab.status == 'Testing'">{{ts('Not selected')}}</td> + </tr> + <tr> + <td>{{ts('Scheduled')}}</td> + <td ng-repeat="am in getActiveMailings()"> + <div ng-repeat="job in am.mailing.jobs" ng-hide="job.is_test == 1 || job.parent_id != null">{{job.scheduled_date}}</div> + </td> + <td ng-show="abtest.ab.status == 'Testing'"></td> + </tr> + <tr> + <td>{{ts('Started at')}}</td> + <td ng-repeat="am in getActiveMailings()"> + <div ng-repeat="job in am.mailing.jobs" ng-hide="job.is_test == 1 || job.parent_id != null">{{job.start_date || ts('Not started')}}</div> + </td> + <td ng-show="abtest.ab.status == 'Testing'"></td> + </tr> + <tr> + <td>{{ts('Completed at')}}</td> + <td ng-repeat="am in getActiveMailings()"> + <div ng-repeat="job in am.mailing.jobs" ng-hide="job.is_test == 1 || job.parent_id != null">{{job.end_date || ts('Not completed')}}</div> + </td> + <td ng-show="abtest.ab.status == 'Testing'"></td> + </tr> + </tbody> + + <thead> + <tr> + <th>{{ts('Performance')}}</th> + <th ng-repeat="am in getActiveMailings()" class="crm-mailing-ab-col">{{am.title}}</th> + <th ng-show="abtest.ab.status == 'Testing'">{{ts('Final')}}</th> + </tr> + </thead> + <tbody> + <tr ng-repeat="statType in statTypes"> + <td>{{statType.title}}</td> + <td ng-repeat="am in getActiveMailings()"> + <a + class="crm-hover-button action-item" + ng-href="{{statUrl(am.mailing, statType, 'search')}}" + ng-if="checkPerm('view all contacts') || checkPerm('edit all contacts')" + title="{{ts('Search for contacts using \'%1\'', {1: statType.title})}}" + crm-icon="fa-search" + ></a> + <a + class="crm-hover-button action-item" + ng-href="{{statUrl(am.mailing, statType, 'events')}}" + title="{{ts('Browse events of type \'%1\'', {1: statType.title})}}" + >{{stats[am.name][statType.name] || ts('n/a')}} </a> {{stats[am.name][rateStats[statType.name]] || ' '}} + <a + class="crm-hover-button action-item" + ng-href="{{statUrl(am.mailing, statType, 'report')}}" + title="{{ts('Reports for \'%1\'', {1: statType.title})}}" + crm-icon="clipboard" + ></a> + </td> + <td ng-show="abtest.ab.status == 'Testing'"></td> + </tr> + </tbody> + + <thead> + <tr> + <th>{{ts('Details')}}</th> + <th ng-repeat="am in getActiveMailings()" class="crm-mailing-ab-col">{{am.title}}</th> + <th ng-show="abtest.ab.status == 'Testing'">{{ts('Final')}}</th> + </tr> + </thead> + + <tbody> + <tr> + <td>{{ts('Mailing Name')}}</td> + <td ng-repeat="am in getActiveMailings()"> + {{am.mailing.name}} + </td> + <td ng-show="abtest.ab.status == 'Testing'"></td> + </tr> + <tr> + <td>{{ts('From')}}</td> + <td ng-repeat="am in getActiveMailings()"> + "{{am.mailing.from_name}}" <{{am.mailing.from_email}}> + </td> + <td ng-show="abtest.ab.status == 'Testing'"></td> + </tr> + <tr> + <td>{{ts('Subject')}}</td> + <td ng-repeat="am in getActiveMailings()"> + {{am.mailing.subject}} + </td> + <td ng-show="abtest.ab.status == 'Testing'"></td> + </tr> + <tr ng-controller="ViewRecipCtrl"> + <td>{{ts('Recipients')}}</td> + <td ng-repeat="am in getActiveMailings()"> + <div ng-show="getIncludesAsString(am.mailing)"> + <strong>{{ts('Include:')}}</strong> {{getIncludesAsString(am.mailing)}} + </div> + <div ng-show="getExcludesAsString(am.mailing)"> + <strong>{{ts('Exclude:')}}</strong> <s>{{getExcludesAsString(am.mailing)}}</s> + </div> + </td> + <td ng-show="abtest.ab.status == 'Testing'"></td> + </tr> + <tr> + <td>{{ts('Content')}}</td> + <td ng-repeat="am in getActiveMailings()"> + <a crm-icon="fa-television" class="crm-hover-button action-item" ng-click="previewMailing(am.name,'html')" ng-show="am.mailing.body_html">{{ts('HTML')}}</a> + <a crm-icon="fa-file-text-o" class="crm-hover-button action-item" ng-click="previewMailing(am.name,'text')" ng-show="am.mailing.body_text">{{ts('Text')}}</a> + </td> + <td ng-show="abtest.ab.status == 'Testing'"></td> + </tr> + <tr> + <td>{{ts('Attachments')}}</td> + <td ng-repeat="am in getActiveMailings()"> + <div ng-repeat="file in am.attachments.files"><a ng-href="{{file.url}}" target="_blank">{{file.name}}</a></div> + </td> + <td ng-show="abtest.ab.status == 'Testing'"></td> + </tr> + <tr> + <td>{{ts('Tracking')}}</td> + <td ng-repeat="am in getActiveMailings()"> + <div crm-mailing-review-bool crm-on="am.mailing.url_tracking=='1'" crm-title="ts('Click-Throughs')"></div> + <div crm-mailing-review-bool crm-on="am.mailing.open_tracking=='1'" crm-title="ts('Opens')"></div> + </td> + <td ng-show="abtest.ab.status == 'Testing'"></td> + </tr> + <tr> + <td>{{ts('Responding')}}</td> + <td ng-repeat="am in getActiveMailings()"> + <div crm-mailing-review-bool crm-on="am.mailing.override_verp=='0'" crm-title="ts('Track Replies')"></div> + <div crm-mailing-review-bool crm-on="am.mailing.override_verp=='0' && mailing.forward_replies=='1'" crm-title="ts('Forward Replies')"></div> + <div ng-controller="PreviewComponentCtrl"> + <div ng-show="am.mailing.override_verp == '0' && mailing.auto_responder"><a crm-icon="fa-envelope" class="crm-hover-button action-item" ng-click="previewComponent(ts('Auto-Respond'), am.mailing.reply_id)">{{ts('Auto-Respond')}}</a></div> + <div><a crm-icon="fa-envelope" class="crm-hover-button action-item" ng-click="previewComponent(ts('Opt-out'), am.mailing.optout_id)">{{ts('Opt-out')}}</a></div> + <div><a crm-icon="fa-envelope" class="crm-hover-button action-item" ng-click="previewComponent(ts('Resubscribe'), am.mailing.resubscribe_id)">{{ts('Resubscribe')}}</a></div> + <div><a crm-icon="fa-envelope" class="crm-hover-button action-item" ng-click="previewComponent(ts('Unsubscribe'), am.mailing.unsubscribe_id)">{{ts('Unsubscribe')}}</a></div> + </div> + </td> + <td ng-show="abtest.ab.status == 'Testing'"></td> + </tr> + <tr> + <td>{{ts('Publication')}}</td> + <td ng-repeat="am in getActiveMailings()"> + {{am.mailing.visibility}} + </td> + <td ng-show="abtest.ab.status == 'Testing'"></td> + </tr> + </tbody> + + </table> + + <!-- + <div crm-ui-tab-set> + <div crm-ui-tab id="tab-opens" crm-title="ts('Opens (WIP)')"> + <div crm-mailing-ab-stats="{criteria: 'open', split_count: 5}" crm-abtest="abtest"></div> + </div> + <div crm-ui-tab id="tab-clicks" crm-title="ts('Total Clicks (WIP)')"> + <div crm-mailing-ab-stats="{criteria: 'total unique clicks', split_count: 5}" crm-abtest="abtest"></div> + </div> + </div> + --> + +</div> diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB/ListCtrl.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB/ListCtrl.html new file mode 100644 index 00000000..5d2c0768 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB/ListCtrl.html @@ -0,0 +1,63 @@ +<!-- +Controller: ABListingCtrl +Required vars: mailingABList +--> + +<span crm-ui-order="{var: 'myOrder', defaults: ['-created_date']}"></span> + +<div crm-ui-accordion="{title: ts('Filter'), collapsed: true}"> + <form name="filterForm"> + <span> + <input class="big crm-form-text" ng-model="filter.name" placeholder="{{ts('Name')}}"/> + </span> + <span> + <select crm-ui-select style="width: 10em;" ng-model="filter.status"> + <option value="">{{ts('- Status -')}}</option> + <option ng-repeat="o in fields.status.options" ng-value="o.key">{{o.value}}</option> + </select> + </span> + <span> + <select crm-ui-select style="width: 20em;" ng-model="filter.testing_criteria"> + <option value="">{{ts('- Test Type -')}}</option> + <option ng-repeat="o in fields.testing_criteria.options" ng-value="o.key">{{o.value}}</option> + </select> + </span> + </form> +</div> + +<div ng-show="mailingABList.length"> + <table class="display"> + <thead> + <tr> + <th><a crm-ui-order-by="[myOrder, 'name']">{{ts('Name')}}</a></th> + <th><a crm-ui-order-by="[myOrder, 'status']">{{ts('Status')}}</a></th> + <th><a crm-ui-order-by="[myOrder, 'testing_criteria']">{{ts('Test Type')}}</a></th> + <th><a crm-ui-order-by="[myOrder, 'created_date']">{{ts('Created')}}</a></th> + <th></th> + </tr> + </thead> + <tbody> + <tr ng-repeat="mailingAB in mailingABList | filter:filter | orderBy:myOrder.get()"> + <td>{{mailingAB.name}}</td> + <td>{{crmMailingABStatus.getByName(mailingAB.status).label}}</td> + <td>{{crmMailingABCriteria.get(mailingAB.testing_criteria).label}}</td> + <td>{{mailingAB.created_date}}</td> + <td> + <a class="action-item crm-hover-button" ng-href="#/abtest/{{mailingAB.id}}" ng-show="mailingAB.status == 'Draft'">{{ts('Continue')}}</a> + <a class="action-item crm-hover-button" ng-href="#/abtest/{{mailingAB.id}}" ng-show="mailingAB.status != 'Draft'">{{ts('Results')}}</a> + </td> + </tr> + </tbody> + </table> +</div> + +<div ng-show="mailingABList.length === 0" class="messages status no-popup"> + <i class="crm-i fa-info-circle"></i> + {{ts('You have no A/B mailings')}} +</div> + + +<div class="crm-submit-buttons"> + <br> + <a ng-href="#/abtest/new" class="button"><span><i class="crm-i fa-bar-chart"></i> {{ts('New A/B Test')}}</span></a> +</div> diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB/ListCtrl.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB/ListCtrl.js new file mode 100644 index 00000000..d0ced773 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB/ListCtrl.js @@ -0,0 +1,12 @@ +(function(angular, $, _) { + + angular.module('crmMailingAB').controller('CrmMailingABListCtrl', function($scope, mailingABList, crmMailingABCriteria, crmMailingABStatus, fields) { + var ts = $scope.ts = CRM.ts(null); + $scope.mailingABList = _.values(mailingABList.values); + $scope.crmMailingABCriteria = crmMailingABCriteria; + $scope.crmMailingABStatus = crmMailingABStatus; + $scope.fields = fields; + $scope.filter = {}; + }); + +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB/NewCtrl.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB/NewCtrl.js new file mode 100644 index 00000000..edcfa705 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB/NewCtrl.js @@ -0,0 +1,11 @@ +(function(angular, $, _) { + + angular.module('crmMailingAB').controller('CrmMailingABNewCtrl', function($scope, abtest, $location) { + // Transition URL "/abtest/new/foo" => "/abtest/123/foo" + var parts = $location.path().split('/'); // e.g. "/mailing/new" or "/mailing/123/wizard" + parts[2] = abtest.id; + $location.path(parts.join('/')); + $location.replace(); + }); + +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB/ReportCtrl.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB/ReportCtrl.js new file mode 100644 index 00000000..8755945e --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB/ReportCtrl.js @@ -0,0 +1,56 @@ +(function(angular, $, _) { + + angular.module('crmMailingAB').controller('CrmMailingABReportCtrl', function($scope, crmApi, crmMailingStats) { + var ts = $scope.ts = CRM.ts(null); + + var CrmMailingABReportCnt = 1, activeMailings = null; + $scope.getActiveMailings = function() { + if ($scope.abtest.$CrmMailingABReportCnt != CrmMailingABReportCnt) { + $scope.abtest.$CrmMailingABReportCnt = ++CrmMailingABReportCnt; + activeMailings = [ + { + name: 'a', + title: ts('Mailing A'), + mailing: $scope.abtest.mailings.a, + attachments: $scope.abtest.attachments.a + }, + { + name: 'b', + title: ts('Mailing B'), + mailing: $scope.abtest.mailings.b, + attachments: $scope.abtest.attachments.b + } + ]; + if ($scope.abtest.ab.status == 'Final') { + activeMailings.push({ + name: 'c', + title: ts('Final'), + mailing: $scope.abtest.mailings.c, + attachments: $scope.abtest.attachments.c + }); + } + } + return activeMailings; + }; + + crmMailingStats.getStats({ + a: $scope.abtest.ab.mailing_id_a, + b: $scope.abtest.ab.mailing_id_b, + c: $scope.abtest.ab.mailing_id_c + }).then(function(stats) { + $scope.stats = stats; + }); + $scope.rateStats = { + 'Unique Clicks': 'clickthrough_rate', + 'Delivered': 'delivered_rate', + 'Opened': 'opened_rate', + }; + $scope.statTypes = crmMailingStats.getStatTypes(); + $scope.statUrl = function statUrl(mailing, statType, view) { + return crmMailingStats.getUrl(mailing, statType, view, 'abtest/' + $scope.abtest.ab.id); + }; + + $scope.checkPerm = CRM.checkPerm; + }); + +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB/Slider.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB/Slider.html new file mode 100644 index 00000000..cdfcad3b --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB/Slider.html @@ -0,0 +1,25 @@ +<table class="crm-mailing-ab-slider"> + <tbody> + <tr> + <td style="width: 10em;">{{ts('Test Mailing A')}}</td> + <td> + <div class="slider-test slider-a"></div> + </td> + <td style="width: 5em;">({{testValue}}%)</td> + </tr> + <tr> + <td>{{ts('Test Mailing B')}}</td> + <td> + <div class="slider-test slider-b"></div> + </td> + <td>({{testValue}}%)</td> + </tr> + </tbody> + <tr> + <td>{{ts('Final Mailing')}}</td> + <td> + <div class="slider-win slider-b"></div> + </td> + <td>({{winValue}}%)</td> + </tr> +</table> diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB/Slider.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB/Slider.js new file mode 100644 index 00000000..d26e35b1 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB/Slider.js @@ -0,0 +1,60 @@ +(function(angular, $, _) { + + // example: <div crm-mailing-ab-slider ng-model="abtest.ab.group_percentage"></div> + angular.module('crmMailingAB').directive('crmMailingAbSlider', function() { + return { + require: '?ngModel', + scope: {}, + templateUrl: '~/crmMailingAB/Slider.html', + link: function(scope, element, attrs, ngModel) { + var TEST_MIN = 1, TEST_MAX = 50; + var sliders = $('.slider-test,.slider-win', element); + var sliderTests = $('.slider-test', element); + var sliderWin = $('.slider-win', element); + + scope.ts = CRM.ts(null); + scope.testValue = 0; + scope.winValue = 100; + + // set the base value (following a GUI event) + function setValue(value) { + value = Math.min(TEST_MAX, Math.max(TEST_MIN, value)); + scope.$apply(function() { + ngModel.$setViewValue(value); + scope.testValue = value; + scope.winValue = 100 - (2 * scope.testValue); + sliderTests.slider('value', scope.testValue); + sliderWin.slider('value', scope.winValue); + }); + } + + sliders.slider({ + min: 0, + max: 100, + range: 'min', + step: 1 + }); + sliderTests.slider({ + slide: function slideTest(event, ui) { + event.preventDefault(); + setValue(ui.value); + } + }); + sliderWin.slider({ + slide: function slideWinner(event, ui) { + event.preventDefault(); + setValue(Math.round((100 - ui.value) / 2)); + } + }); + + ngModel.$render = function() { + scope.testValue = ngModel.$viewValue; + scope.winValue = 100 - (2 * scope.testValue); + sliderTests.slider('value', scope.testValue); + sliderWin.slider('value', scope.winValue); + }; + } + }; + }); + +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB/Stats.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB/Stats.js new file mode 100644 index 00000000..da2ebb63 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB/Stats.js @@ -0,0 +1,280 @@ +(function (angular, $, _) { + + + // FIXME: This code is long and hasn't been fully working for me, but I've moved it into a spot + // where it at least fits in a bit better. + + // example: <div crm-mailing-ab-stats="{split_count: 6, criteria:'Open'}" crm-abtest="myabtest" /> + // options (see also: Mailing.graph_stats API) + // - split_count: int + // - criteria: string + // - target_date: string, date + // - target_url: string + angular.module('crmMailingAB').directive('crmMailingAbStats', function (crmApi, $parse) { + return { + scope: { + crmMailingAbStats: '@', + crmAbtest: '@' + }, + template: '<div class="crm-mailing-ab-stats"></div>', + link: function (scope, element, attrs) { + var abtestModel = $parse(attrs.crmAbtest); + var optionModel = $parse(attrs.crmMailingAbStats); + var options = angular.extend({}, optionModel(scope.$parent), { + criteria: 'Open', // e.g. 'Open', 'Total Unique Clicks' + split_count: 5 + }); + + scope.$watch(attrs.crmAbtest, refresh); + function refresh() { + var abtest = abtestModel(scope.$parent); + if (!abtest) { + console.log('failed to draw stats - missing abtest'); + return; + } + + scope.graph_data = [ + {}, + {}, + {}, + {}, + {} + ]; + var keep_cnt = 0; + + for (var i = 1; i <= options.split_count; i++) { + var result = crmApi('MailingAB', 'graph_stats', { + id: abtest.ab.id, + target_date: abtest.ab.declare_winning_time ? abtest.ab.declare_winning_time : 'now', + target_url: null, // FIXME + criteria: options.criteria, + split_count: options.split_count, + split_count_select: i + }); + /*jshint -W083 */ + result.then(function (data) { + var temp = 0; + keep_cnt++; + for (var key in data.values.A) { + temp = key; + } + var t = data.values.A[temp].time.split(" "); + var m = t[0]; + var year = t[2]; + var day = t[1].substr(0, t[1].length - 3); + var t1, hur, hour, min; + if (_.isEmpty(t[3])) { + t1 = t[4].split(":"); + hur = t1[0]; + if (t[5] == "AM") { + hour = hur; + if (hour == 12) { + hour = 0; + } + } + if (t[5] == "PM") { + hour = parseInt(hur) + 12; + } + min = t1[1]; + } + else { + t1 = t[3].split(":"); + hur = t1[0]; + if (t[4] == "AM") { + hour = hur; + if (hour == 12) { + hour = 0; + } + } + if (t[4] == "PM") { + hour = parseInt(hur) + 12; + } + min = t1[1]; + } + var month = 0; + switch (m) { + case "January": + month = 0; + break; + case "February": + month = 1; + break; + case "March": + month = 2; + break; + case "April": + month = 3; + break; + case "May": + month = 4; + break; + case "June": + month = 5; + break; + case "July": + month = 6; + break; + case "August": + month = 7; + break; + case "September": + month = 8; + break; + case "October": + month = 9; + break; + case "November": + month = 10; + break; + case "December": + month = 11; + break; + + } + var tp = new Date(year, month, day, hour, min, 0, 0); + scope.graph_data[temp - 1] = { + time: tp, + x: data.values.A[temp].count, + y: data.values.B[temp].count + }; + + if (keep_cnt == options.split_count) { + scope.graphload = true; + data = scope.graph_data; + + // set up a colour variable + var color = d3.scale.category10(); + + // map one colour each to x, y and z + // keys grabs the key value or heading of each key value pair in the json + // but not time + color.domain(d3.keys(data[0]).filter(function (key) { + return key !== "time"; + })); + + // create a nested series for passing to the line generator + // it's best understood by console logging the data + var series = color.domain().map(function (name) { + return { + name: name, + values: data.map(function (d) { + return { + time: d.time, + score: +d[name] + }; + }) + }; + }); + + // Set the dimensions of the canvas / graph + var margin = { + top: 30, + right: 20, + bottom: 40, + left: 75 + }, + width = 550 - margin.left - margin.right, + height = 350 - margin.top - margin.bottom; + + // Set the ranges + //var x = d3.time.scale().range([0, width]).domain([0,10]); + var x = d3.time.scale().range([0, width]); + var y = d3.scale.linear().range([height, 0]); + + // Define the axes + var xAxis = d3.svg.axis().scale(x) + .orient("bottom").ticks(10); + + var yAxis = d3.svg.axis().scale(y) + .orient("left").ticks(5); + + // Define the line + // Note you plot the time / score pair from each key you created earlier + var valueline = d3.svg.line() + .x(function (d) { + return x(d.time); + }) + .y(function (d) { + return y(d.score); + }); + + // Adds the svg canvas + var svg = d3.select($('.crm-mailing-ab-stats', element)[0]) + .append("svg") + .attr("width", width + margin.left + margin.right) + .attr("height", height + margin.top + margin.bottom) + .append("g") + .attr("transform", "translate(" + margin.left + "," + margin.top + ")"); + + // Scale the range of the data + x.domain(d3.extent(data, function (d) { + return d.time; + })); + + // note the nested nature of this you need to dig an additional level + y.domain([ + d3.min(series, function (c) { + return d3.min(c.values, function (v) { + return v.score; + }); + }), + d3.max(series, function (c) { + return d3.max(c.values, function (v) { + return v.score; + }); + }) + ]); + svg.append("text") // text label for the x axis + .attr("x", width / 2) + .attr("y", height + margin.bottom) + .style("text-anchor", "middle") + .text("Time"); + + svg.append("text") // text label for the x axis + .style("text-anchor", "middle") + .text(scope.winnercriteria).attr("transform",function (d) { + return "rotate(-90)"; + }).attr("x", -height / 2) + .attr("y", -30); + + // create a variable called series and bind the date + // for each series append a g element and class it as series for css styling + series = svg.selectAll(".series") + .data(series) + .enter().append("g") + .attr("class", "series"); + + // create the path for each series in the variable series i.e. x, y and z + // pass each object called x, y nad z to the lne generator + series.append("path") + .attr("class", "line") + .attr("d", function (d) { + // console.log(d); // to see how d3 iterates through series + return valueline(d.values); + }) + .style("stroke", function (d) { + return color(d.name); + }); + + // Add the X Axis + svg.append("g") // Add the X Axis + .attr("class", "x axis") + .attr("transform", "translate(0," + height + ")") + .call(xAxis) + .selectAll("text") + .attr("transform", function (d) { + return "rotate(-30)"; + }); + + // Add the Y Axis + svg.append("g") // Add the Y Axis + .attr("class", "y axis") + .call(yAxis); + } + }); + } + } + } // link() + }; + }); +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB/WinnerDialogCtrl.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB/WinnerDialogCtrl.html new file mode 100644 index 00000000..0c2db4f6 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB/WinnerDialogCtrl.html @@ -0,0 +1,19 @@ +<div ng-controller="CrmMailingABWinnerDialogCtrl"> + <form novalidate name="winnerForm"> + <div class="help"> + {{ts('After selecting %1 as the winner, one must schedule the delivery for the final mailing.', {1: mailingTitle})}} + </div> + + <div crm-mailing-radio-date="schedule" ng-model="abtest.mailings.c.scheduled_date"> + <div> + <input ng-model="schedule.mode" type="radio" name="send" value="now" id="schedule-send-now"/> + <label for="schedule-send-now">{{ts('Send final mailing immediately')}}</label> + </div> + <div> + <input ng-model="schedule.mode" type="radio" name="send" value="at" id="schedule-send-at"/> + <label for="schedule-send-at">{{ts('Send final mailing at:')}}</label> + <input crm-ui-datepicker ng-model="schedule.datetime"/> + </div> + </div> + </form> +</div> diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB/WinnerDialogCtrl.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB/WinnerDialogCtrl.js new file mode 100644 index 00000000..f378f641 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB/WinnerDialogCtrl.js @@ -0,0 +1,43 @@ +(function(angular, $, _) { + + angular.module('crmMailingAB').controller('CrmMailingABWinnerDialogCtrl', function($scope, $timeout, dialogService, crmMailingMgr, crmStatus) { + var ts = $scope.ts = CRM.ts(null); + var abtest = $scope.abtest = $scope.model.abtest; + var mailingName = $scope.model.mailingName; + + var titles = {a: ts('Mailing A'), b: ts('Mailing B')}; + $scope.mailingTitle = titles[mailingName]; + + function init() { + // When using dialogService with a button bar, the major button actions + // need to be registered with the dialog widget (and not embedded in + // the body of the dialog). + var buttons = [ + { + text: ts('Submit final mailing'), + icons: {primary: 'fa-paper-plane'}, + click: function() { + crmStatus({start: ts('Submitting...'), success: ts('Submitted')}, + abtest.submitFinal(abtest.mailings[mailingName].id).then(function (r) { + delete abtest.$CrmMailingABReportCnt; + })) + .then(function () { + dialogService.close('selectWinnerDialog', abtest); + }); + } + }, + { + text: ts('Cancel'), + icons: {primary: 'fa-times'}, + click: function() { + dialogService.cancel('selectWinnerDialog'); + } + } + ]; + dialogService.setButtons('selectWinnerDialog', buttons); + } + + $timeout(init); + }); + +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB/services.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB/services.js new file mode 100644 index 00000000..2e9fa926 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmMailingAB/services.js @@ -0,0 +1,234 @@ +(function (angular, $, _) { + + function OptionGroup(values) { + this.get = function get(value) { + var r = _.where(values, {value: '' + value}); + return r.length > 0 ? r[0] : null; + }; + this.getByName = function get(name) { + var r = _.where(values, {name: '' + name}); + return r.length > 0 ? r[0] : null; + }; + this.getAll = function getAll() { + return values; + }; + } + + angular.module('crmMailingAB').factory('crmMailingABCriteria', function () { + // TODO Get data from server + var values = { + '1': {value: 'subject', name: 'subject', label: ts('Test different "Subject" lines')}, + '2': {value: 'from', name: 'from', label: ts('Test different "From" lines')}, + '3': {value: 'full_email', name: 'full_email', label: ts('Test entirely different emails')} + }; + return new OptionGroup(values); + }); + + angular.module('crmMailingAB').factory('crmMailingABStatus', function () { + // TODO Get data from server + var values = { + '1': {value: '1', name: 'Draft', label: ts('Draft')}, + '2': {value: '2', name: 'Testing', label: ts('Testing')}, + '3': {value: '3', name: 'Final', label: ts('Final')} + }; + return new OptionGroup(values); + }); + + // CrmMailingAB is a data-model which combines an AB test (APIv3 "MailingAB"), three mailings (APIv3 "Mailing"), + // and three sets of attachments (APIv3 "Attachment"). + // + // example: + // var abtest = new CrmMailingAB(123); + // abtest.load().then(function(){ + // alert("Mailing A is named "+abtest.mailings.a.name); + // }); + angular.module('crmMailingAB').factory('CrmMailingAB', function (crmApi, crmMailingMgr, $q, CrmAttachments) { + function CrmMailingAB(id) { + this.id = id; + this.mailings = {}; + this.attachments = {}; + } + + angular.extend(CrmMailingAB.prototype, { + getAutosaveSignature: function() { + return [ + this.ab, + this.mailings, + this.attachments.a.getAutosaveSignature(), + this.attachments.b.getAutosaveSignature(), + this.attachments.c.getAutosaveSignature() + ]; + }, + // @return Promise CrmMailingAB + load: function load() { + var crmMailingAB = this; + if (!crmMailingAB.id) { + crmMailingAB.ab = { + name: '', + status: 'Draft', + mailing_id_a: null, + mailing_id_b: null, + mailing_id_c: null, + domain_id: null, + testing_criteria: 'subject', + winner_criteria: null, + specific_url: '', + declare_winning_time: null, + group_percentage: 10 + }; + var mailingDefaults = { + // Most defaults provided by Mailing.create API, but we + // want to force-enable tracking. + open_tracking: "1", + url_tracking: "1", + mailing_type:"experiment" + }; + crmMailingAB.mailings.a = crmMailingMgr.create(mailingDefaults); + crmMailingAB.mailings.b = crmMailingMgr.create(mailingDefaults); + mailingDefaults.mailing_type = 'winner'; + crmMailingAB.mailings.c = crmMailingMgr.create(mailingDefaults); + crmMailingAB.attachments.a = new CrmAttachments(function () { + return {entity_table: 'civicrm_mailing', entity_id: crmMailingAB.ab.mailing_id_a}; + }); + crmMailingAB.attachments.b = new CrmAttachments(function () { + return {entity_table: 'civicrm_mailing', entity_id: crmMailingAB.ab.mailing_id_b}; + }); + crmMailingAB.attachments.c = new CrmAttachments(function () { + return {entity_table: 'civicrm_mailing', entity_id: crmMailingAB.ab.mailing_id_c}; + }); + + var dfr = $q.defer(); + dfr.resolve(crmMailingAB); + return dfr.promise; + } + else { + return crmApi('MailingAB', 'get', {id: crmMailingAB.id}) + .then(function (abResult) { + if (abResult.count != 1) { + throw "Failed to load AB Test"; + } + crmMailingAB.ab = abResult.values[abResult.id]; + return crmMailingAB._loadMailings(); + }); + } + }, + // @return Promise CrmMailingAB + save: function save() { + var crmMailingAB = this; + return crmMailingAB._saveMailings() + .then(function () { + return crmApi('MailingAB', 'create', crmMailingAB.ab) + .then(function (abResult) { + if (!crmMailingAB.id) { + crmMailingAB.id = crmMailingAB.ab.id = abResult.id; + } + }); + }) + .then(function () { + return crmMailingAB; + }); + }, + // Schedule the test + // @return Promise CrmMailingAB + // Note: Submission may cause the server state to change. Consider abtest.submit().then(...abtest.load()...) + submitTest: function submitTest() { + var crmMailingAB = this; + var params = { + id: this.ab.id, + status: 'Testing', + approval_date: 'now', + scheduled_date: this.mailings.a.scheduled_date ? this.mailings.a.scheduled_date : 'now' + }; + return crmApi('MailingAB', 'submit', params) + .then(function () { + return crmMailingAB.load(); + }); + }, + // Schedule the final mailing + // @return Promise CrmMailingAB + // Note: Submission may cause the server state to change. Consider abtest.submit().then(...abtest.load()...) + submitFinal: function submitFinal(winner_id) { + var crmMailingAB = this; + var params = { + id: this.ab.id, + status: 'Final', + winner_id: winner_id, + approval_date: 'now', + scheduled_date: this.mailings.c.scheduled_date ? this.mailings.c.scheduled_date : 'now' + }; + return crmApi('MailingAB', 'submit', params) + .then(function () { + return crmMailingAB.load(); + }); + }, + // @param mailing Object (per APIv3) + // @return Promise + 'delete': function () { + if (this.id) { + return crmApi('MailingAB', 'delete', {id: this.id}); + } + else { + var d = $q.defer(); + d.resolve(); + return d.promise; + } + }, + // Load mailings A, B, and C (if available) + // @return Promise CrmMailingAB + _loadMailings: function _loadMailings() { + var crmMailingAB = this; + var todos = {}; + _.each(['a', 'b', 'c'], function (mkey) { + if (crmMailingAB.ab['mailing_id_' + mkey]) { + todos[mkey] = crmMailingMgr.get(crmMailingAB.ab['mailing_id_' + mkey]) + .then(function (mailing) { + crmMailingAB.mailings[mkey] = mailing; + crmMailingAB.attachments[mkey] = new CrmAttachments(function () { + return {entity_table: 'civicrm_mailing', entity_id: crmMailingAB.ab['mailing_id_' + mkey]}; + }); + return crmMailingAB.attachments[mkey].load(); + }); + } + else { + crmMailingAB.mailings[mkey] = crmMailingMgr.create(); + crmMailingAB.attachments[mkey] = new CrmAttachments(function () { + return {entity_table: 'civicrm_mailing', entity_id: crmMailingAB.ab['mailing_id_' + mkey]}; + }); + } + }); + return $q.all(todos).then(function () { + return crmMailingAB; + }); + }, + // Save mailings A, B, and C (if available) + // @return Promise CrmMailingAB + _saveMailings: function _saveMailings() { + var crmMailingAB = this; + var todos = {}; + var p = $q.when(true); + _.each(['a', 'b', 'c'], function (mkey) { + if (!crmMailingAB.mailings[mkey]) { + return; + } + if (crmMailingAB.ab['mailing_id_' + mkey]) { + // paranoia: in case caller forgot to manage id on mailing + crmMailingAB.mailings[mkey].id = crmMailingAB.ab['mailing_id_' + mkey]; + } + p = p.then(function(){ + return crmMailingMgr.save(crmMailingAB.mailings[mkey]) + .then(function () { + crmMailingAB.ab['mailing_id_' + mkey] = crmMailingAB.mailings[mkey].id; + return crmMailingAB.attachments[mkey].save(); + }); + }); + }); + return p.then(function () { + return crmMailingAB; + }); + } + + }); + return CrmMailingAB; + }); + +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmResource.ang.php b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmResource.ang.php new file mode 100644 index 00000000..577f48e1 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmResource.ang.php @@ -0,0 +1,11 @@ +<?php +// This file declares an Angular module which can be autoloaded +// in CiviCRM. See also: +// http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_angularModules + +return [ + 'ext' => 'civicrm', + // 'js' => array('js/angular-crmResource/byModule.js'), // One HTTP request per module. + // One HTTP request for all modules. + 'js' => ['js/angular-crmResource/all.js'], +]; diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmRouteBinder.ang.php b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmRouteBinder.ang.php new file mode 100644 index 00000000..0da8aa36 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmRouteBinder.ang.php @@ -0,0 +1,12 @@ +<?php +// This file declares an Angular module which can be autoloaded +// in CiviCRM. See also: +// http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_angularModules + +return [ + 'ext' => 'civicrm', + 'js' => ['ang/crmRouteBinder.js'], + 'css' => [], + 'partials' => [], + 'requires' => ['ngRoute'], +]; diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmRouteBinder.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmRouteBinder.js new file mode 100644 index 00000000..a8563fd0 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmRouteBinder.js @@ -0,0 +1,111 @@ +(function(angular, $, _) { + angular.module('crmRouteBinder', CRM.angRequires('crmRouteBinder')); + + // While processing a change from the $watch()'d data, we set the "pendingUpdates" flag + // so that automated URL changes don't cause a reload. + var pendingUpdates = null, activeTimer = null, registered = false, ignorable = {}; + + function registerGlobalListener($injector) { + if (registered) return; + registered = true; + + $injector.get('$rootScope').$on('$routeUpdate', function () { + // Only reload if someone else -- like the user or an <a href> -- changed URL. + if (null === pendingUpdates) { + $injector.get('$route').reload(); + } + }); + } + + var formats = { + json: { + watcher: '$watchCollection', + decode: angular.fromJson, + encode: angular.toJson, + default: {} + }, + raw: { + watcher: '$watch', + decode: function(v) { return v; }, + encode: function(v) { return v; }, + default: '' + }, + int: { + watcher: '$watch', + decode: function(v) { return parseInt(v); }, + encode: function(v) { return v; }, + default: 0 + }, + bool: { + watcher: '$watch', + decode: function(v) { return v === '1'; }, + encode: function(v) { return v ? '1' : '0'; }, + default: false + } + }; + + angular.module('crmRouteBinder').config(function ($provide) { + $provide.decorator('$rootScope', function ($delegate, $injector, $parse) { + Object.getPrototypeOf($delegate).$bindToRoute = function (options) { + registerGlobalListener($injector); + + options.format = options.format || 'json'; + var fmt = _.clone(formats[options.format]); + if (options.deep) { + fmt.watcher = '$watch'; + } + if (options.default === undefined) { + options.default = fmt.default; + } + var value, + _scope = this, + $route = $injector.get('$route'), + $timeout = $injector.get('$timeout'); + + if (options.param in $route.current.params) { + value = fmt.decode($route.current.params[options.param]); + } + else { + value = _.cloneDeep(options.default); + ignorable[options.param] = fmt.encode(options.default); + } + $parse(options.expr).assign(_scope, value); + + // Keep the URL bar up-to-date. + _scope[fmt.watcher](options.expr, function (newValue) { + var encValue = fmt.encode(newValue); + if (!_.isEqual(newValue, options.default) && $route.current.params[options.param] === encValue) { + return; + } + + pendingUpdates = pendingUpdates || {}; + pendingUpdates[options.param] = encValue; + var p = angular.extend({}, $route.current.params, pendingUpdates); + + angular.forEach(ignorable, function(v, k) { + if (p[k] === v) { + delete p[k]; + } + }); + + // Remove params from url if they equal their defaults + if (_.isEqual(newValue, options.default)) { + p[options.param] = null; + } + + $route.updateParams(p); + + if (activeTimer) $timeout.cancel(activeTimer); + activeTimer = $timeout(function () { + pendingUpdates = null; + activeTimer = null; + ignorable = {}; + }, 50); + }, options.deep); + }; + + return $delegate; + }); + }); + +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmRouteBinder.md b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmRouteBinder.md new file mode 100644 index 00000000..a297bdc4 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmRouteBinder.md @@ -0,0 +1,106 @@ +# crmRouteBinder + +Live-update the URL to stay in sync with controller data. + +## Example + +```js +angular.module('sandbox').config(function($routeProvider) { + $routeProvider.when('/example-route', { + reloadOnSearch: false, + template: '<input ng-model="filters.foo" />', + controller: function($scope) { + $scope.$bindToRoute({ + param: 'f', + expr: 'filters', + default: {foo: 'default-value'} + }); + } + }); +}); +``` + +Things to try out: + + * Navigate to `#/example-route`. Observe that the URL automatically + updates to `#/example-route?f={"foo":"default-value"}`. + * Edit the content in the `<input>` field. Observe that the URL changes. + * Initiate a change in the browser -- by editing the URL bar or pressing + the "Back" button. The page should refresh. + +## Functions + +**`$scope.$bindToRoute(options)`** +*The `options` object should contain keys:* + + * `expr` (string): The name of a scoped variable to sync. + * `param` (string): The name of a query-parameter to sync. (If the `param` is included in the URL, it will initialize the expr.) + * `format` (string): The type of data to put in `param`. May be one of: + * `json` (default): The `param` is JSON, and the `expr` is a decoded object. + * `raw`: The `param` is string, and the `expr` is a string. + * `int`: the `param` is an integer-like string, and the expr is an integer. + * `bool`: The `param` is '0'/'1', and the `expr` is false/true. + * `default` (object): The default data. (If the `param` is not included in the URL, it will initialize the expr.) + * `deep` (boolean): By default the json format will be watched using a shallow comparison. For nested objects and arrays enable this option. + +## Suggested Usage + +`$bindToRoute()` was written for a complicated routing scenario with +multiple parameters, e.g. `caseFilters:Object`, `caseId:Int`, `tab:String`, +`activityFilters:Object`, `activityId:Int`. If you're use-case is one or +two scalar values, then stick to vanilla `ngRoute`. This is only for +complicated scenarios. + +If you are using `$bindToRoute()`, should you split up parameters -- with +some using `ngRoute` and some using `$bindToRoute()`? I'd pick one style +and stick to it. You're in a complex use-case where `$bindToRoute()` makes +sense, then you already need to put thought into the different +flows/input-combinations. Having two technical styles will increase the +mental load. + +A goal of `bindToRoute()` is to accept inputs interchangably from the URL or +HTML fields. Using `ngRoute`'s `resolve:` option only addresses the URL +half. If you want one piece of code handling all inputs the same way, you +should avoid `resolve:` and instead write a controller focused on +orchestrating I/O: + +```js +angular.module('sandbox').config(function($routeProvider) { + $routeProvider.when('/example-route', { + reloadOnSearch: false, + template: + '<div filter-toolbar-a="filterSetA" />' + + '<div filter-toolbar-b="filterSetB" />' + + '<div filter-toolbar-c="filterSetC" />' + + '<div data-set-a="dataSetA" />' + + '<div data-set-b="dataSetB" />' + + '<div data-set-c="dataSetC" />', + controller: function($scope) { + $scope.$bindToRoute({expr:'filterSetA', param:'a', default:{}}); + $scope.$watchCollection('filterSetA', function(){ + crmApi(...).then(function(...){ + $scope.dataSetA = ...; + }); + }); + + $scope.$bindToRoute({expr:'filterSetB', param:'b', default:{}}); + $scope.$watchCollection('filterSetB', function(){ + crmApi(...).then(function(...){ + $scope.dataSetB = ...; + }); + }); + + $scope.$bindToRoute({expr:'filterSetC', param:'c', default:{}}); + $scope.$watchCollection('filterSetC', function(){ + crmApi(...).then(function(...){ + $scope.dataSetC = ...; + }); + }); + } + }); +}); +``` + +(This example is a little more symmetric than a real one -- because the A, +B, and C datasets look independent. In practice, their loading may be +intermingled.) diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmStatusPage.ang.php b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmStatusPage.ang.php new file mode 100644 index 00000000..c72127f3 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmStatusPage.ang.php @@ -0,0 +1,15 @@ +<?php +// This file declares an Angular module which can be autoloaded +// in CiviCRM. See also: +// http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_angularModules + +// ODDITY: Angular name 'statuspage' doesn't match the file name 'crmStatusPage'. + +return [ + 'ext' => 'civicrm', + 'js' => ['ang/crmStatusPage.js', 'ang/crmStatusPage/*.js'], + 'css' => ['ang/crmStatusPage.css'], + 'partials' => ['ang/crmStatusPage'], + 'settings' => [], + 'requires' => ['crmUi', 'crmUtil', 'ngRoute', 'crmResource'], +]; diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmStatusPage.css b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmStatusPage.css new file mode 100644 index 00000000..7bc17224 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmStatusPage.css @@ -0,0 +1,82 @@ +/* CSS rules for Angular module "statuspage" */ + +#crm-status-list h3 { + color: white; + opacity: .85; +} + +#crm-status-list h3:hover, +#crm-status-list h3.menuopen { + opacity: 1; +} + +/* Error Severity */ +#crm-status-list .crm-severity-emergency, +#crm-status-list .crm-severity-alert, +#crm-status-list .crm-severity-critical, +#crm-status-list .crm-severity-error{ + background-color: #E43D2B; +} + +/* Warning Severity */ +#crm-status-list .crm-severity-warning { + background-color: #eba12d; +} + +/* Not Okay - Not Warning */ +#crm-status-list .crm-severity-notice { + background-color: #4d90eb; +} + +/* All OK Severity */ +#crm-status-list .crm-severity-info, +#crm-status-list .crm-severity-debug { + background-color: #00994D; +} + +#crm-status-list .crm-status-message-body { + margin: 1em 0; +} + +#crm-status-list .hidden-until { + font-weight: normal; + font-size: .8em; + margin-right: 1em; +} + +#tab-status-visible-1 .hush-menu > div { + width: 20em; /* determines max-width of popup menu */ +} + +#crm-status-list .hush-menu > div { + position: relative; + font-size: .8em; + top: -2px; +} + +#crm-status-list .hush-menu button { + float: right; + cursor: pointer; + line-height: 1em; +} + +#crm-status-list .hush-menu ul { + position: absolute; + top: 1.5em; + right: 0; + width: auto; + margin: 0; + padding: 0; + z-index: 99; +} + +#crm-status-list .hush-menu li { + padding: 0.2em 0.5em; + background-color: rgba(255, 255, 255, 0.9); + z-index: 99; + font-weight: normal; +} + +.status-debug-information { + font-size: smaller; +} diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmStatusPage.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmStatusPage.js new file mode 100644 index 00000000..b0db81a3 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmStatusPage.js @@ -0,0 +1,19 @@ +(function(angular, $, _) { + angular.module('statuspage', CRM.angRequires('statuspage')); + + // router + angular.module('statuspage').config( function($routeProvider) { + $routeProvider.when('/status', { + controller: 'statuspageStatusPage', + templateUrl: '~/statuspage/StatusPage.html', + + resolve: { + statusData: function(crmApi) { + return crmApi('System', 'check', {sequential: 1}); + } + } + }); + + } +); +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmStatusPage/SnoozeOptions.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmStatusPage/SnoozeOptions.html new file mode 100644 index 00000000..b21cb7b9 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmStatusPage/SnoozeOptions.html @@ -0,0 +1,11 @@ +<div ng-if="!status.is_visible"> + <button ng-click="setPref(status, '', 1)" type="button" >{{ts('Unhide')}}</button> +</div> +<div ng-if="status.is_visible && status.severity_id >= 2"> + <button type="button" class="hush-menu-button">{{ts('Hide')}}</button> + <ul style="display:none;"> + <li ng-click="setPref(status, 'now + 1 week', 0)">{{ts('Remind me again in a week')}}</li> + <li ng-click="setPref(status, 'now + 1 month', 0)">{{ts('Remind me again in a month')}}</li> + <li ng-click="setPref(status, '', 0)">{{ts('Never remind me again')}}</li> + </ul> +</div> diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmStatusPage/StatusPage.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmStatusPage/StatusPage.html new file mode 100644 index 00000000..71a9c6aa --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmStatusPage/StatusPage.html @@ -0,0 +1,39 @@ +<div crm-ui-debug="statuses"></div> + +<h1 crm-page-title crm-document-title="ts('CiviCRM System Status') + ' (' + countVisible(1) + ')'"> + {{ts('CiviCRM System Status')}} +</h1> + +<div id="crm-status-list" crm-ui-tab-set> + <div crm-ui-tab + ng-repeat="tab in [{is_visible: 1, icon: 'fa-bell'}, {is_visible: 0, icon: 'fa-bell-slash-o'}]" + id="tab-status-visible-{{tab.is_visible}}" + count="{{countVisible(tab.is_visible)}}" + crm-title="tab.is_visible ? ts('Active') : ts('Hidden')" + crm-icon="{{tab.icon}}" + > + <div class="crm-status-item" ng-repeat="status in statuses | filter:{is_visible: tab.is_visible}" > + <h3 class="crm-severity-{{status.severity}}"> + <i ng-if="status.icon" class="crm-i {{status.icon}}"></i> + {{status.title}} + <div statuspage-popup-menu class="hush-menu css_right"></div> + <div ng-if="!status.is_visible" class="hidden-until css_right"> + ({{status.hidden_until ? ts('Hidden until %1', {1: formatDate(status.hidden_until)}) : ts('Hidden permanently')}}) + </div> + </h3> + <div class="crm-block crm-status-message-body"> + <span ng-bind-html="status.message | trusted"></span> + <a + ng-if="status.help" + class="helpicon" + ng-click="help(status.title, status.help);" + href="javascript:void(0)" + > + </a> + <div ng-if="status.actions" class="crm-status-item-actions"> + <button ng-repeat="action in status.actions" ng-click="doAction(action)">{{ action.title }}</button> + </div> + </div> + </div> + </div> +</div> diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmStatusPage/StatusPageCtrl.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmStatusPage/StatusPageCtrl.js new file mode 100644 index 00000000..4abca2ab --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmStatusPage/StatusPageCtrl.js @@ -0,0 +1,74 @@ +(function(angular, $, _) { + + angular.module('statuspage').controller('statuspageStatusPage', + function($scope, crmApi, crmStatus, statusData) { + $scope.ts = CRM.ts(); + $scope.help = CRM.help; + $scope.formatDate = CRM.utils.formatDate; + $scope.statuses = statusData.values; + + // Refresh the list. Optionally execute api calls first. + function refresh(apiCalls, title) { + title = title || 'Untitled operation'; + apiCalls = (apiCalls || []).concat([['System', 'check', {sequential: 1}]]); + $('#crm-status-list').block(); + crmApi(apiCalls, true) + .then(function(results) { + $scope.statuses = results[results.length - 1].values; + results.forEach(function(result) { + if (result.is_error) { + var error_message = ts(result.error_message); + if (typeof(result.debug_information) !== 'undefined') { + error_message += '<div class="status-debug-information">' + + '<b>' + ts('Debug information') + ':</b><br>' + + result.debug_information + '</div>'; + } + CRM.alert(error_message, ts('Operation failed: ' + title), 'error'); + } + }); + $('#crm-status-list').unblock(); + }); + } + + // updates a status preference and refreshes status data + $scope.setPref = function(status, until, visible) { + refresh([ + ['StatusPreference', 'create', { + name: status.name, + ignore_severity: visible ? 0 : status.severity, + hush_until: until + }] + ], 'Set preference'); + }; + + $scope.countVisible = function(visibility) { + return _.filter($scope.statuses, function(s) { + return s.is_visible == visibility && s.severity_id >= 2; + }).length; + }; + + $scope.doAction = function(action) { + function run() { + switch (action.type) { + case 'href': + window.location = CRM.url(action.params.path, action.params.query, action.params.mode); + break; + + case 'api3': + refresh([action.params], action.title); + break; + } + } + + if (action.confirm) { + CRM.confirm({ + title: action.title, + message: action.confirm + }).on('crmConfirm:yes', run); + } else { + run(); + } + }; + }); + +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmStatusPage/StatusPageServices.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmStatusPage/StatusPageServices.js new file mode 100644 index 00000000..a37ceafb --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmStatusPage/StatusPageServices.js @@ -0,0 +1,31 @@ +(function(angular, $, _) { + + angular.module('statuspage') + .filter('trusted', function($sce){ return $sce.trustAsHtml; }) + + // Todo: abstract this into a generic crmUi directive? + .directive('statuspagePopupMenu', function($timeout) { + return { + templateUrl: '~/statuspage/SnoozeOptions.html', + transclude: true, + + link: function(scope, element, attr) { + element.on('click', '.hush-menu-button', function() { + $timeout(function() { + $('ul', element).show().menu(); + element.closest('h3').addClass('menuopen'); + $('body').one('click', function() { + $('ul', element).menu('destroy').hide(); + element.closest('h3').removeClass('menuopen'); + }); + }); + }); + // TODO: Is there a more "Angular" way to do this animation? + element.on('click', 'button:not(.hush-menu-button), li', function() { + $(this).closest('div.crm-status-item').slideUp(); + }); + } + }; + }); + +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmUi.ang.php b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmUi.ang.php new file mode 100644 index 00000000..72c6594a --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmUi.ang.php @@ -0,0 +1,14 @@ +<?php +// This file declares an Angular module which can be autoloaded +// in CiviCRM. See also: +// http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_angularModules + +return [ + 'ext' => 'civicrm', + 'js' => ['ang/crmUi.js'], + 'partials' => ['ang/crmUi'], + 'requires' => [ + 'crmResource', + 'ui.utils', + ], +]; diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmUi.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmUi.js new file mode 100644 index 00000000..0b09c60d --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmUi.js @@ -0,0 +1,1092 @@ +/// crmUi: Sundry UI helpers +(function (angular, $, _) { + + var uidCount = 0, + pageTitle = 'CiviCRM', + documentTitle = 'CiviCRM'; + + angular.module('crmUi', CRM.angRequires('crmUi')) + + // example <div crm-ui-accordion crm-title="ts('My Title')" crm-collapsed="true">...content...</div> + // WISHLIST: crmCollapsed should support two-way/continuous binding + .directive('crmUiAccordion', function() { + return { + scope: { + crmUiAccordion: '=' + }, + template: '<div ng-class="cssClasses"><div class="crm-accordion-header">{{crmUiAccordion.title}} <a crm-ui-help="help" ng-if="help"></a></div><div class="crm-accordion-body" ng-transclude></div></div>', + transclude: true, + link: function (scope, element, attrs) { + scope.cssClasses = { + 'crm-accordion-wrapper': true, + collapsed: scope.crmUiAccordion.collapsed + }; + scope.help = null; + scope.$watch('crmUiAccordion', function(crmUiAccordion) { + if (crmUiAccordion && crmUiAccordion.help) { + scope.help = crmUiAccordion.help.clone({}, { + title: crmUiAccordion.title + }); + } + }); + } + }; + }) + + // Examples: + // crmUiAlert({text: 'My text', title: 'My title', type: 'error'}); + // crmUiAlert({template: '<a ng-click="ok()">Hello</a>', scope: $scope.$new()}); + // var h = crmUiAlert({templateUrl: '~/crmFoo/alert.html', scope: $scope.$new()}); + // ... h.close(); ... + .service('crmUiAlert', function($compile, $rootScope, $templateRequest, $q) { + var count = 0; + return function crmUiAlert(params) { + var id = 'crmUiAlert_' + (++count); + var tpl = null; + if (params.templateUrl) { + tpl = $templateRequest(params.templateUrl); + } + else if (params.template) { + tpl = params.template; + } + if (tpl) { + params.text = '<div id="' + id + '"></div>'; // temporary stub + } + var result = CRM.alert(params.text, params.title, params.type, params.options); + if (tpl) { + $q.when(tpl, function(html) { + var scope = params.scope || $rootScope.$new(); + var linker = $compile(html); + $('#' + id).append($(linker(scope))); + }); + } + return result; + }; + }) + + // Simple wrapper around $.crmDatepicker. + // example with no time input: <input crm-ui-datepicker="{time: false}" ng-model="myobj.datefield"/> + // example with custom date format: <input crm-ui-datepicker="{date: 'm/d/y'}" ng-model="myobj.datefield"/> + .directive('crmUiDatepicker', function () { + return { + restrict: 'AE', + require: 'ngModel', + scope: { + crmUiDatepicker: '=' + }, + link: function (scope, element, attrs, ngModel) { + ngModel.$render = function () { + element.val(ngModel.$viewValue).change(); + }; + + element + .crmDatepicker(scope.crmUiDatepicker) + .on('change', function() { + var requiredLength = 19; + if (scope.crmUiDatepicker && scope.crmUiDatepicker.time === false) { + requiredLength = 10; + } + if (scope.crmUiDatepicker && scope.crmUiDatepicker.date === false) { + requiredLength = 8; + } + ngModel.$setValidity('incompleteDateTime', !($(this).val().length && $(this).val().length !== requiredLength)); + }); + } + }; + }) + + // Display debug information (if available) + // For richer DX, checkout Batarang/ng-inspector (Chrome/Safari), or AngScope/ng-inspect (Firefox). + // example: <div crm-ui-debug="myobject" /> + .directive('crmUiDebug', function ($location) { + return { + restrict: 'AE', + scope: { + crmUiDebug: '@' + }, + template: function() { + var args = $location.search(); + return (args && args.angularDebug) ? '<div crm-ui-accordion=\'{title: ts("Debug (%1)", {1: crmUiDebug}), collapsed: true}\'><pre>{{data|json}}</pre></div>' : ''; + }, + link: function(scope, element, attrs) { + var args = $location.search(); + if (args && args.angularDebug) { + scope.ts = CRM.ts(null); + scope.$parent.$watch(attrs.crmUiDebug, function(data) { + scope.data = data; + }); + } + } + }; + }) + + // Display a field/row in a field list + // example: <div crm-ui-field="{title: ts('My Field')}"> {{mydata}} </div> + // example: <div crm-ui-field="{name: 'subform.myfield', title: ts('My Field')}"> <input crm-ui-id="subform.myfield" name="myfield" /> </div> + // example: <div crm-ui-field="{name: 'subform.myfield', title: ts('My Field')}"> <input crm-ui-id="subform.myfield" name="myfield" required /> </div> + // example: <div crm-ui-field="{name: 'subform.myfield', title: ts('My Field'), help: hs('help_field_name'), required: true}"> {{mydata}} </div> + .directive('crmUiField', function() { + // Note: When writing new templates, the "label" position is particular. See/patch "var label" below. + var templateUrls = { + default: '~/crmUi/field.html', + checkbox: '~/crmUi/field-cb.html' + }; + + return { + require: '^crmUiIdScope', + restrict: 'EA', + scope: { + // {title, name, help, helpFile} + crmUiField: '=' + }, + templateUrl: function(tElement, tAttrs){ + var layout = tAttrs.crmLayout ? tAttrs.crmLayout : 'default'; + return templateUrls[layout]; + }, + transclude: true, + link: function (scope, element, attrs, crmUiIdCtrl) { + $(element).addClass('crm-section'); + scope.help = null; + scope.$watch('crmUiField', function(crmUiField) { + if (crmUiField && crmUiField.help) { + scope.help = crmUiField.help.clone({}, { + title: crmUiField.title + }); + } + }); + } + }; + }) + + // example: <div ng-form="subform" crm-ui-id-scope><label crm-ui-for="subform.foo">Foo:</label><input crm-ui-id="subform.foo" name="foo"/></div> + .directive('crmUiId', function () { + return { + require: '^crmUiIdScope', + restrict: 'EA', + link: { + pre: function (scope, element, attrs, crmUiIdCtrl) { + var id = crmUiIdCtrl.get(attrs.crmUiId); + element.attr('id', id); + } + } + }; + }) + + // for example, see crmUiHelp + .service('crmUiHelp', function(){ + // example: var h = new FieldHelp({id: 'foo'}); h.open(); + function FieldHelp(options) { + this.options = options; + } + angular.extend(FieldHelp.prototype, { + get: function(n) { + return this.options[n]; + }, + open: function open() { + CRM.help(this.options.title, {id: this.options.id, file: this.options.file}); + }, + clone: function clone(options, defaults) { + return new FieldHelp(angular.extend({}, defaults, this.options, options)); + } + }); + + // example: var hs = crmUiHelp({file: 'CRM/Foo/Bar'}); + return function(defaults){ + // example: hs('myfield') + // example: hs({id: 'myfield', title: 'Foo Bar', file: 'Whiz/Bang'}) + return function(options) { + if (_.isString(options)) { + options = {id: options}; + } + return new FieldHelp(angular.extend({}, defaults, options)); + }; + }; + }) + + // Display a help icon + // Example: Use a default *.hlp file + // scope.hs = crmUiHelp({file: 'Path/To/Help/File'}); + // HTML: <a crm-ui-help="hs({title:ts('My Field'), id:'my_field'})"> + // Example: Use an explicit *.hlp file + // HTML: <a crm-ui-help="hs({title:ts('My Field'), id:'my_field', file:'CRM/Foo/Bar'})"> + .directive('crmUiHelp', function() { + return { + restrict: 'EA', + link: function(scope, element, attrs) { + setTimeout(function() { + var crmUiHelp = scope.$eval(attrs.crmUiHelp); + var title = crmUiHelp && crmUiHelp.get('title') ? ts('%1 Help', {1: crmUiHelp.get('title')}) : ts('Help'); + element.attr('title', title); + }, 50); + + element + .addClass('helpicon') + .attr('href', '#') + .on('click', function(e) { + e.preventDefault(); + scope.$eval(attrs.crmUiHelp).open(); + }); + } + }; + }) + + // example: <div ng-form="subform" crm-ui-id-scope><label crm-ui-for="subform.foo">Foo:</label><input crm-ui-id="subform.foo" name="foo"/></div> + .directive('crmUiFor', function ($parse, $timeout) { + return { + require: '^crmUiIdScope', + restrict: 'EA', + template: '<span ng-class="cssClasses"><span ng-transclude/><span crm-ui-visible="crmIsRequired" class="crm-marker" title="This field is required.">*</span></span>', + transclude: true, + link: function (scope, element, attrs, crmUiIdCtrl) { + scope.crmIsRequired = false; + scope.cssClasses = {}; + + if (!attrs.crmUiFor) return; + + var id = crmUiIdCtrl.get(attrs.crmUiFor); + element.attr('for', id); + var ngModel = null; + + var updateCss = function () { + scope.cssClasses['crm-error'] = !ngModel.$valid && !ngModel.$pristine; + }; + + // Note: if target element is dynamically generated (eg via ngInclude), then it may not be available + // immediately for initialization. Use retries/retryDelay to initialize such elements. + var init = function (retries, retryDelay) { + var input = $('#' + id); + if (input.length === 0 && !attrs.crmUiForceRequired) { + if (retries) { + $timeout(function(){ + init(retries-1, retryDelay); + }, retryDelay); + } + return; + } + + if (attrs.crmUiForceRequired) { + scope.crmIsRequired = true; + return; + } + + var tgtScope = scope;//.$parent; + if (attrs.crmDepth) { + for (var i = attrs.crmDepth; i > 0; i--) { + tgtScope = tgtScope.$parent; + } + } + + if (input.attr('ng-required')) { + scope.crmIsRequired = scope.$parent.$eval(input.attr('ng-required')); + scope.$parent.$watch(input.attr('ng-required'), function (isRequired) { + scope.crmIsRequired = isRequired; + }); + } + else { + scope.crmIsRequired = input.prop('required'); + } + + ngModel = $parse(attrs.crmUiFor)(tgtScope); + if (ngModel) { + ngModel.$viewChangeListeners.push(updateCss); + } + }; + + $timeout(function(){ + init(3, 100); + }); + } + }; + }) + + // Define a scope in which a name like "subform.foo" maps to a unique ID. + // example: <div ng-form="subform" crm-ui-id-scope><label crm-ui-for="subform.foo">Foo:</label><input crm-ui-id="subform.foo" name="foo"/></div> + .directive('crmUiIdScope', function () { + return { + restrict: 'EA', + scope: {}, + controllerAs: 'crmUiIdCtrl', + controller: function($scope) { + var ids = {}; + this.get = function(name) { + if (!ids[name]) { + ids[name] = "crmUiId_" + (++uidCount); + } + return ids[name]; + }; + }, + link: function (scope, element, attrs) {} + }; + }) + + // Display an HTML blurb inside an IFRAME. + // example: <iframe crm-ui-iframe="getHtmlContent()"></iframe> + // example: <iframe crm-ui-iframe crm-ui-iframe-src="getUrl()"></iframe> + .directive('crmUiIframe', function ($parse) { + return { + scope: { + crmUiIframeSrc: '@', // expression which evaluates to a URL + crmUiIframe: '@' // expression which evaluates to HTML content + }, + link: function (scope, elm, attrs) { + var iframe = $(elm)[0]; + iframe.setAttribute('width', '100%'); + iframe.setAttribute('height', '250px'); + iframe.setAttribute('frameborder', '0'); + + var refresh = function () { + if (attrs.crmUiIframeSrc) { + iframe.setAttribute('src', scope.$parent.$eval(attrs.crmUiIframeSrc)); + } + else { + var iframeHtml = scope.$parent.$eval(attrs.crmUiIframe); + + var doc = iframe.document; + if (iframe.contentDocument) { + doc = iframe.contentDocument; + } + else if (iframe.contentWindow) { + doc = iframe.contentWindow.document; + } + + doc.open(); + doc.writeln(iframeHtml); + doc.close(); + } + }; + + // If the iframe is in a dialog, respond to resize events + $(elm).parent().on('dialogresize dialogopen', function(e, ui) { + $(this).css({padding: '0', margin: '0', overflow: 'hidden'}); + iframe.setAttribute('height', '' + $(this).innerHeight() + 'px'); + }); + + $(elm).parent().on('dialogresize', function(e, ui) { + iframe.setAttribute('class', 'resized'); + }); + + scope.$parent.$watch(attrs.crmUiIframe, refresh); + } + }; + }) + + // Example: + // <a ng-click="$broadcast('my-insert-target', 'some new text')>Insert</a> + // <textarea crm-ui-insert-rx='my-insert-target'></textarea> + .directive('crmUiInsertRx', function() { + return { + link: function(scope, element, attrs) { + scope.$on(attrs.crmUiInsertRx, function(e, tokenName) { + CRM.wysiwyg.insert(element, tokenName); + $(element).select2('close').select2('val', ''); + CRM.wysiwyg.focus(element); + }); + } + }; + }) + + // Define a rich text editor. + // example: <textarea crm-ui-id="myForm.body_html" crm-ui-richtext name="body_html" ng-model="mailing.body_html"></textarea> + .directive('crmUiRichtext', function ($timeout) { + return { + require: '?ngModel', + link: function (scope, elm, attr, ngModel) { + + var editor = CRM.wysiwyg.create(elm); + if (!ngModel) { + return; + } + + if (attr.ngBlur) { + $(elm).on('blur', function() { + $timeout(function() { + scope.$eval(attr.ngBlur); + }); + }); + } + + ngModel.$render = function(value) { + editor.done(function() { + CRM.wysiwyg.setVal(elm, ngModel.$viewValue || ''); + }); + }; + } + }; + }) + + // Display a lock icon (based on a boolean). + // example: <a crm-ui-lock binding="mymodel.boolfield"></a> + // example: <a crm-ui-lock + // binding="mymodel.boolfield" + // title-locked="ts('Boolfield is locked')" + // title-unlocked="ts('Boolfield is unlocked')"></a> + .directive('crmUiLock', function ($parse, $rootScope) { + var defaultVal = function (defaultValue) { + var f = function (scope) { + return defaultValue; + }; + f.assign = function (scope, value) { + // ignore changes + }; + return f; + }; + + // like $parse, but accepts a defaultValue in case expr is undefined + var parse = function (expr, defaultValue) { + return expr ? $parse(expr) : defaultVal(defaultValue); + }; + + return { + template: '', + link: function (scope, element, attrs) { + var binding = parse(attrs.binding, true); + var titleLocked = parse(attrs.titleLocked, ts('Locked')); + var titleUnlocked = parse(attrs.titleUnlocked, ts('Unlocked')); + + $(element).addClass('crm-i lock-button'); + var refresh = function () { + var locked = binding(scope); + if (locked) { + $(element) + .removeClass('fa-unlock') + .addClass('fa-lock') + .prop('title', titleLocked(scope)) + ; + } + else { + $(element) + .removeClass('fa-lock') + .addClass('fa-unlock') + .prop('title', titleUnlocked(scope)) + ; + } + }; + + $(element).click(function () { + binding.assign(scope, !binding(scope)); + //scope.$digest(); + $rootScope.$digest(); + }); + + scope.$watch(attrs.binding, refresh); + scope.$watch(attrs.titleLocked, refresh); + scope.$watch(attrs.titleUnlocked, refresh); + + refresh(); + } + }; + }) + + // CrmUiOrderCtrl is a controller class which manages sort orderings. + // Ex: + // JS: $scope.myOrder = new CrmUiOrderCtrl(['+field1', '-field2]); + // $scope.myOrder.toggle('field1'); + // $scope.myOrder.setDir('field2', ''); + // HTML: <tr ng-repeat="... | order:myOrder.get()">...</tr> + .service('CrmUiOrderCtrl', function(){ + // + function CrmUiOrderCtrl(defaults){ + this.values = defaults; + } + angular.extend(CrmUiOrderCtrl.prototype, { + get: function get() { + return this.values; + }, + getDir: function getDir(name) { + if (this.values.indexOf(name) >= 0 || this.values.indexOf('+' + name) >= 0) { + return '+'; + } + if (this.values.indexOf('-' + name) >= 0) { + return '-'; + } + return ''; + }, + // @return bool TRUE if something is removed + remove: function remove(name) { + var idx = this.values.indexOf(name); + if (idx >= 0) { + this.values.splice(idx, 1); + return true; + } + else { + return false; + } + }, + setDir: function setDir(name, dir) { + return this.toggle(name, dir); + }, + // Toggle sort order on a field. + // To set a specific order, pass optional parameter 'next' ('+', '-', or ''). + toggle: function toggle(name, next) { + if (!next && next !== '') { + next = '+'; + if (this.remove(name) || this.remove('+' + name)) { + next = '-'; + } + if (this.remove('-' + name)) { + next = ''; + } + } + + if (next == '+') { + this.values.unshift('+' + name); + } + else if (next == '-') { + this.values.unshift('-' + name); + } + } + }); + return CrmUiOrderCtrl; + }) + + // Define a controller which manages sort order. You may interact with the controller + // directly ("myOrder.toggle('fieldname')") order using the helper, crm-ui-order-by. + // example: + // <span crm-ui-order="{var: 'myOrder', defaults: {'-myField'}}"></span> + // <th><a crm-ui-order-by="[myOrder,'myField']">My Field</a></th> + // <tr ng-repeat="... | order:myOrder.get()">...</tr> + // <button ng-click="myOrder.toggle('myField')"> + .directive('crmUiOrder', function(CrmUiOrderCtrl) { + return { + link: function(scope, element, attrs){ + var options = angular.extend({var: 'crmUiOrderBy'}, scope.$eval(attrs.crmUiOrder)); + scope[options.var] = new CrmUiOrderCtrl(options.defaults); + } + }; + }) + + // For usage, see crmUiOrder (above) + .directive('crmUiOrderBy', function() { + return { + link: function(scope, element, attrs) { + function updateClass(crmUiOrderCtrl, name) { + var dir = crmUiOrderCtrl.getDir(name); + element + .toggleClass('sorting_asc', dir === '+') + .toggleClass('sorting_desc', dir === '-') + .toggleClass('sorting', dir === ''); + } + + element.on('click', function(e){ + var tgt = scope.$eval(attrs.crmUiOrderBy); + tgt[0].toggle(tgt[1]); + updateClass(tgt[0], tgt[1]); + e.preventDefault(); + scope.$digest(); + }); + + var tgt = scope.$eval(attrs.crmUiOrderBy); + updateClass(tgt[0], tgt[1]); + } + }; + }) + + // Display a fancy SELECT (based on select2). + // usage: <select crm-ui-select="{placeholder:'Something',allowClear:true,...}" ng-model="myobj.field"><option...></select> + .directive('crmUiSelect', function ($parse, $timeout) { + return { + require: '?ngModel', + priority: 1, + scope: { + crmUiSelect: '=' + }, + link: function (scope, element, attrs, ngModel) { + // In cases where UI initiates update, there may be an extra + // call to refreshUI, but it doesn't create a cycle. + + if (ngModel) { + ngModel.$render = function () { + $timeout(function () { + // ex: msg_template_id adds new item then selects it; use $timeout to ensure that + // new item is added before selection is made + var newVal = _.cloneDeep(ngModel.$modelValue); + // Fix possible data-type mismatch + if (typeof newVal === 'string' && element.select2('container').hasClass('select2-container-multi')) { + newVal = newVal.length ? newVal.split(',') : []; + } + element.select2('val', newVal); + }); + }; + } + function refreshModel() { + var oldValue = ngModel.$viewValue, newValue = element.select2('val'); + if (oldValue != newValue) { + scope.$parent.$apply(function () { + ngModel.$setViewValue(newValue); + }); + } + } + + function init() { + // TODO watch select2-options + element.crmSelect2(scope.crmUiSelect || {}); + if (ngModel) { + element.on('change', refreshModel); + } + } + + init(); + } + }; + }) + + // Render a crmEntityRef widget + // usage: <input crm-entityref="{entity: 'Contact', select: {allowClear:true}}" ng-model="myobj.field" /> + .directive('crmEntityref', function ($parse, $timeout) { + return { + require: '?ngModel', + scope: { + crmEntityref: '=' + }, + link: function (scope, element, attrs, ngModel) { + // In cases where UI initiates update, there may be an extra + // call to refreshUI, but it doesn't create a cycle. + + ngModel.$render = function () { + $timeout(function () { + // ex: msg_template_id adds new item then selects it; use $timeout to ensure that + // new item is added before selection is made + var newVal = _.cloneDeep(ngModel.$modelValue); + // Fix possible data-type mismatch + if (typeof newVal === 'string' && element.select2('container').hasClass('select2-container-multi')) { + newVal = newVal.length ? newVal.split(',') : []; + } + element.select2('val', newVal); + }); + }; + function refreshModel() { + var oldValue = ngModel.$viewValue, newValue = element.select2('val'); + if (oldValue != newValue) { + scope.$parent.$apply(function () { + ngModel.$setViewValue(newValue); + }); + } + } + + function init() { + // TODO can we infer "entity" from model? + element.crmEntityRef(scope.crmEntityref || {}); + element.on('change', refreshModel); + $timeout(ngModel.$render); + } + + init(); + } + }; + }) + + // validate multiple email text + // usage: <input crm-multiple-email type="text" ng-model="myobj.field" /> + .directive('crmMultipleEmail', function ($parse, $timeout) { + return { + require: 'ngModel', + link: function(scope, element, attrs, ctrl) { + ctrl.$parsers.unshift(function(viewValue) { + // if empty value provided simply bypass validation + if (_.isEmpty(viewValue)) { + ctrl.$setValidity('crmMultipleEmail', true); + return viewValue; + } + + // split email string on basis of comma + var emails = viewValue.split(','); + // regex pattern for single email + var emailRegex = /\S+@\S+\.\S+/; + + var validityArr = emails.map(function(str){ + return emailRegex.test(str.trim()); + }); + + if ($.inArray(false, validityArr) > -1) { + ctrl.$setValidity('crmMultipleEmail', false); + } else { + ctrl.$setValidity('crmMultipleEmail', true); + } + return viewValue; + }); + } + }; + }) + // example <div crm-ui-tab id="tab-1" crm-title="ts('My Title')" count="3">...content...</div> + // WISHLIST: use a full Angular component instead of an incomplete jQuery wrapper + .directive('crmUiTab', function($parse) { + return { + require: '^crmUiTabSet', + restrict: 'EA', + scope: { + crmTitle: '@', + crmIcon: '@', + count: '@', + id: '@' + }, + template: '<div ng-transclude></div>', + transclude: true, + link: function (scope, element, attrs, crmUiTabSetCtrl) { + crmUiTabSetCtrl.add(scope); + } + }; + }) + + // example: <div crm-ui-tab-set><div crm-ui-tab crm-title="Tab 1">...</div><div crm-ui-tab crm-title="Tab 2">...</div></div> + .directive('crmUiTabSet', function() { + return { + restrict: 'EA', + scope: { + crmUiTabSet: '@', + tabSetOptions: '@' + }, + templateUrl: '~/crmUi/tabset.html', + transclude: true, + controllerAs: 'crmUiTabSetCtrl', + controller: function($scope, $parse) { + var tabs = $scope.tabs = []; // array<$scope> + this.add = function(tab) { + if (!tab.id) throw "Tab is missing 'id'"; + tabs.push(tab); + }; + }, + link: function (scope, element, attrs) {} + }; + }) + + // Generic, field-independent form validator. + // example: <span ng-model="placeholder" crm-ui-validate="foo && bar || whiz" /> + // example: <span ng-model="placeholder" crm-ui-validate="foo && bar || whiz" crm-ui-validate-name="myError" /> + .directive('crmUiValidate', function() { + return { + restrict: 'EA', + require: 'ngModel', + link: function(scope, element, attrs, ngModel) { + var validationKey = attrs.crmUiValidateName ? attrs.crmUiValidateName : 'crmUiValidate'; + scope.$watch(attrs.crmUiValidate, function(newValue){ + ngModel.$setValidity(validationKey, !!newValue); + }); + } + }; + }) + + // like ng-show, but hides/displays elements using "visibility" which maintains positioning + // example <div crm-ui-visible="false">...content...</div> + .directive('crmUiVisible', function($parse) { + return { + restrict: 'EA', + scope: { + crmUiVisible: '@' + }, + link: function (scope, element, attrs) { + var model = $parse(attrs.crmUiVisible); + function updatecChildren() { + element.css('visibility', model(scope.$parent) ? 'inherit' : 'hidden'); + } + updatecChildren(); + scope.$parent.$watch(attrs.crmUiVisible, updatecChildren); + } + }; + }) + + // example: <div crm-ui-wizard="myWizardCtrl"><div crm-ui-wizard-step crm-title="ts('Step 1')">...</div><div crm-ui-wizard-step crm-title="ts('Step 2')">...</div></div> + // example with custom nav classes: <div crm-ui-wizard crm-ui-wizard-nav-class="ng-animate-out ...">...</div> + // Note: "myWizardCtrl" has various actions/properties like next() and $first(). + // WISHLIST: Allow each step to determine if it is "complete" / "valid" / "selectable" + // WISHLIST: Allow each step to enable/disable (show/hide) itself + .directive('crmUiWizard', function() { + return { + restrict: 'EA', + scope: { + crmUiWizard: '@', + crmUiWizardNavClass: '@' // string, A list of classes that will be added to the nav items + }, + templateUrl: '~/crmUi/wizard.html', + transclude: true, + controllerAs: 'crmUiWizardCtrl', + controller: function($scope, $parse) { + var steps = $scope.steps = []; // array<$scope> + var crmUiWizardCtrl = this; + var maxVisited = 0; + var selectedIndex = null; + + var findIndex = function() { + var found = null; + angular.forEach(steps, function(step, stepKey) { + if (step.selected) found = stepKey; + }); + return found; + }; + + /// @return int the index of the current step + this.$index = function() { return selectedIndex; }; + /// @return bool whether the currentstep is first + this.$first = function() { return this.$index() === 0; }; + /// @return bool whether the current step is last + this.$last = function() { return this.$index() === steps.length -1; }; + this.$maxVisit = function() { return maxVisited; }; + this.$validStep = function() { + return steps[selectedIndex] && steps[selectedIndex].isStepValid(); + }; + this.iconFor = function(index) { + if (index < this.$index()) return '√'; + if (index === this.$index()) return '»'; + return ' '; + }; + this.isSelectable = function(step) { + if (step.selected) return false; + return this.$validStep(); + }; + + /*** @param Object step the $scope of the step */ + this.select = function(step) { + angular.forEach(steps, function(otherStep, otherKey) { + otherStep.selected = (otherStep === step); + if (otherStep === step && maxVisited < otherKey) maxVisited = otherKey; + }); + selectedIndex = findIndex(); + }; + /*** @param Object step the $scope of the step */ + this.add = function(step) { + if (steps.length === 0) { + step.selected = true; + selectedIndex = 0; + } + steps.push(step); + steps.sort(function(a,b){ + return a.crmUiWizardStep - b.crmUiWizardStep; + }); + selectedIndex = findIndex(); + }; + this.remove = function(step) { + var key = null; + angular.forEach(steps, function(otherStep, otherKey) { + if (otherStep === step) key = otherKey; + }); + if (key !== null) { + steps.splice(key, 1); + } + }; + this.goto = function(index) { + if (index < 0) index = 0; + if (index >= steps.length) index = steps.length-1; + this.select(steps[index]); + }; + this.previous = function() { this.goto(this.$index()-1); }; + this.next = function() { this.goto(this.$index()+1); }; + if ($scope.crmUiWizard) { + $parse($scope.crmUiWizard).assign($scope.$parent, this); + } + }, + link: function (scope, element, attrs) { + scope.ts = CRM.ts(null); + + element.find('.crm-wizard-buttons button[ng-click^=crmUiWizardCtrl]').click(function () { + // These values are captured inside the click handler to ensure the + // positions/sizes of the elements are captured at the time of the + // click vs. at the time this directive is initialized. + var topOfWizard = element.offset().top; + var heightOfMenu = $('#civicrm-menu').height() || 0; + + $('html') + // stop any other animations that might be happening... + .stop() + // gracefully slide the user to the top of the wizard + .animate({scrollTop: topOfWizard - heightOfMenu}, 1000); + }); + } + }; + }) + + // Use this to add extra markup to wizard + .directive('crmUiWizardButtons', function() { + return { + require: '^crmUiWizard', + restrict: 'EA', + scope: {}, + template: '<span ng-transclude></span>', + transclude: true, + link: function (scope, element, attrs, crmUiWizardCtrl) { + var realButtonsEl = $(element).closest('.crm-wizard').find('.crm-wizard-buttons'); + $(element).appendTo(realButtonsEl); + } + }; + }) + + // Example for Font Awesome: <button crm-icon="fa-check">Save</button> + // Example for jQuery UI (deprecated): <button crm-icon="check">Save</button> + .directive('crmIcon', function() { + return { + restrict: 'EA', + link: function (scope, element, attrs) { + if (element.is('[crm-ui-tab]')) { + // handled in crmUiTab ctrl + return; + } + if (attrs.crmIcon.substring(0,3) == 'fa-') { + $(element).prepend('<i class="crm-i ' + attrs.crmIcon + '"></i> '); + } + else { + $(element).prepend('<span class="icon ui-icon-' + attrs.crmIcon + '"></span> '); + } + if ($(element).is('button')) { + $(element).addClass('crm-button'); + } + } + }; + }) + + // example: <div crm-ui-wizard-step crm-title="ts('My Title')" ng-form="mySubForm">...content...</div> + // If there are any conditional steps, then be sure to set a weight explicitly on *all* steps to maintain ordering. + // example: <div crm-ui-wizard-step="100" crm-title="..." ng-if="...">...content...</div> + // example with custom classes: <div crm-ui-wizard-step="100" crm-ui-wizard-step-class="ng-animate-out ...">...content...</div> + .directive('crmUiWizardStep', function() { + var nextWeight = 1; + return { + require: ['^crmUiWizard', 'form'], + restrict: 'EA', + scope: { + crmTitle: '@', // expression, evaluates to a printable string + crmUiWizardStep: '@', // int, a weight which determines the ordering of the steps + crmUiWizardStepClass: '@' // string, A list of classes that will be added to the template + }, + template: '<div class="crm-wizard-step {{crmUiWizardStepClass}}" ng-show="selected" ng-transclude/></div>', + transclude: true, + link: function (scope, element, attrs, ctrls) { + var crmUiWizardCtrl = ctrls[0], form = ctrls[1]; + if (scope.crmUiWizardStep) { + scope.crmUiWizardStep = parseInt(scope.crmUiWizardStep); + } else { + scope.crmUiWizardStep = nextWeight++; + } + scope.isStepValid = function() { + return form.$valid; + }; + crmUiWizardCtrl.add(scope); + scope.$on('$destroy', function(){ + crmUiWizardCtrl.remove(scope); + }); + } + }; + }) + + // Example: <button crm-confirm="{message: ts('Are you sure you want to continue?')}" on-yes="frobnicate(123)">Frobincate</button> + // Example: <button crm-confirm="{type: 'disable', obj: myObject}" on-yes="myObject.is_active=0; myObject.save()">Disable</button> + // Example: <button crm-confirm="{templateUrl: '~/path/to/view.html', export: {foo: bar}}" on-yes="frobnicate(123)">Frobincate</button> + .directive('crmConfirm', function ($compile, $rootScope, $templateRequest, $q) { + // Helpers to calculate default options for CRM.confirm() + var defaultFuncs = { + 'disable': function (options) { + return { + message: ts('Are you sure you want to disable this?'), + options: {no: ts('Cancel'), yes: ts('Disable')}, + width: 300, + title: ts('Disable %1?', { + 1: options.obj.title || options.obj.label || options.obj.name || ts('the record') + }) + }; + }, + 'revert': function (options) { + return { + message: ts('Are you sure you want to revert this?'), + options: {no: ts('Cancel'), yes: ts('Revert')}, + width: 300, + title: ts('Revert %1?', { + 1: options.obj.title || options.obj.label || options.obj.name || ts('the record') + }) + }; + }, + 'delete': function (options) { + return { + message: ts('Are you sure you want to delete this?'), + options: {no: ts('Cancel'), yes: ts('Delete')}, + width: 300, + title: ts('Delete %1?', { + 1: options.obj.title || options.obj.label || options.obj.name || ts('the record') + }) + }; + } + }; + var confirmCount = 0; + return { + link: function (scope, element, attrs) { + $(element).click(function () { + var options = scope.$eval(attrs.crmConfirm); + if (attrs.title && !options.title) { + options.title = attrs.title; + } + var defaults = (options.type) ? defaultFuncs[options.type](options) : {}; + + var tpl = null, stubId = null; + if (!options.message) { + if (options.templateUrl) { + tpl = $templateRequest(options.templateUrl); + } + else if (options.template) { + tpl = options.template; + } + if (tpl) { + stubId = 'crmUiConfirm_' + (++confirmCount); + options.message = '<div id="' + stubId + '"></div>'; + } + } + + CRM.confirm(_.extend(defaults, options)) + .on('crmConfirm:yes', function() { scope.$apply(attrs.onYes); }) + .on('crmConfirm:no', function() { scope.$apply(attrs.onNo); }); + + if (tpl && stubId) { + $q.when(tpl, function(html) { + var scope = options.scope || $rootScope.$new(); + if (options.export) { + angular.extend(scope, options.export); + } + var linker = $compile(html); + $('#' + stubId).append($(linker(scope))); + }); + } + }); + } + }; + }) + + // Sets document title & page title; attempts to override CMS title markup for the latter + // WARNING: Use only once per route! + // WARNING: This directive works only if your AngularJS base page does not + // set a custom title (i.e., it has an initial title of "CiviCRM"). See the + // global variables pageTitle and documentTitle. + // Example (same title for both): <h1 crm-page-title>{{ts('Hello')}}</h1> + // Example (separate document title): <h1 crm-document-title="ts('Hello')" crm-page-title><i class="crm-i fa-flag"></i>{{ts('Hello')}}</h1> + .directive('crmPageTitle', function($timeout) { + return { + scope: { + crmDocumentTitle: '=' + }, + link: function(scope, $el, attrs) { + function update() { + $timeout(function() { + var newPageTitle = _.trim($el.html()), + newDocumentTitle = scope.crmDocumentTitle || $el.text(); + document.title = $('title').text().replace(documentTitle, newDocumentTitle); + // If the CMS has already added title markup to the page, use it + $('h1').not('.crm-container h1').each(function() { + if (_.trim($(this).html()) === pageTitle) { + $(this).addClass('crm-page-title').html(newPageTitle); + $el.hide(); + } + }); + pageTitle = newPageTitle; + documentTitle = newDocumentTitle; + }); + } + + scope.$watch(function() {return scope.crmDocumentTitle + $el.html();}, update); + } + }; + }) + + .run(function($rootScope, $location) { + /// Example: <button ng-click="goto('home')">Go home!</button> + $rootScope.goto = function(path) { + $location.path(path); + }; + // useful for debugging: $rootScope.log = console.log || function() {}; + }) + ; + +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmUi/field-cb.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmUi/field-cb.html new file mode 100644 index 00000000..fe1a2ab9 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmUi/field-cb.html @@ -0,0 +1,8 @@ +<label crm-depth="1"> + <span ng-transclude></span> + <span ng-class="cssClasses"> + {{crmUiField.title}} + </span> +</label> +<a crm-ui-help="help" ng-if="crmUiField.help"></a> +<div class="clear"></div> diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmUi/field.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmUi/field.html new file mode 100644 index 00000000..da6521a6 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmUi/field.html @@ -0,0 +1,6 @@ +<div class="label"> + <label crm-ui-for="{{crmUiField.name}}" crm-depth="1" crm-ui-force-required="{{crmUiField.required}}">{{crmUiField.title}}</label> + <a crm-ui-help="help" ng-if="help"></a> +</div> +<div class="content" ng-transclude></div> +<div class="clear"></div> diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmUi/tabset.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmUi/tabset.html new file mode 100644 index 00000000..6bb45711 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmUi/tabset.html @@ -0,0 +1,12 @@ +<div ui-jq="tabs" ui-options="{{tabSetOptions}}" class="crm-tabset"> + <ul> + <li ng-repeat="tab in tabs" class="ui-corner-all crm-tab-button crm-count-{{tab.count}}"> + <a href="#{{tab.id}}"> + <i ng-if="tab.crmIcon" class="crm-i {{tab.crmIcon}}"></i> + {{tab.$parent.$eval(tab.crmTitle)}} + <em ng-if="tab.count">{{tab.count}}</em> + </a> + </li> + </ul> + <div ng-transclude></div> +</div>
\ No newline at end of file diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmUi/wizard.html b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmUi/wizard.html new file mode 100644 index 00000000..116f060d --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmUi/wizard.html @@ -0,0 +1,15 @@ +<div class="crm-wizard"> + <ul class="crm-wizard-nav wizard-bar"> + <li ng-repeat="step in steps" ng-class="{'current-step':step.selected}" class="{{crmUiWizardNavClass}}"> + <span>{{crmUiWizardCtrl.iconFor($index)}}</span> + <a href="" ng-click="crmUiWizardCtrl.select(step)" ng-show="crmUiWizardCtrl.isSelectable(step)">{{1 + $index}}. {{step.$parent.$eval(step.crmTitle)}}</a> + <span ng-show="!crmUiWizardCtrl.isSelectable(step)">{{1 + $index}}. {{step.$parent.$eval(step.crmTitle)}}</span> + <!-- Don't know a good way to localize text like "1. Title" --> + </li> + </ul> + <div class="crm-wizard-body" ng-transclude></div> + <div class="crm-wizard-buttons"> + <button crm-icon="fa-chevron-left" ng-click="crmUiWizardCtrl.previous()" ng-show="!crmUiWizardCtrl.$first()" class="crmUi-btn-primary">{{ts('Previous')}}</button> + <button crm-icon="fa-chevron-right" title="{{!crmUiWizardCtrl.$validStep() ? ts('Complete all required fields first') : ts('Next step')}}" ng-click="crmUiWizardCtrl.next()" ng-show="!crmUiWizardCtrl.$last()" ng-disabled="!crmUiWizardCtrl.$validStep()" class="crmUi-btn-primary">{{ts('Next')}}</button> + </div> +</div> diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmUtil.ang.php b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmUtil.ang.php new file mode 100644 index 00000000..78ca548c --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmUtil.ang.php @@ -0,0 +1,10 @@ +<?php +// This file declares an Angular module which can be autoloaded +// in CiviCRM. See also: +// http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_angularModules + +return [ + 'ext' => 'civicrm', + 'js' => ['ang/crmUtil.js'], + 'requires' => [], +]; diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmUtil.js b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmUtil.js new file mode 100644 index 00000000..ab460ab7 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/crmUtil.js @@ -0,0 +1,361 @@ +/// crmUi: Sundry UI helpers +(function (angular, $, _) { + angular.module('crmUtil', CRM.angRequires('crmUtil')); + + // Angular implementation of CRM.api3 + // @link http://wiki.civicrm.org/confluence/display/CRMDOC/AJAX+Interface#AJAXInterface-CRM.api3 + // + // Note: To mock API results in unit-tests, override crmApi.backend, e.g. + // var apiSpy = jasmine.createSpy('crmApi'); + // crmApi.backend = apiSpy.and.returnValue(crmApi.val({ + // is_error: 1 + // })); + angular.module('crmUtil').factory('crmApi', function($q) { + var crmApi = function(entity, action, params, message) { + // JSON serialization in CRM.api3 is not aware of Angular metadata like $$hash, so use angular.toJson() + var deferred = $q.defer(); + var p; + var backend = crmApi.backend || CRM.api3; + if (params && params.body_html) { + // CRM-18474 - remove Unicode Character 'LINE SEPARATOR' (U+2028) + // and 'PARAGRAPH SEPARATOR' (U+2029) from the html if present. + params.body_html = params.body_html.replace(/([\u2028]|[\u2029])/g, '\n'); + } + if (_.isObject(entity)) { + // eval content is locally generated. + /*jshint -W061 */ + p = backend(eval('('+angular.toJson(entity)+')'), action); + } else { + // eval content is locally generated. + /*jshint -W061 */ + p = backend(entity, action, eval('('+angular.toJson(params)+')'), message); + } + // CRM.api3 returns a promise, but the promise doesn't really represent errors as errors, so we + // convert them + p.then( + function(result) { + if (result.is_error) { + deferred.reject(result); + } else { + deferred.resolve(result); + } + }, + function(error) { + deferred.reject(error); + } + ); + return deferred.promise; + }; + crmApi.backend = null; + crmApi.val = function(value) { + var d = $.Deferred(); + d.resolve(value); + return d.promise(); + }; + return crmApi; + }); + + // Get and cache the metadata for an API entity. + // usage: + // $q.when(crmMetadata.getFields('MyEntity'), function(fields){ + // console.log('The fields are:', options); + // }); + angular.module('crmUtil').factory('crmMetadata', function($q, crmApi) { + + // Convert {key:$,value:$} sequence to unordered {$key: $value} map. + function convertOptionsToMap(options) { + var result = {}; + angular.forEach(options, function(o) { + result[o.key] = o.value; + }); + return result; + } + + var cache = {}; // cache[entityName+'::'+action][fieldName].title + var deferreds = {}; // deferreds[cacheKey].push($q.defer()) + var crmMetadata = { + // usage: $q.when(crmMetadata.getField('MyEntity', 'my_field')).then(...); + getField: function getField(entity, field) { + return $q.when(crmMetadata.getFields(entity)).then(function(fields){ + return fields[field]; + }); + }, + // usage: $q.when(crmMetadata.getFields('MyEntity')).then(...); + // usage: $q.when(crmMetadata.getFields(['MyEntity', 'myaction'])).then(...); + getFields: function getFields(entity) { + var action = '', cacheKey; + if (_.isArray(entity)) { + action = entity[1]; + entity = entity[0]; + cacheKey = entity + '::' + action; + } else { + cacheKey = entity; + } + + if (_.isObject(cache[cacheKey])) { + return cache[cacheKey]; + } + + var needFetch = _.isEmpty(deferreds[cacheKey]); + deferreds[cacheKey] = deferreds[cacheKey] || []; + var deferred = $q.defer(); + deferreds[cacheKey].push(deferred); + + if (needFetch) { + crmApi(entity, 'getfields', {action: action, sequential: 1, options: {get_options: 'all'}}) + .then( + // on success: + function(fields) { + cache[cacheKey] = _.indexBy(fields.values, 'name'); + angular.forEach(cache[cacheKey],function (field){ + if (field.options) { + field.optionsMap = convertOptionsToMap(field.options); + } + }); + angular.forEach(deferreds[cacheKey], function(dfr) { + dfr.resolve(cache[cacheKey]); + }); + delete deferreds[cacheKey]; + }, + // on error: + function() { + cache[cacheKey] = {}; // cache nack + angular.forEach(deferreds[cacheKey], function(dfr) { + dfr.reject(); + }); + delete deferreds[cacheKey]; + } + ); + } + + return deferred.promise; + } + }; + + return crmMetadata; + }); + + // usage: + // var block = $scope.block = crmBlocker(); + // $scope.save = function() { return block(crmApi('MyEntity','create',...)); }; + // <button ng-click="save()" ng-disabled="block.check()">Do something</button> + angular.module('crmUtil').factory('crmBlocker', function() { + return function() { + var blocks = 0; + var result = function(promise) { + blocks++; + return promise.finally(function() { + blocks--; + }); + }; + result.check = function() { + return blocks > 0; + }; + return result; + }; + }); + + angular.module('crmUtil').factory('crmLegacy', function() { + return CRM; + }); + + // example: scope.$watch('foo', crmLog.wrap(function(newValue, oldValue){ ... })); + angular.module('crmUtil').factory('crmLog', function(){ + var level = 0; + var write = console.log; + function indent() { + var s = '>'; + for (var i = 0; i < level; i++) s = s + ' '; + return s; + } + var crmLog = { + log: function(msg, vars) { + write(indent() + msg, vars); + }, + wrap: function(label, f) { + return function(){ + level++; + crmLog.log(label + ": start", arguments); + var r; + try { + r = f.apply(this, arguments); + } finally { + crmLog.log(label + ": end"); + level--; + } + return r; + }; + } + }; + return crmLog; + }); + + angular.module('crmUtil').factory('crmNavigator', ['$window', function($window) { + return { + redirect: function(path) { + $window.location.href = path; + } + }; + }]); + + // Wrap an async function in a queue, ensuring that independent async calls are issued in strict sequence. + // usage: qApi = crmQueue(crmApi); qApi(entity,action,...).then(...); qApi(entity2,action2,...).then(...); + // This is similar to promise-chaining, but allows chaining independent procs (without explicitly sharing promises). + angular.module('crmUtil').factory('crmQueue', function($q) { + // @param worker A function which generates promises + return function crmQueue(worker) { + var queue = []; + function next() { + var task = queue[0]; + worker.apply(null, task.a).then( + function onOk(data) { + queue.shift(); + task.dfr.resolve(data); + if (queue.length > 0) next(); + }, + function onErr(err) { + queue.shift(); + task.dfr.reject(err); + if (queue.length > 0) next(); + } + ); + } + function enqueue() { + var dfr = $q.defer(); + queue.push({a: arguments, dfr: dfr}); + if (queue.length === 1) { + next(); + } + return dfr.promise; + } + return enqueue; + }; + }); + + // Adapter for CRM.status which supports Angular promises (instead of jQuery promises) + // example: crmStatus('Saving', crmApi(...)).then(function(result){...}) + angular.module('crmUtil').factory('crmStatus', function($q){ + return function(options, aPromise){ + if (aPromise) { + return CRM.toAPromise($q, CRM.status(options, CRM.toJqPromise(aPromise))); + } else { + return CRM.toAPromise($q, CRM.status(options)); + } + }; + }); + + // crmWatcher allows one to setup event listeners and temporarily suspend + // them en masse. + // + // example: + // angular.controller(... function($scope, crmWatcher){ + // var watcher = crmWatcher(); + // function myfunc() { + // watcher.suspend('foo', function(){ + // ...do stuff... + // }); + // } + // watcher.setup('foo', function(){ + // return [ + // $scope.$watch('foo', myfunc), + // $scope.$watch('bar', myfunc), + // $scope.$watch('whiz', otherfunc) + // ]; + // }); + // }); + angular.module('crmUtil').factory('crmWatcher', function(){ + return function() { + var unwatches = {}, watchFactories = {}, suspends = {}; + + // Specify the list of watches + this.setup = function(name, newWatchFactory) { + watchFactories[name] = newWatchFactory; + unwatches[name] = watchFactories[name](); + suspends[name] = 0; + return this; + }; + + // Temporarily disable watches and run some logic + this.suspend = function(name, f) { + suspends[name]++; + this.teardown(name); + var r; + try { + r = f.apply(this, []); + } finally { + if (suspends[name] === 1) { + unwatches[name] = watchFactories[name](); + if (!angular.isArray(unwatches[name])) { + unwatches[name] = [unwatches[name]]; + } + } + suspends[name]--; + } + return r; + }; + + this.teardown = function(name) { + if (!unwatches[name]) return; + _.each(unwatches[name], function(unwatch){ + unwatch(); + }); + delete unwatches[name]; + }; + + return this; + }; + }); + + // Run a given function. If it is already running, wait for it to finish before running again. + // If multiple requests are made before the first request finishes, all but the last will be ignored. + // This prevents overwhelming the server with redundant queries during e.g. an autocomplete search while the user types. + // Given function should return an angular promise. crmThrottle will deliver the contents when resolved. + angular.module('crmUtil').factory('crmThrottle', function($q) { + var pending = [], + executing = []; + return function(func) { + var deferred = $q.defer(); + + function checkResult(result, success) { + _.pull(executing, func); + if (_.includes(pending, func)) { + runNext(); + } else if (success) { + deferred.resolve(result); + } else { + deferred.reject(result); + } + } + + function runNext() { + executing.push(func); + _.pull(pending, func); + func().then(function(result) { + checkResult(result, true); + }, function(result) { + checkResult(result, false); + }); + } + + if (!_.includes(executing, func)) { + runNext(); + } else if (!_.includes(pending, func)) { + pending.push(func); + } + return deferred.promise; + }; + }); + + angular.module('crmUtil').factory('crmLoadScript', function($q) { + return function(url) { + var deferred = $q.defer(); + + CRM.loadScript(url).done(function() { + deferred.resolve(true); + }); + + return deferred.promise; + }; + }); + +})(angular, CRM.$, CRM._); diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/dialogService.ang.php b/www/crm/wp-content/plugins/civicrm/civicrm/ang/dialogService.ang.php new file mode 100644 index 00000000..31a9a8ac --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/dialogService.ang.php @@ -0,0 +1,11 @@ +<?php +// This file declares an Angular module which can be autoloaded +// in CiviCRM. See also: +// http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_angularModules + +// https://github.com/jwstadler/angular-jquery-dialog-service + +return [ + 'ext' => 'civicrm', + 'js' => ['bower_components/angular-jquery-dialog-service/dialog-service.js'], +]; diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/ngRoute.ang.php b/www/crm/wp-content/plugins/civicrm/civicrm/ang/ngRoute.ang.php new file mode 100644 index 00000000..11c6d868 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/ngRoute.ang.php @@ -0,0 +1,9 @@ +<?php +// This file declares an Angular module which can be autoloaded +// in CiviCRM. See also: +// http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_angularModules + +return [ + 'ext' => 'civicrm', + 'js' => ['bower_components/angular-route/angular-route.min.js'], +]; diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/ngSanitize.ang.php b/www/crm/wp-content/plugins/civicrm/civicrm/ang/ngSanitize.ang.php new file mode 100644 index 00000000..36329e55 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/ngSanitize.ang.php @@ -0,0 +1,9 @@ +<?php +// This file declares an Angular module which can be autoloaded +// in CiviCRM. See also: +// http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_angularModules + +return [ + 'ext' => 'civicrm', + 'js' => ['bower_components/angular-sanitize/angular-sanitize.min.js'], +]; diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/ui.bootstrap.ang.php b/www/crm/wp-content/plugins/civicrm/civicrm/ang/ui.bootstrap.ang.php new file mode 100644 index 00000000..417d7bf6 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/ui.bootstrap.ang.php @@ -0,0 +1,11 @@ +<?php +// This file declares an Angular module which can be autoloaded +// in CiviCRM. See also: +// http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_angularModules + +return [ + 'ext' => 'civicrm', + 'basePages' => [], + 'js' => ['bower_components/angular-bootstrap/ui-bootstrap-tpls.min.js'], + 'css' => ['bower_components/angular-bootstrap/ui-bootstrap-csp.css', 'ang/ui.bootstrap.css'], +]; diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/ui.bootstrap.css b/www/crm/wp-content/plugins/civicrm/civicrm/ang/ui.bootstrap.css new file mode 100644 index 00000000..e8cf3de9 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/ui.bootstrap.css @@ -0,0 +1 @@ +.nav, .pagination, .carousel, .panel-title a { cursor: pointer; } diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/ui.sortable.ang.php b/www/crm/wp-content/plugins/civicrm/civicrm/ang/ui.sortable.ang.php new file mode 100644 index 00000000..9679e6e6 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/ui.sortable.ang.php @@ -0,0 +1,9 @@ +<?php +// This file declares an Angular module which can be autoloaded +// in CiviCRM. See also: +// http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_angularModules + +return [ + 'ext' => 'civicrm', + 'js' => ['bower_components/angular-ui-sortable/sortable.min.js'], +]; diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/ui.utils.ang.php b/www/crm/wp-content/plugins/civicrm/civicrm/ang/ui.utils.ang.php new file mode 100644 index 00000000..58b798a4 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/ui.utils.ang.php @@ -0,0 +1,9 @@ +<?php +// This file declares an Angular module which can be autoloaded +// in CiviCRM. See also: +// http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_angularModules + +return [ + 'ext' => 'civicrm', + 'js' => ['bower_components/angular-ui-utils/ui-utils.min.js'], +]; diff --git a/www/crm/wp-content/plugins/civicrm/civicrm/ang/unsavedChanges.ang.php b/www/crm/wp-content/plugins/civicrm/civicrm/ang/unsavedChanges.ang.php new file mode 100644 index 00000000..f8822cd2 --- /dev/null +++ b/www/crm/wp-content/plugins/civicrm/civicrm/ang/unsavedChanges.ang.php @@ -0,0 +1,9 @@ +<?php +// This file declares an Angular module which can be autoloaded +// in CiviCRM. See also: +// http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_angularModules + +return [ + 'ext' => 'civicrm', + 'js' => ['bower_components/angular-unsavedChanges/dist/unsavedChanges.min.js'], +]; |