2 if (!CRM
.UF
) CRM
.UF
= {};
5 {val
: 0, label
: ts('No')},
6 {val
: 1, label
: ts('Yes')}
10 {val
: 'User and User Admin Only', label
: ts('User and User Admin Only'), isInSelectorAllowed
: false},
11 {val
: 'Public Pages', label
: ts('Expose Publicly'), isInSelectorAllowed
: true},
12 {val
: 'Public Pages and Listings', label
: ts('Expose Publicly and for Listings'), isInSelectorAllowed
: true}
15 var LOCATION_TYPES
= _
.map(CRM
.PseudoConstant
.locationType
, function(value
, key
) {
16 return {val
: key
, label
: value
};
18 LOCATION_TYPES
.unshift({val
: '', label
: ts('Primary')});
19 var DEFAULT_LOCATION_TYPE_ID
= '';
21 var PHONE_TYPES
= _
.map(CRM
.PseudoConstant
.phoneType
, function(value
, key
) {
22 return {val
: key
, label
: value
};
25 var WEBSITE_TYPES
= _
.map(CRM
.PseudoConstant
.websiteType
, function(value
, key
) {
26 return {val
: key
, label
: value
};
28 var DEFAULT_PHONE_TYPE_ID
= PHONE_TYPES
[0].val
;
29 var DEFAULT_WEBSITE_TYPE_ID
= WEBSITE_TYPES
[0].val
;
32 * Add a help link to a form label
34 function addHelp(title
, options
) {
35 return title
+ ' <a href="#" onclick=\'CRM.help("' + title
+ '", ' + JSON
.stringify(options
) + '); return false;\' title="' + ts('%1 Help', {1: title
}) + '" class="helpicon"></a>';
38 function watchChanges() {
39 CRM
.designerApp
.vent
.trigger('ufUnsaved', true);
43 * Parse a "group_type" expression
45 * @param string groupTypeExpr example: "Individual,Activity\0ActivityType:2:28"
46 * Note: I've seen problems where HTML "�" != JS '\0', so we support ';;' as an equivalent delimiter
47 * @return Object example: {coreTypes: {"Individual":true,"Activity":true}, subTypes: {"ActivityType":{2: true, 28:true}]}}
49 CRM
.UF
.parseTypeList = function(groupTypeExpr
) {
50 var typeList
= {coreTypes
: {}, subTypes
:{}};
51 // The API may have automatically converted a string with '\0' to an array
52 var parts
= _
.isArray(groupTypeExpr
) ? groupTypeExpr
: groupTypeExpr
.replace(';;','\0').split('\0');
53 var coreTypesExpr
= parts
[0];
54 var subTypesExpr
= parts
[1];
56 if (!_
.isEmpty(coreTypesExpr
)) {
57 _
.each(coreTypesExpr
.split(','), function(coreType
){
58 typeList
.coreTypes
[coreType
] = true;
62 //CRM-15427 Allow Multiple subtype filtering
63 if (!_
.isEmpty(subTypesExpr
)) {
64 if (subTypesExpr
.indexOf(';;') !== -1) {
65 var subTypeparts
= subTypesExpr
.replace(/;;/g,'\0').split('\0');
66 _
.each(subTypeparts
, function(subTypepart
) {
67 var subTypes
= subTypepart
.split(':');
68 var subTypeKey
= subTypes
.shift();
69 typeList
.subTypes
[subTypeKey
] = {};
70 _
.each(subTypes
, function(subTypeId
) {
71 typeList
.subTypes
[subTypeKey
][subTypeId
] = true;
76 var subTypes
= subTypesExpr
.split(':');
77 var subTypeKey
= subTypes
.shift();
78 typeList
.subTypes
[subTypeKey
] = {};
79 _
.each(subTypes
, function(subTypeId
) {
80 typeList
.subTypes
[subTypeKey
][subTypeId
] = true;
88 * This function is a hack for generating simulated values of "entity_name"
89 * in the form-field model.
91 * @param {string} field_type
94 CRM
.UF
.guessEntityName = function(field_type
) {
105 return 'contribution_1';
107 return 'membership_1';
109 return 'participant_1';
113 if (!$.isEmptyObject(CRM
.contactSubTypes
) && ($.inArray(field_type
,CRM
.contactSubTypes
) > -1)) {
117 throw "Cannot guess entity name for field_type=" + field_type
;
123 * Represents a field in a customizable form.
125 CRM
.UF
.UFFieldModel
= CRM
.Backbone
.Model
.extend({
127 * Backbone.Form description of the field to which this refers
133 * @var bool, non-persistent indication of whether this field is unique or duplicate
134 * within its UFFieldCollection
154 options
: ['Contact', 'Individual', 'Organization', 'Contribution', 'Membership', 'Participant', 'Activity']
157 title
: addHelp(ts('Field Post Help'), {id
: "help", file
:"CRM/UF/Form/Field"}),
161 title
: addHelp(ts('Field Pre Help'), {id
: "help", file
:"CRM/UF/Form/Field"}),
165 title
: addHelp(ts('Results Columns?'), {id
: "in_selector", file
:"CRM/UF/Form/Field"}),
170 title
: addHelp(ts('Active?'), {id
: "is_active", file
:"CRM/UF/Form/Field"}),
174 'is_multi_summary': {
175 title
: ts("Include in multi-record listing?"),
180 title
: addHelp(ts('Required?'), {id
: "is_required", file
:"CRM/UF/Form/Field"}),
189 title
: addHelp(ts("Searchable"), {id
: "is_searchable", file
:"CRM/UF/Form/Field"}),
194 title
: addHelp(ts('View Only?'), {id
: "is_view", file
:"CRM/UF/Form/Field"}),
199 title
: ts('Field Label'),
201 editorAttrs
: {maxlength
: 255}
203 'location_type_id': {
204 title
: ts('Location Type'),
206 options
: LOCATION_TYPES
209 title
: ts('Website Type'),
211 options
: WEBSITE_TYPES
214 title
: ts('Phone Type'),
219 title
: addHelp(ts('Visibility'), {id
: "visibility", file
:"CRM/UF/Form/Field"}),
227 initialize: function() {
228 if (this.get('field_name').indexOf('formatting') === 0) {
229 this.schema
.help_pre
.title
= ts('Markup');
231 this.set('entity_name', CRM
.UF
.guessEntityName(this.get('field_type')));
232 this.on("rel:ufGroupModel", this.applyDefaults
, this);
233 this.on('change', watchChanges
);
235 applyDefaults: function() {
236 var fieldSchema
= this.getFieldSchema();
237 if (fieldSchema
&& fieldSchema
.civiIsLocation
&& !this.get('location_type_id')) {
238 this.set('location_type_id', DEFAULT_LOCATION_TYPE_ID
);
240 if (fieldSchema
&& fieldSchema
.civiIsWebsite
&& !this.get('website_type_id')) {
241 this.set('website_type_id', DEFAULT_WEBSITE_TYPE_ID
);
243 if (fieldSchema
&& fieldSchema
.civiIsPhone
&& !this.get('phone_type_id')) {
244 this.set('phone_type_id', DEFAULT_PHONE_TYPE_ID
);
247 isInSelectorAllowed: function() {
248 var visibility
= _
.first(_
.where(VISIBILITY
, {val
: this.get('visibility')}));
250 return visibility
.isInSelectorAllowed
;
256 getFieldSchema: function() {
257 return this.getRel('ufGroupModel').getFieldSchema(this.get('entity_name'), this.get('field_name'));
260 * Create a uniqueness signature. Ideally, each UFField in a UFGroup should
261 * have a unique signature.
265 getSignature: function() {
266 return this.get("entity_name") +
267 '::' + this.get("field_name") +
268 '::' + (this.get("location_type_id") ? this.get("location_type_id") : this.get("website_type_id") ? this.get("website_type_id") : '') +
269 '::' + (this.get("phone_type_id") ? this.get("phone_type_id") : '');
273 * This is like destroy(), but it only destroys the item on the client-side;
274 * it does not trigger REST or Backbone.sync() operations.
278 destroyLocal: function() {
279 this.trigger('destroy', this, this.collection
, {});
285 * Represents a list of fields in a customizable form
290 CRM
.UF
.UFFieldCollection
= CRM
.Backbone
.Collection
.extend({
291 model
: CRM
.UF
.UFFieldModel
,
292 uf_group_id
: null, // int
293 initialize: function(models
, options
) {
294 options
= options
|| {};
295 this.uf_group_id
= options
.uf_group_id
;
296 this.initializeCopyToChildrenRelation('ufGroupModel', options
.ufGroupModel
, models
);
297 this.on('add', this.watchDuplicates
, this);
298 this.on('remove', this.unwatchDuplicates
, this);
299 this.on('change', watchChanges
);
300 this.on('add', watchChanges
);
301 this.on('remove', watchChanges
);
303 getFieldsByName: function(entityName
, fieldName
) {
304 return this.filter(function(ufFieldModel
) {
305 return (ufFieldModel
.get('entity_name') == entityName
&& ufFieldModel
.get('field_name') == fieldName
);
308 toSortedJSON: function() {
309 var fields
= this.map(function(ufFieldModel
){
310 return ufFieldModel
.toStrictJSON();
312 return _
.sortBy(fields
, function(ufFieldJSON
){
313 return parseInt(ufFieldJSON
.weight
);
316 isAddable: function(ufFieldModel
) {
317 var entity_name
= ufFieldModel
.get('entity_name'),
318 field_name
= ufFieldModel
.get('field_name'),
319 fieldSchema
= this.getRel('ufGroupModel').getFieldSchema(ufFieldModel
.get('entity_name'), ufFieldModel
.get('field_name'));
320 if (field_name
.indexOf('formatting') === 0) {
326 var fields
= this.getFieldsByName(entity_name
, field_name
);
328 if (fieldSchema
.civiIsLocation
) {
329 limit
*= LOCATION_TYPES
.length
;
331 if (fieldSchema
.civiIsWebsite
) {
332 limit
*= WEBSITE_TYPES
.length
;
334 if (fieldSchema
.civiIsPhone
) {
335 limit
*= PHONE_TYPES
.length
;
337 return fields
.length
< limit
;
339 watchDuplicates: function(model
, collection
, options
) {
340 model
.on('change:location_type_id', this.markDuplicates
, this);
341 model
.on('change:website_type_id', this.markDuplicates
, this);
342 model
.on('change:phone_type_id', this.markDuplicates
, this);
343 this.markDuplicates();
345 unwatchDuplicates: function(model
, collection
, options
) {
346 model
.off('change:location_type_id', this.markDuplicates
, this);
347 model
.off('change:website_type_id', this.markDuplicates
, this);
348 model
.off('change:phone_type_id', this.markDuplicates
, this);
349 this.markDuplicates();
351 hasDuplicates: function() {
352 var firstDupe
= this.find(function(ufFieldModel
){
353 return ufFieldModel
.get('is_duplicate');
355 return firstDupe
? true : false;
360 markDuplicates: function() {
361 var ufFieldModelsByKey
= this.groupBy(function(ufFieldModel
) {
362 return ufFieldModel
.getSignature();
364 this.each(function(ufFieldModel
){
365 var is_duplicate
= ufFieldModelsByKey
[ufFieldModel
.getSignature()].length
> 1;
366 if (is_duplicate
!= ufFieldModel
.get('is_duplicate')) {
367 ufFieldModel
.set('is_duplicate', is_duplicate
);
374 * Represents an entity in a customizable form
376 CRM
.UF
.UFEntityModel
= CRM
.Backbone
.Model
.extend({
383 title
: ts('Entity Name'),
384 help
: ts('Symbolic name which referenced in the fields'),
388 title
: ts('Entity Type'),
390 options
: ['IndividualModel', 'ActivityModel']
393 // Use '*' to match all subtypes; use an int to match a specific type id; use empty-string to match none
394 title
: ts('Sub Type'),
401 initialize: function() {
404 * Get a list of all fields that can be used with this entity.
406 * @return {Object} keys are field names; values are fieldSchemas
408 getFieldSchemas: function() {
409 var ufEntityModel
= this;
410 var modelClass
= this.getModelClass();
412 if (this.get('entity_sub_type') == '*') {
413 return _
.clone(modelClass
.prototype.schema
);
417 _
.each(modelClass
.prototype.schema
, function(fieldSchema
, fieldName
){
418 var section
= modelClass
.prototype.sections
[fieldSchema
.section
];
419 if (ufEntityModel
.isSectionEnabled(section
)) {
420 result
[fieldName
] = fieldSchema
;
425 isSectionEnabled: function(section
) {
427 return (!section
|| !section
.extends_entity_column_value
|| _
.contains(section
.extends_entity_column_value
, this.get('entity_sub_type')) || this.get('entity_sub_type') == '*');
429 getSections: function() {
430 var ufEntityModel
= this;
432 _
.each(ufEntityModel
.getModelClass().prototype.sections
, function(section
, sectionKey
){
433 if (ufEntityModel
.isSectionEnabled(section
)) {
434 result
[sectionKey
] = section
;
439 getModelClass: function() {
440 return CRM
.Schema
[this.get('entity_type')];
445 * Represents a list of entities in a customizable form
448 * - ufGroupModel: UFGroupModel
450 CRM
.UF
.UFEntityCollection
= CRM
.Backbone
.Collection
.extend({
451 model
: CRM
.UF
.UFEntityModel
,
453 initialize: function(models
, options
) {
454 options
= options
|| {};
455 this.initializeCopyToChildrenRelation('ufGroupModel', options
.ufGroupModel
, models
);
460 * @return {UFEntityModel} if found; otherwise, null
462 getByName: function(name
) {
463 // TODO consider indexing
464 return this.find(function(ufEntityModel
){
465 return ufEntityModel
.get('entity_name') == name
;
471 * Represents a customizable form
473 CRM
.UF
.UFGroupModel
= CRM
.Backbone
.Model
.extend({
475 title
: ts('Unnamed Profile'),
488 title
: ts('Profile Name'),
491 editorAttrs
: {maxlength
: 64},
492 validators
: ['required']
495 // For a description of group_type, see CRM_Core_BAO_UFGroup::updateGroupTypes
500 title
: ts('Include reCAPTCHA?'),
506 title
: ts('Add new contacts to a Group?'),
507 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.'),
511 title
: ts('Cancel Redirect URL'),
512 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.'),
524 title
: ts('Post-form Help'),
525 help
: ts('Explanatory text displayed at the end of the form.') +
526 ts('Note that this help text is displayed on profile create/edit screens only.'),
530 title
: ts('Pre-form Help'),
531 help
: ts('Explanatory text displayed at the beginning of the form.') +
532 ts('Note that this help text is displayed on profile create/edit screens only.'),
536 title
: ts('Is this CiviCRM Profile active?'),
541 title
: ts('Drupal user account registration option?'),// FIXME
544 options
: YESNO
// FIXME
547 title
: ts('Include profile edit links in search results?'),
548 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.'),
553 title
: ts('Enable mapping for this profile?'),
554 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.'),
558 'is_proximity_search': {
559 title
: ts('Proximity Search'),
562 options
: YESNO
// FIXME
570 title
: ts('Include Drupal user account information links in search results?'), // FIXME
576 title
: ts('What to do upon duplicate match'),
579 options
: YESNO
// FIXME
581 'limit_listings_group_id': {
582 title
: ts('Limit listings to a specific Group?'),
583 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.'),
587 title
: ts('Notify when profile form is submitted?'),
588 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.'),
592 title
: ts('Redirect URL'),
593 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.'"),
598 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.'),
600 // FIXME positive int
603 initialize: function() {
604 var ufGroupModel
= this;
606 if (!this.getRel('ufEntityCollection')) {
607 var ufEntityCollection
= new CRM
.UF
.UFEntityCollection([], {
611 this.setRel('ufEntityCollection', ufEntityCollection
);
614 if (!this.getRel('ufFieldCollection')) {
615 var ufFieldCollection
= new CRM
.UF
.UFFieldCollection([], {
616 uf_group_id
: this.id
,
619 this.setRel('ufFieldCollection', ufFieldCollection
);
622 if (!this.getRel('paletteFieldCollection')) {
623 var paletteFieldCollection
= new CRM
.Designer
.PaletteFieldCollection([], {
626 paletteFieldCollection
.sync = function(method
, model
, options
) {
627 if (!options
) options
= {};
628 // console.log(method, model, options);
631 var success
= options
.success
;
632 options
.success = function(resp
, status
, xhr
) {
633 if (success
) success(resp
, status
, xhr
);
634 model
.trigger('sync', model
, resp
, options
);
636 success(ufGroupModel
.buildPaletteFields());
642 throw 'Unsupported method: ' + method
;
645 throw 'Unsupported method: ' + method
;
648 this.setRel('paletteFieldCollection', paletteFieldCollection
);
651 this.getRel('ufEntityCollection').on('reset', this.resetEntities
, this);
652 this.resetEntities();
654 this.on('change', watchChanges
);
657 * Generate a copy of this UFGroupModel and its fields, with all ID's removed. The result
658 * is suitable for a new, identical UFGroup.
660 * @return {CRM.UF.UFGroupModel}
662 deepCopy: function() {
663 var copy
= new CRM
.UF
.UFGroupModel(_
.omit(this.toStrictJSON(), ['id','created_id','created_date','is_reserved','group_type']));
664 copy
.getRel('ufEntityCollection').reset(
665 this.getRel('ufEntityCollection').toJSON()
666 // FIXME: for configurable entities, omit ['id', 'uf_group_id']
668 copy
.getRel('ufFieldCollection').reset(
669 this.getRel('ufFieldCollection').map(function(ufFieldModel
) {
670 return _
.omit(ufFieldModel
.toStrictJSON(), ['id', 'uf_group_id']);
673 var copyLabel
= ' ' + ts('(Copy)');
674 copy
.set('title', copy
.get('title').slice(0, 64 - copyLabel
.length
) + copyLabel
);
677 getModelClass: function(entity_name
) {
678 var ufEntity
= this.getRel('ufEntityCollection').getByName(entity_name
);
679 if (!ufEntity
) throw 'Failed to locate entity: ' + entity_name
;
680 return ufEntity
.getModelClass();
682 getFieldSchema: function(entity_name
, field_name
) {
683 if (field_name
.indexOf('formatting') === 0) {
684 field_name
= 'formatting';
686 var modelClass
= this.getModelClass(entity_name
);
687 var fieldSchema
= modelClass
.prototype.schema
[field_name
];
689 CRM
.console('warn', 'Failed to locate field: ' + entity_name
+ "." + field_name
);
695 * Check that the group_type contains *only* the types listed in validTypes
697 * @param string validTypesExpr
698 * @param bool allowAllSubtypes
702 checkGroupType: function(validTypesExpr
, allowAllSubtypes
, usedByFilter
) {
703 var allMatched
= true;
704 allowAllSubtypes
= allowAllSubtypes
|| false;
705 usedByFilter
= usedByFilter
|| null;
706 if (_
.isEmpty(this.get('group_type'))) {
709 if (usedByFilter
&& _
.isEmpty(this.get('module'))) {
713 var actualTypes
= CRM
.UF
.parseTypeList(this.get('group_type'));
714 var validTypes
= CRM
.UF
.parseTypeList(validTypesExpr
);
716 // Every actual.coreType is a valid.coreType
717 _
.each(actualTypes
.coreTypes
, function(ignore
, actualCoreType
) {
718 if (! validTypes
.coreTypes
[actualCoreType
]) {
723 // CRM-16915 - filter with usedBy module if specified.
724 if (usedByFilter
&& this.get('module') != usedByFilter
) {
727 //CRM-15427 allow all subtypes
728 if (!$.isEmptyObject(validTypes
.subTypes
) && !allowAllSubtypes
) {
729 // Every actual.subType is a valid.subType
730 _
.each(actualTypes
.subTypes
, function(actualSubTypeIds
, actualSubTypeKey
) {
731 if (!validTypes
.subTypes
[actualSubTypeKey
]) {
735 // actualSubTypeIds is a list of all subtypes which can be used by group,
736 // so it's sufficient to match any one of them
737 var subTypeMatched
= false;
738 _
.each(actualSubTypeIds
, function(ignore
, actualSubTypeId
) {
739 if (validTypes
.subTypes
[actualSubTypeKey
][actualSubTypeId
]) {
740 subTypeMatched
= true;
743 allMatched
= allMatched
&& subTypeMatched
;
748 calculateContactEntityType: function() {
749 var ufGroupModel
= this;
751 // set proper entity model based on selected profile
752 var contactTypes
= ['Individual', 'Household', 'Organization'];
753 var profileType
= ufGroupModel
.get('group_type') || '';
755 // check if selected profile have subtype defined eg: ["Individual,Contact,Case", "caseType:7"]
756 if (_
.isArray(profileType
) && profileType
[0]) {
757 profileType
= profileType
[0];
759 profileType
= profileType
.split(',');
762 _
.each(profileType
, function (ptype
) {
763 if ($.inArray(ptype
, contactTypes
) > -1) {
764 ufEntityModel
= ptype
+ 'Model';
769 return ufEntityModel
;
771 setUFGroupModel: function(entityType
, allEntityModels
) {
772 var ufGroupModel
= this;
774 var newUfEntityModels
= [];
775 _
.each(allEntityModels
, function (values
) {
776 if (entityType
&& values
.entity_name
== 'contact_1') {
777 values
.entity_type
= entityType
;
779 newUfEntityModels
.push(new CRM
.UF
.UFEntityModel(values
));
782 ufGroupModel
.getRel('ufEntityCollection').reset(newUfEntityModels
);
784 resetEntities: function() {
785 var ufGroupModel
= this;
786 var deleteFieldList
= [];
787 ufGroupModel
.getRel('ufFieldCollection').each(function(ufFieldModel
){
788 if (!ufFieldModel
.getFieldSchema()) {
789 CRM
.alert(ts('This profile no longer includes field "%1"! All references to the field have been removed.', {
790 1: ufFieldModel
.get('label')
791 }), '', 'alert', {expires
: false});
792 deleteFieldList
.push(ufFieldModel
);
796 _
.each(deleteFieldList
, function(ufFieldModel
) {
797 ufFieldModel
.destroyLocal();
800 this.getRel('paletteFieldCollection').reset(this.buildPaletteFields());
802 // reset to redraw the cancel after entity type is updated.
803 ufGroupModel
.getRel('ufFieldCollection').reset(ufGroupModel
.getRel('ufFieldCollection').toJSON());
807 * @return {Array} of PaletteFieldModel
809 buildPaletteFields: function() {
810 // rebuild list of fields; reuse old instances of PaletteFieldModel and create new ones
812 // Note: The system as a whole is ill-defined in cases where we have an existing
813 // UFField that references a model field that disappears.
815 var ufGroupModel
= this;
817 var oldPaletteFieldModelsBySig
= {};
818 this.getRel('paletteFieldCollection').each(function(paletteFieldModel
){
819 oldPaletteFieldModelsBySig
[paletteFieldModel
.get("entityName") + '::' + paletteFieldModel
.get("fieldName")] = paletteFieldModel
;
822 var newPaletteFieldModels
= [];
823 this.getRel('ufEntityCollection').each(function(ufEntityModel
){
824 var modelClass
= ufEntityModel
.getModelClass();
825 _
.each(ufEntityModel
.getFieldSchemas(), function(value
, key
, list
) {
826 var model
= oldPaletteFieldModelsBySig
[ufEntityModel
.get('entity_name') + '::' + key
];
828 model
= new CRM
.Designer
.PaletteFieldModel({
829 modelClass
: modelClass
,
830 entityName
: ufEntityModel
.get('entity_name'),
834 newPaletteFieldModels
.push(model
);
838 return newPaletteFieldModels
;
843 * Represents a list of customizable form
845 CRM
.UF
.UFGroupCollection
= CRM
.Backbone
.Collection
.extend({
846 model
: CRM
.UF
.UFGroupModel