(function($, _) { if (!CRM.UF) CRM.UF = {}; var YESNO = [ {val: 0, label: ts('No')}, {val: 1, label: ts('Yes')} ]; var VISIBILITY = [ {val: 'User and User Admin Only', label: ts('User and User Admin Only'), isInSelectorAllowed: false}, {val: 'Public Pages', label: ts('Expose Publicly'), isInSelectorAllowed: true}, {val: 'Public Pages and Listings', label: ts('Expose Publicly and for Listings'), isInSelectorAllowed: true} ]; var LOCATION_TYPES = _.map(CRM.PseudoConstant.locationType, function(value, key) { return {val: key, label: value}; }); LOCATION_TYPES.unshift({val: '', label: ts('Primary')}); var DEFAULT_LOCATION_TYPE_ID = ''; var PHONE_TYPES = _.map(CRM.PseudoConstant.phoneType, function(value, key) { return {val: key, label: value}; }); var WEBSITE_TYPES = _.map(CRM.PseudoConstant.websiteType, function(value, key) { return {val: key, label: value}; }); var DEFAULT_PHONE_TYPE_ID = PHONE_TYPES[0].val; var DEFAULT_WEBSITE_TYPE_ID = WEBSITE_TYPES[0].val; /** * Add a help link to a form label */ function addHelp(title, options) { return title + ' '; } function watchChanges() { CRM.designerApp.vent.trigger('ufUnsaved', true); } /** * Parse a "group_type" expression * * @param string groupTypeExpr example: "Individual,Activity\0ActivityType:2:28" * Note: I've seen problems where HTML "�" != JS '\0', so we support ';;' as an equivalent delimiter * @return Object example: {coreTypes: {"Individual":true,"Activity":true}, subTypes: {"ActivityType":{2: true, 28:true}]}} */ CRM.UF.parseTypeList = function(groupTypeExpr) { var typeList = {coreTypes: {}, subTypes:{}}; // The API may have automatically converted a string with '\0' to an array var parts = _.isArray(groupTypeExpr) ? groupTypeExpr : groupTypeExpr.replace(';;','\0').split('\0'); var coreTypesExpr = parts[0]; var subTypesExpr = parts[1]; if (!_.isEmpty(coreTypesExpr)) { _.each(coreTypesExpr.split(','), function(coreType){ typeList.coreTypes[coreType] = true; }); } //CRM-15427 Allow Multiple subtype filtering if (!_.isEmpty(subTypesExpr)) { if (subTypesExpr.indexOf(';;') !== -1) { var subTypeparts = subTypesExpr.replace(/;;/g,'\0').split('\0'); _.each(subTypeparts, function(subTypepart) { var subTypes = subTypepart.split(':'); var subTypeKey = subTypes.shift(); typeList.subTypes[subTypeKey] = {}; _.each(subTypes, function(subTypeId) { typeList.subTypes[subTypeKey][subTypeId] = true; }); }); } else { var subTypes = subTypesExpr.split(':'); var subTypeKey = subTypes.shift(); typeList.subTypes[subTypeKey] = {}; _.each(subTypes, function(subTypeId) { typeList.subTypes[subTypeKey][subTypeId] = true; }); } } return typeList; }; /** * This function is a hack for generating simulated values of "entity_name" * in the form-field model. * * @param {string} field_type * @return {string} */ CRM.UF.guessEntityName = function(field_type) { switch (field_type) { case 'Contact': case 'Individual': case 'Organization': case 'Household': case 'Formatting': return 'contact_1'; case 'Activity': return 'activity_1'; case 'Contribution': return 'contribution_1'; case 'Membership': return 'membership_1'; case 'Participant': return 'participant_1'; case 'Case': return 'case_1'; default: if (CRM.contactSubTypes.length && ($.inArray(field_type,CRM.contactSubTypes) > -1)) { return 'contact_1'; } else { throw "Cannot guess entity name for field_type=" + field_type; } } }; /** * Represents a field in a customizable form. */ CRM.UF.UFFieldModel = CRM.Backbone.Model.extend({ /** * Backbone.Form description of the field to which this refers */ defaults: { help_pre: '', help_post: '', /** * @var bool, non-persistent indication of whether this field is unique or duplicate * within its UFFieldCollection */ is_duplicate: false }, schema: { 'id': { type: 'Number' }, 'uf_group_id': { type: 'Number' }, 'entity_name': { // pseudo-field type: 'Text' }, 'field_name': { type: 'Text' }, 'field_type': { type: 'Select', options: ['Contact', 'Individual', 'Organization', 'Contribution', 'Membership', 'Participant', 'Activity'] }, 'help_post': { title: addHelp(ts('Field Post Help'), {id: "help", file:"CRM/UF/Form/Field"}), type: 'TextArea' }, 'help_pre': { title: addHelp(ts('Field Pre Help'), {id: "help", file:"CRM/UF/Form/Field"}), type: 'TextArea' }, 'in_selector': { title: addHelp(ts('Results Columns?'), {id: "in_selector", file:"CRM/UF/Form/Field"}), type: 'Select', options: YESNO }, 'is_active': { title: addHelp(ts('Active?'), {id: "is_active", file:"CRM/UF/Form/Field"}), type: 'Select', options: YESNO }, 'is_multi_summary': { title: ts("Include in multi-record listing?"), type: 'Select', options: YESNO }, 'is_required': { title: addHelp(ts('Required?'), {id: "is_required", file:"CRM/UF/Form/Field"}), type: 'Select', options: YESNO }, 'is_reserved': { type: 'Select', options: YESNO }, 'is_searchable': { title: addHelp(ts("Searchable"), {id: "is_searchable", file:"CRM/UF/Form/Field"}), type: 'Select', options: YESNO }, 'is_view': { title: addHelp(ts('View Only?'), {id: "is_view", file:"CRM/UF/Form/Field"}), type: 'Select', options: YESNO }, 'label': { title: ts('Field Label'), type: 'Text', editorAttrs: {maxlength: 255} }, 'location_type_id': { title: ts('Location Type'), type: 'Select', options: LOCATION_TYPES }, 'website_type_id': { title: ts('Website Type'), type: 'Select', options: WEBSITE_TYPES }, 'phone_type_id': { title: ts('Phone Type'), type: 'Select', options: PHONE_TYPES }, 'visibility': { title: addHelp(ts('Visibility'), {id: "visibility", file:"CRM/UF/Form/Field"}), type: 'Select', options: VISIBILITY }, 'weight': { type: 'Number' } }, initialize: function() { if (this.get('field_name').indexOf('formatting') === 0) { this.schema.help_pre.title = ts('Markup'); } this.set('entity_name', CRM.UF.guessEntityName(this.get('field_type'))); this.on("rel:ufGroupModel", this.applyDefaults, this); this.on('change', watchChanges); }, applyDefaults: function() { var fieldSchema = this.getFieldSchema(); if (fieldSchema && fieldSchema.civiIsLocation && !this.get('location_type_id')) { this.set('location_type_id', DEFAULT_LOCATION_TYPE_ID); } if (fieldSchema && fieldSchema.civiIsWebsite && !this.get('website_type_id')) { this.set('website_type_id', DEFAULT_WEBSITE_TYPE_ID); } if (fieldSchema && fieldSchema.civiIsPhone && !this.get('phone_type_id')) { this.set('phone_type_id', DEFAULT_PHONE_TYPE_ID); } }, isInSelectorAllowed: function() { var visibility = _.first(_.where(VISIBILITY, {val: this.get('visibility')})); if (visibility) { return visibility.isInSelectorAllowed; } else { return false; } }, getFieldSchema: function() { return this.getRel('ufGroupModel').getFieldSchema(this.get('entity_name'), this.get('field_name')); }, /** * Create a uniqueness signature. Ideally, each UFField in a UFGroup should * have a unique signature. * * @return {String} */ getSignature: function() { return this.get("entity_name") + '::' + this.get("field_name") + '::' + (this.get("location_type_id") ? this.get("location_type_id") : this.get("website_type_id") ? this.get("website_type_id") : '') + '::' + (this.get("phone_type_id") ? this.get("phone_type_id") : ''); }, /** * This is like destroy(), but it only destroys the item on the client-side; * it does not trigger REST or Backbone.sync() operations. * * @return {Boolean} */ destroyLocal: function() { this.trigger('destroy', this, this.collection, {}); return false; } }); /** * Represents a list of fields in a customizable form * * options: * - uf_group_id: int */ CRM.UF.UFFieldCollection = CRM.Backbone.Collection.extend({ model: CRM.UF.UFFieldModel, uf_group_id: null, // int initialize: function(models, options) { options = options || {}; this.uf_group_id = options.uf_group_id; this.initializeCopyToChildrenRelation('ufGroupModel', options.ufGroupModel, models); this.on('add', this.watchDuplicates, this); this.on('remove', this.unwatchDuplicates, this); this.on('change', watchChanges); this.on('add', watchChanges); this.on('remove', watchChanges); }, getFieldsByName: function(entityName, fieldName) { return this.filter(function(ufFieldModel) { return (ufFieldModel.get('entity_name') == entityName && ufFieldModel.get('field_name') == fieldName); }); }, toSortedJSON: function() { var fields = this.map(function(ufFieldModel){ return ufFieldModel.toStrictJSON(); }); return _.sortBy(fields, function(ufFieldJSON){ return parseInt(ufFieldJSON.weight); }); }, isAddable: function(ufFieldModel) { var entity_name = ufFieldModel.get('entity_name'), field_name = ufFieldModel.get('field_name'), fieldSchema = this.getRel('ufGroupModel').getFieldSchema(ufFieldModel.get('entity_name'), ufFieldModel.get('field_name')); if (field_name.indexOf('formatting') === 0) { return true; } if (! fieldSchema) { return false; } var fields = this.getFieldsByName(entity_name, field_name); var limit = 1; if (fieldSchema.civiIsLocation) { limit *= LOCATION_TYPES.length; } if (fieldSchema.civiIsWebsite) { limit *= WEBSITE_TYPES.length; } if (fieldSchema.civiIsPhone) { limit *= PHONE_TYPES.length; } return fields.length < limit; }, watchDuplicates: function(model, collection, options) { model.on('change:location_type_id', this.markDuplicates, this); model.on('change:website_type_id', this.markDuplicates, this); model.on('change:phone_type_id', this.markDuplicates, this); this.markDuplicates(); }, unwatchDuplicates: function(model, collection, options) { model.off('change:location_type_id', this.markDuplicates, this); model.off('change:website_type_id', this.markDuplicates, this); model.off('change:phone_type_id', this.markDuplicates, this); this.markDuplicates(); }, hasDuplicates: function() { var firstDupe = this.find(function(ufFieldModel){ return ufFieldModel.get('is_duplicate'); }); return firstDupe ? true : false; }, /** * */ markDuplicates: function() { var ufFieldModelsByKey = this.groupBy(function(ufFieldModel) { return ufFieldModel.getSignature(); }); this.each(function(ufFieldModel){ var is_duplicate = ufFieldModelsByKey[ufFieldModel.getSignature()].length > 1; if (is_duplicate != ufFieldModel.get('is_duplicate')) { ufFieldModel.set('is_duplicate', is_duplicate); } }); } }); /** * Represents an entity in a customizable form */ CRM.UF.UFEntityModel = CRM.Backbone.Model.extend({ schema: { 'id': { // title: ts(''), type: 'Number' }, 'entity_name': { title: ts('Entity Name'), help: ts('Symbolic name which referenced in the fields'), type: 'Text' }, 'entity_type': { title: ts('Entity Type'), type: 'Select', options: ['IndividualModel', 'ActivityModel'] }, 'entity_sub_type': { // Use '*' to match all subtypes; use an int to match a specific type id; use empty-string to match none title: ts('Sub Type'), type: 'Text' } }, defaults: { entity_sub_type: '*' }, initialize: function() { }, /** * Get a list of all fields that can be used with this entity. * * @return {Object} keys are field names; values are fieldSchemas */ getFieldSchemas: function() { var ufEntityModel = this; var modelClass= this.getModelClass(); if (this.get('entity_sub_type') == '*') { return _.clone(modelClass.prototype.schema); } var result = {}; _.each(modelClass.prototype.schema, function(fieldSchema, fieldName){ var section = modelClass.prototype.sections[fieldSchema.section]; if (ufEntityModel.isSectionEnabled(section)) { result[fieldName] = fieldSchema; } }); return result; }, isSectionEnabled: function(section) { //CRM-15427 return (!section || !section.extends_entity_column_value || _.contains(section.extends_entity_column_value, this.get('entity_sub_type')) || this.get('entity_sub_type') == '*'); }, getSections: function() { var ufEntityModel = this; var result = {}; _.each(ufEntityModel.getModelClass().prototype.sections, function(section, sectionKey){ if (ufEntityModel.isSectionEnabled(section)) { result[sectionKey] = section; } }); return result; }, getModelClass: function() { return CRM.Schema[this.get('entity_type')]; } }); /** * Represents a list of entities in a customizable form * * options: * - ufGroupModel: UFGroupModel */ CRM.UF.UFEntityCollection = CRM.Backbone.Collection.extend({ model: CRM.UF.UFEntityModel, byName: {}, initialize: function(models, options) { options = options || {}; this.initializeCopyToChildrenRelation('ufGroupModel', options.ufGroupModel, models); }, /** * * @param name * @return {UFEntityModel} if found; otherwise, null */ getByName: function(name) { // TODO consider indexing return this.find(function(ufEntityModel){ return ufEntityModel.get('entity_name') == name; }); } }); /** * Represents a customizable form */ CRM.UF.UFGroupModel = CRM.Backbone.Model.extend({ defaults: { title: ts('Unnamed Profile'), is_active: 1 }, schema: { 'id': { // title: ts(''), type: 'Number' }, 'name': { // title: ts(''), type: 'Text' }, 'title': { title: ts('Profile Name'), help: ts(''), type: 'Text', editorAttrs: {maxlength: 64}, validators: ['required'] }, 'group_type': { // For a description of group_type, see CRM_Core_BAO_UFGroup::updateGroupTypes // title: ts(''), type: 'Text' }, 'add_captcha': { title: ts('Include reCAPTCHA?'), help: ts('FIXME'), type: 'Select', options: YESNO }, 'add_to_group_id': { title: ts('Add new contacts to a Group?'), help: ts('Select a group if you are using this profile for adding new contacts, AND you want the new contacts to be automatically assigned to a group.'), type: 'Number' }, 'cancel_URL': { title: ts('Cancel Redirect URL'), help: ts('If you are using this profile as a contact signup or edit form, and want to redirect the user to a static URL if they click the Cancel button - enter the complete URL here. If this field is left blank, the built-in Profile form will be redisplayed.'), type: 'Text' }, 'created_date': { //title: ts(''), type: 'Text'// FIXME }, 'created_id': { //title: ts(''), type: 'Number' }, 'help_post': { title: ts('Post-form Help'), help: ts('Explanatory text displayed at the end of the form.') + ts('Note that this help text is displayed on profile create/edit screens only.'), type: 'TextArea' }, 'help_pre': { title: ts('Pre-form Help'), help: ts('Explanatory text displayed at the beginning of the form.') + ts('Note that this help text is displayed on profile create/edit screens only.'), type: 'TextArea' }, 'is_active': { title: ts('Is this CiviCRM Profile active?'), type: 'Select', options: YESNO }, 'is_cms_user': { title: ts('Drupal user account registration option?'),// FIXME help: ts('FIXME'), type: 'Select', options: YESNO // FIXME }, 'is_edit_link': { title: ts('Include profile edit links in search results?'), help: ts('Check this box if you want to include a link in the listings to Edit profile fields. Only users with permission to edit the contact will see this link.'), type: 'Select', options: YESNO }, 'is_map': { title: ts('Enable mapping for this profile?'), help: ts('If enabled, a Map link is included on the profile listings rows and detail screens for any contacts whose records include sufficient location data for your mapping provider.'), type: 'Select', options: YESNO }, 'is_proximity_search': { title: ts('Proximity Search'), help: ts('FIXME'), type: 'Select', options: YESNO // FIXME }, 'is_reserved': { // title: ts(''), type: 'Select', options: YESNO }, 'is_uf_link': { title: ts('Include Drupal user account information links in search results?'), // FIXME help: ts('FIXME'), type: 'Select', options: YESNO }, 'is_update_dupe': { title: ts('What to do upon duplicate match'), help: ts('FIXME'), type: 'Select', options: YESNO // FIXME }, 'limit_listings_group_id': { title: ts('Limit listings to a specific Group?'), help: ts('Select a group if you are using this profile for search and listings, AND you want to limit the listings to members of a specific group.'), type: 'Number' }, 'notify': { title: ts('Notify when profile form is submitted?'), help: ts('If you want member(s) of your organization to receive a notification email whenever this Profile form is used to enter or update contact information, enter one or more email addresses here. Multiple email addresses should be separated by a comma (e.g. jane@example.org, paula@example.org). The first email address listed will be used as the FROM address in the notifications.'), type: 'TextArea' }, 'post_URL': { title: ts('Redirect URL'), help: ts("If you are using this profile as a contact signup or edit form, and want to redirect the user to a static URL after they've submitted the form, you can also use contact tokens in URL - enter the complete URL here. If this field is left blank, the built-in Profile form will be redisplayed with a generic status message - 'Your contact information has been saved.'"), type: 'Text' }, 'weight': { title: ts('Order'), help: ts('Weight controls the order in which profiles are presented when more than one profile is included in User Registration or My Account screens. Enter a positive or negative integer - lower numbers are displayed ahead of higher numbers.'), type: 'Number' // FIXME positive int } }, initialize: function() { var ufGroupModel = this; if (!this.getRel('ufEntityCollection')) { var ufEntityCollection = new CRM.UF.UFEntityCollection([], { ufGroupModel: this, silent: false }); this.setRel('ufEntityCollection', ufEntityCollection); } if (!this.getRel('ufFieldCollection')) { var ufFieldCollection = new CRM.UF.UFFieldCollection([], { uf_group_id: this.id, ufGroupModel: this }); this.setRel('ufFieldCollection', ufFieldCollection); } if (!this.getRel('paletteFieldCollection')) { var paletteFieldCollection = new CRM.Designer.PaletteFieldCollection([], { ufGroupModel: this }); paletteFieldCollection.sync = function(method, model, options) { if (!options) options = {}; // console.log(method, model, options); switch (method) { case 'read': var success = options.success; options.success = function(resp, status, xhr) { if (success) success(resp, status, xhr); model.trigger('sync', model, resp, options); }; success(ufGroupModel.buildPaletteFields()); break; case 'create': case 'update': case 'delete': throw 'Unsupported method: ' + method; default: throw 'Unsupported method: ' + method; } }; this.setRel('paletteFieldCollection', paletteFieldCollection); } this.getRel('ufEntityCollection').on('reset', this.resetEntities, this); this.resetEntities(); this.on('change', watchChanges); }, /** * Generate a copy of this UFGroupModel and its fields, with all ID's removed. The result * is suitable for a new, identical UFGroup. * * @return {CRM.UF.UFGroupModel} */ deepCopy: function() { var copy = new CRM.UF.UFGroupModel(_.omit(this.toStrictJSON(), ['id','created_id','created_date','is_reserved','group_type'])); copy.getRel('ufEntityCollection').reset( this.getRel('ufEntityCollection').toJSON() // FIXME: for configurable entities, omit ['id', 'uf_group_id'] ); copy.getRel('ufFieldCollection').reset( this.getRel('ufFieldCollection').map(function(ufFieldModel) { return _.omit(ufFieldModel.toStrictJSON(), ['id', 'uf_group_id']); }) ); var copyLabel = ' ' + ts('(Copy)'); copy.set('title', copy.get('title').slice(0, 64 - copyLabel.length) + copyLabel); return copy; }, getModelClass: function(entity_name) { var ufEntity = this.getRel('ufEntityCollection').getByName(entity_name); if (!ufEntity) throw 'Failed to locate entity: ' + entity_name; return ufEntity.getModelClass(); }, getFieldSchema: function(entity_name, field_name) { if (field_name.indexOf('formatting') === 0) { field_name = 'formatting'; } var modelClass = this.getModelClass(entity_name); var fieldSchema = modelClass.prototype.schema[field_name]; if (!fieldSchema) { CRM.console('warn', 'Failed to locate field: ' + entity_name + "." + field_name); return null; } return fieldSchema; }, /** * Check that the group_type contains *only* the types listed in validTypes * * @param string validTypesExpr * @param bool allowAllSubtypes * @return {Boolean} */ //CRM-15427 checkGroupType: function(validTypesExpr, allowAllSubtypes, usedByFilter) { var allMatched = true; allowAllSubtypes = allowAllSubtypes || false; usedByFilter = usedByFilter || null; if (_.isEmpty(this.get('group_type'))) { return true; } if (usedByFilter && _.isEmpty(this.get('module'))) { return false; } var actualTypes = CRM.UF.parseTypeList(this.get('group_type')); var validTypes = CRM.UF.parseTypeList(validTypesExpr); // Every actual.coreType is a valid.coreType _.each(actualTypes.coreTypes, function(ignore, actualCoreType) { if (! validTypes.coreTypes[actualCoreType]) { allMatched = false; } }); // CRM-16915 - filter with usedBy module if specified. if (usedByFilter && this.get('module') != usedByFilter) { allMatched = false; } //CRM-15427 allow all subtypes if (!$.isEmptyObject(validTypes.subTypes) && !allowAllSubtypes) { // Every actual.subType is a valid.subType _.each(actualTypes.subTypes, function(actualSubTypeIds, actualSubTypeKey) { if (!validTypes.subTypes[actualSubTypeKey]) { allMatched = false; return; } // actualSubTypeIds is a list of all subtypes which can be used by group, // so it's sufficient to match any one of them var subTypeMatched = false; _.each(actualSubTypeIds, function(ignore, actualSubTypeId) { if (validTypes.subTypes[actualSubTypeKey][actualSubTypeId]) { subTypeMatched = true; } }); allMatched = allMatched && subTypeMatched; }); } return allMatched; }, calculateContactEntityType: function() { var ufGroupModel = this; // set proper entity model based on selected profile var contactTypes = ['Individual', 'Household', 'Organization']; var profileType = ufGroupModel.get('group_type') || ''; // check if selected profile have subtype defined eg: ["Individual,Contact,Case", "caseType:7"] if (_.isArray(profileType) && profileType[0]) { profileType = profileType[0]; } profileType = profileType.split(','); var ufEntityModel; _.each(profileType, function (ptype) { if ($.inArray(ptype, contactTypes) > -1) { ufEntityModel = ptype + 'Model'; return true; } }); return ufEntityModel; }, setUFGroupModel: function(entityType, allEntityModels) { var ufGroupModel = this; var newUfEntityModels = []; _.each(allEntityModels, function (values) { if (entityType && values.entity_name == 'contact_1') { values.entity_type = entityType; } newUfEntityModels.push(new CRM.UF.UFEntityModel(values)); }); ufGroupModel.getRel('ufEntityCollection').reset(newUfEntityModels); }, resetEntities: function() { var ufGroupModel = this; var deleteFieldList = []; ufGroupModel.getRel('ufFieldCollection').each(function(ufFieldModel){ if (!ufFieldModel.getFieldSchema()) { CRM.alert(ts('This profile no longer includes field "%1"! All references to the field have been removed.', { 1: ufFieldModel.get('label') }), '', 'alert', {expires: false}); deleteFieldList.push(ufFieldModel); } }); _.each(deleteFieldList, function(ufFieldModel) { ufFieldModel.destroyLocal(); }); this.getRel('paletteFieldCollection').reset(this.buildPaletteFields()); // reset to redraw the cancel after entity type is updated. ufGroupModel.getRel('ufFieldCollection').reset(ufGroupModel.getRel('ufFieldCollection').toJSON()); }, /** * * @return {Array} of PaletteFieldModel */ buildPaletteFields: function() { // rebuild list of fields; reuse old instances of PaletteFieldModel and create new ones // as appropriate // Note: The system as a whole is ill-defined in cases where we have an existing // UFField that references a model field that disappears. var ufGroupModel = this; var oldPaletteFieldModelsBySig = {}; this.getRel('paletteFieldCollection').each(function(paletteFieldModel){ oldPaletteFieldModelsBySig[paletteFieldModel.get("entityName") + '::' + paletteFieldModel.get("fieldName")] = paletteFieldModel; }); var newPaletteFieldModels = []; this.getRel('ufEntityCollection').each(function(ufEntityModel){ var modelClass = ufEntityModel.getModelClass(); _.each(ufEntityModel.getFieldSchemas(), function(value, key, list) { var model = oldPaletteFieldModelsBySig[ufEntityModel.get('entity_name') + '::' + key]; if (!model) { model = new CRM.Designer.PaletteFieldModel({ modelClass: modelClass, entityName: ufEntityModel.get('entity_name'), fieldName: key }); } newPaletteFieldModels.push(model); }); }); return newPaletteFieldModels; } }); /** * Represents a list of customizable form */ CRM.UF.UFGroupCollection = CRM.Backbone.Collection.extend({ model: CRM.UF.UFGroupModel }); })(CRM.$, CRM._);