/** * A control that allows selection of multiple items in a list. */Ext.define('Ext.ux.form.MultiSelect', { extend: 'Ext.form.FieldContainer', mixins: [ 'Ext.util.StoreHolder', 'Ext.form.field.Field' ], alternateClassName: 'Ext.ux.Multiselect', alias: ['widget.multiselectfield', 'widget.multiselect'], requires: ['Ext.panel.Panel', 'Ext.view.BoundList', 'Ext.layout.container.Fit'], uses: ['Ext.view.DragZone', 'Ext.view.DropZone'], layout: 'anchor', /** * @cfg {String} [dragGroup=""] The ddgroup name for the MultiSelect DragZone. */ /** * @cfg {String} [dropGroup=""] The ddgroup name for the MultiSelect DropZone. */ /** * @cfg {String} [title=""] A title for the underlying panel. */ /** * @cfg {Boolean} [ddReorder=false] Whether the items in the MultiSelect list are drag/drop * reorderable. */ ddReorder: false, /** * @cfg {Object/Array} tbar An optional toolbar to be inserted at the top of the control's * selection list. This can be a {@link Ext.toolbar.Toolbar} object, a toolbar config, * or an array of buttons/button configs to be added to the toolbar. * See {@link Ext.panel.Panel#tbar}. */ /** * @cfg {String} [appendOnly=false] `true` if the list should only allow append drops * when drag/drop is enabled. This is useful for lists which are sorted. */ appendOnly: false, /** * @cfg {String} [displayField="text"] Name of the desired display field in the dataset. */ displayField: 'text', /** * @cfg {String} [valueField="text"] Name of the desired value field in the dataset. */ /** * @cfg {Boolean} [allowBlank=true] `false` to require at least one item in the list * to be selected, `true` to allow no selection. */ allowBlank: true, /** * @cfg {Number} [minSelections=0] Minimum number of selections allowed. */ minSelections: 0, /** * @cfg {Number} [maxSelections=Number.MAX_VALUE] Maximum number of selections allowed. */ maxSelections: Number.MAX_VALUE, /** * @cfg {String} [blankText="This field is required"] Default text displayed when the control * contains no items. */ blankText: 'This field is required', /** * @cfg {String} [minSelectionsText="Minimum {0}item(s) required"] * Validation message displayed when {@link #minSelections} is not met. * The {0} token will be replaced by the value of {@link #minSelections}. */ minSelectionsText: 'Minimum {0} item(s) required', /** * @cfg {String} [maxSelectionsText="Maximum {0}item(s) allowed"] * Validation message displayed when {@link #maxSelections} is not met * The {0} token will be replaced by the value of {@link #maxSelections}. */ maxSelectionsText: 'Maximum {0} item(s) required', /** * @cfg {String} [delimiter=","] The string used to delimit the selected values when * {@link #getSubmitValue submitting} the field as part of a form. If you wish to have * the selected values submitted as separate parameters rather than a single * delimited parameter, set this to `null`. */ delimiter: ',', /** * @cfg {String} [dragText="{0} Item{1}"] The text to show while dragging items. * {0} will be replaced by the number of items. {1} will be replaced by the plural * form if there is more than 1 item. */ dragText: '{0} Item{1}', /** * @cfg {Ext.data.Store/Array} store The data source to which this MultiSelect is bound * (defaults to `undefined`). * Acceptable values for this property are: * <div class="mdetail-params"><ul> * <li><b>any {@link Ext.data.Store Store} subclass</b></li> * <li><b>an Array</b> : Arrays will be converted to a {@link Ext.data.ArrayStore} internally. * <div class="mdetail-params"><ul> * <li><b>1-dimensional array</b> : (e.g., <tt>['Foo','Bar']</tt>)<div class="sub-desc"> * A 1-dimensional array will automatically be expanded (each array item will be the combo * {@link #valueField value} and {@link #displayField text})</div></li> * <li><b>2-dimensional array</b> : (e.g., `[['f','Foo'],['b','Bar']]`)<div class="sub-desc"> * For a multi-dimensional array, the value in index 0 of each item will be assumed to be * the combo {@link #valueField value}, while the value at index 1 is assumed to be the combo * {@link #displayField text}. * </div></li></ul></div></li></ul></div> */ ignoreSelectChange: 0, /** * @cfg {Object} listConfig * An optional set of configuration properties that will be passed to the * {@link Ext.view.BoundList}'s constructor. Any configuration that is valid for BoundList * can be included. */ /** * @cfg {Number} [pageSize=10] The number of items to advance on pageUp and pageDown */ pageSize: 10, initComponent: function() { var me = this; me.items = me.setupItems(); me.bindStore(me.store, true); me.callParent(); me.initField(); }, setupItems: function() { var me = this; me.boundList = new Ext.view.BoundList(Ext.apply({ anchor: 'none 100%', border: 1, multiSelect: true, store: me.store, displayField: me.displayField, disabled: me.disabled, tabIndex: 0, navigationModel: { type: 'default' } }, me.listConfig)); me.boundList.getNavigationModel().addKeyBindings({ pageUp: me.onKeyPageUp, pageDown: me.onKeyPageDown, scope: me }); me.boundList.getSelectionModel().on('selectionchange', me.onSelectChange, me); // Boundlist expects a reference to its pickerField for when an item is selected // (see Boundlist#onItemClick). me.boundList.pickerField = me; // Only need to wrap the BoundList in a Panel if we have a title. if (!me.title) { return me.boundList; } // Wrap to add a title me.boundList.border = false; return { xtype: 'panel', isAriaRegion: false, border: true, anchor: 'none 100%', layout: 'anchor', title: me.title, tbar: me.tbar, items: me.boundList }; }, onSelectChange: function(selModel, selections) { if (!this.ignoreSelectChange) { this.setValue(selections); } }, getSelected: function() { return this.boundList.getSelectionModel().getSelection(); }, // compare array values isEqual: function(v1, v2) { var fromArray = Ext.Array.from, i = 0, len; v1 = fromArray(v1); v2 = fromArray(v2); len = v1.length; if (len !== v2.length) { return false; } for (; i < len; i++) { if (v2[i] !== v1[i]) { return false; } } return true; }, afterRender: function() { var me = this, boundList, scrollable, records, panel; me.callParent(); boundList = me.boundList; scrollable = boundList && boundList.getScrollable(); if (me.selectOnRender) { records = me.getRecordsForValue(me.value); if (records.length) { ++me.ignoreSelectChange; boundList.getSelectionModel().select(records); --me.ignoreSelectChange; } delete me.toSelect; } if (me.ddReorder && !me.dragGroup && !me.dropGroup) { me.dragGroup = me.dropGroup = 'MultiselectDD-' + Ext.id(); } if (me.draggable || me.dragGroup) { me.dragZone = Ext.create('Ext.view.DragZone', { view: boundList, ddGroup: me.dragGroup, dragText: me.dragText, containerScroll: !!scrollable, scrollEl: scrollable && scrollable.getElement() }); } if (me.droppable || me.dropGroup) { me.dropZone = Ext.create('Ext.view.DropZone', { view: boundList, ddGroup: me.dropGroup, handleNodeDrop: function(data, dropRecord, position) { var view = this.view, store = view.getStore(), records = data.records, index; // remove the Models from the source Store data.view.store.remove(records); index = store.indexOf(dropRecord); if (position === 'after') { index++; } store.insert(index, records); view.getSelectionModel().select(records); me.fireEvent('drop', me, records); } }); } panel = me.down('panel'); if (panel && boundList) { boundList.ariaEl.dom.setAttribute('aria-labelledby', panel.header.id + '-title-textEl'); } }, onKeyPageUp: function(e) { var me = this, pageSize = me.pageSize, boundList = me.boundList, nm = boundList.getNavigationModel(), oldIdx, newIdx; oldIdx = nm.recordIndex; // Unlike up arrow, pgUp does not wrap but goes to the first item newIdx = oldIdx > pageSize ? oldIdx - pageSize : 0; nm.setPosition(newIdx, e); }, onKeyPageDown: function(e) { var me = this, pageSize = me.pageSize, boundList = me.boundList, nm = boundList.getNavigationModel(), count, oldIdx, newIdx; count = boundList.getStore().getCount(); oldIdx = nm.recordIndex; // Unlike down arrow, pgDown does not wrap but goes to the last item newIdx = oldIdx < (count - pageSize) ? oldIdx + pageSize : count - 1; nm.setPosition(newIdx, e); }, isValid: function() { var me = this, disabled = me.disabled, validate = me.forceValidation || !disabled; return validate ? me.validateValue(me.value) : disabled; }, validateValue: function(value) { var me = this, errors = me.getErrors(value), isValid = Ext.isEmpty(errors); if (!me.preventMark) { if (isValid) { me.clearInvalid(); } else { me.markInvalid(errors); } } return isValid; }, markInvalid: function(errors) { // Save the message and fire the 'invalid' event var me = this, oldMsg = me.getActiveError(); me.setActiveErrors(Ext.Array.from(errors)); if (oldMsg !== me.getActiveError()) { me.updateLayout(); } }, /** * Clear any invalid styles/messages for this field. * * __Note:__ this method does not cause the Field's {@link #validate} or {@link #isValid} * methods to return `true` if the value does not _pass_ validation. So simply clearing * a field's errors will not necessarily allow submission of forms submitted with the * {@link Ext.form.action.Submit#clientValidation} option set. */ clearInvalid: function() { // Clear the message and fire the 'valid' event var me = this, hadError = me.hasActiveError(); me.unsetActiveError(); if (hadError) { me.updateLayout(); } }, getSubmitData: function() { var me = this, data = null, val; if (!me.disabled && me.submitValue && !me.isFileUpload()) { val = me.getSubmitValue(); if (val !== null) { data = {}; data[me.getName()] = val; } } return data; }, /** * Returns the value that would be included in a standard form submit for this field. * * @return {String} The value to be submitted, or `null`. */ getSubmitValue: function() { var me = this, delimiter = me.delimiter, val = me.getValue(); return Ext.isString(delimiter) ? val.join(delimiter) : val; }, getValue: function() { return this.value || []; }, getRecordsForValue: function(value) { var me = this, records = [], all = me.store.getRange(), valueField = me.valueField, i = 0, allLen = all.length, rec, j, valueLen; for (valueLen = value.length; i < valueLen; ++i) { for (j = 0; j < allLen; ++j) { rec = all[j]; if (rec.get(valueField) === value[i]) { records.push(rec); } } } return records; }, setupValue: function(value) { var delimiter = this.delimiter, valueField = this.valueField, i = 0, out, len, item; if (Ext.isDefined(value)) { if (delimiter && Ext.isString(value)) { value = value.split(delimiter); } else if (!Ext.isArray(value)) { value = [value]; } for (len = value.length; i < len; ++i) { item = value[i]; if (item && item.isModel) { value[i] = item.get(valueField); } } out = Ext.Array.unique(value); } else { out = []; } return out; }, setValue: function(value) { var me = this, selModel = me.boundList.getSelectionModel(), store = me.store; // Store not loaded yet - we cannot set the value if (!store.getCount()) { store.on({ load: Ext.Function.bind(me.setValue, me, [value]), single: true }); return; } value = me.setupValue(value); me.mixins.field.setValue.call(me, value); if (me.rendered) { ++me.ignoreSelectChange; selModel.deselectAll(); if (value.length) { selModel.select(me.getRecordsForValue(value)); } --me.ignoreSelectChange; } else { me.selectOnRender = true; } }, clearValue: function() { this.setValue([]); }, onEnable: function() { var list = this.boundList; this.callParent(); if (list) { list.enable(); } }, onDisable: function() { var list = this.boundList; this.callParent(); if (list) { list.disable(); } }, getErrors: function(value) { var me = this, format = Ext.String.format, errors = [], numSelected; value = Ext.Array.from(value || me.getValue()); numSelected = value.length; if (!me.allowBlank && numSelected < 1) { errors.push(me.blankText); } if (numSelected < me.minSelections) { errors.push(format(me.minSelectionsText, me.minSelections)); } if (numSelected > me.maxSelections) { errors.push(format(me.maxSelectionsText, me.maxSelections)); } return errors; }, doDestroy: function() { var me = this; me.bindStore(null); Ext.destroy(me.dragZone, me.dropZone, me.keyNav); me.callParent(); }, onBindStore: function(store) { var me = this, boundList = this.boundList; if (store.autoCreated) { me.resolveDisplayField(); } if (!Ext.isDefined(me.valueField)) { me.valueField = me.displayField; } if (boundList) { boundList.bindStore(store); } }, /** * Applies auto-created store fields to field and boundlist * @private */ resolveDisplayField: function() { var me = this, boundList = me.boundList, store = me.getStore(); me.valueField = me.displayField = 'field1'; if (!store.expanded) { me.displayField = 'field2'; } if (boundList) { boundList.setDisplayField(me.displayField); } }});