Code:
MultiSelect.js
/**
* A control that allows selection of multiple items in a list
*/
Ext.define('Ext.ux.form.MultiSelect', {
extend: 'Ext.form.FieldContainer',
mixins: {
bindable: 'Ext.util.Bindable',
field: 'Ext.form.field.Field'
},
alternateClassName: 'Ext.ux.Multiselect',
alias: ['widget.multiselectfield', 'widget.multiselect'],
requires: ['Ext.panel.Panel', 'Ext.view.BoundList'],
uses: ['Ext.view.DragZone', 'Ext.view.DropZone'],
/**
* @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: 'Minimum {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 <tt>null</tt>.
*/
delimiter: ',',
/**
* @cfg {Ext.data.Store/Array} store The data source to which this MultiSelect is bound (defaults to <tt>undefined</tt>).
* 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., <tt>[['f','Foo'],['b','Bar']]</tt>)<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,
initComponent: function () {
var me = this;
me.bindStore(me.store, true);
if (me.store.autoCreated) {
me.valueField = me.displayField = 'field1';
if (!me.store.expanded) {
me.displayField = 'field2';
}
}
if (!Ext.isDefined(me.valueField)) {
me.valueField = me.displayField;
}
Ext.apply(me, me.setupItems());
me.callParent();
me.initField();
me.addEvents('drop');
},
setupItems: function () {
var me = this;
me.boundList = Ext.create('Ext.view.BoundList', {
deferInitialRefresh: false,
multiSelect: true,
store: me.store,
displayField: me.displayField,
disabled: me.disabled
});
me.boundList.getSelectionModel().on('selectionchange', me.onSelectChange, me);
return {
layout: 'fit',
labelAlign: 'top',
fieldLabel: me.fieldLabel,
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;
me.callParent();
if (me.selectOnRender) {
++me.ignoreSelectChange;
me.boundList.getSelectionModel().select(me.getRecordsForValue(me.value));
--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: me.boundList,
ddGroup: me.dragGroup,
dragText: '{0} Item{1}'
});
}
if (me.droppable || me.dropGroup) {
me.dropZone = Ext.create('Ext.view.DropZone', {
view: me.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);
}
});
}
},
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 not loaded yet - we cannot set the value
if (!me.store.getCount()) {
me.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();
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;
},
onDestroy: function () {
var me = this;
me.bindStore(null);
Ext.destroy(me.dragZone, me.dropZone);
me.callParent();
},
onBindStore: function (store) {
var boundList = this.boundList;
if (boundList) {
boundList.bindStore(store);
}
}
});
ItemSelector.js
/*
* Note that this control will most likely remain as an example, and not as a core Ext form
* control. However, the API will be changing in a future release and so should not yet be
* treated as a final, stable API at this time.
*/
/**
* A control that allows selection of between two Ext.ux.form.MultiSelect controls.
*/
Ext.define('Ext.ux.form.ItemSelector', {
extend: 'Ext.ux.form.MultiSelect',
alias: ['widget.itemselectorfield', 'widget.itemselector'],
alternateClassName: ['Ext.ux.ItemSelector'],
requires: [
'Ext.button.Button',
'Ext.ux.form.MultiSelect'
],
fromLabel: 'From',
toLabel: 'To',
/**
* @cfg {Boolean} [hideNavIcons=false] True to hide the navigation icons
*/
hideNavIcons: false,
/**
* @cfg {Array} buttons Defines the set of buttons that should be displayed in between the ItemSelector
* fields. Defaults to <tt>['top', 'up', 'add', 'remove', 'down', 'bottom']</tt>. These names are used
* to build the button CSS class names, and to look up the button text labels in {@link #buttonsText}.
* This can be overridden with a custom Array to change which buttons are displayed or their order.
*/
buttons: ['top', 'up', 'add', 'remove', 'down', 'bottom'],
/**
* @cfg {Object} buttonsText The tooltips for the {@link #buttons}.
* Labels for buttons.
*/
buttonsText: {
top: "Move to Top",
up: "Move Up",
add: "Add to Selected",
remove: "Remove from Selected",
down: "Move Down",
bottom: "Move to Bottom"
},
initComponent: function () {
var me = this;
me.ddGroup = me.id + '-dd';
me.callParent();
// bindStore must be called after the fromField has been created because
// it copies records from our configured Store into the fromField's Store
me.bindStore(me.store);
},
createList: function (fieldLabel) {
var me = this;
return Ext.create('Ext.ux.form.MultiSelect', {
submitValue: false,
flex: 1,
fieldLabel: fieldLabel,
dragGroup: me.ddGroup,
dropGroup: me.ddGroup,
store: {
model: me.store.model,
data: []
},
displayField: me.displayField,
disabled: me.disabled,
listeners: {
boundList: {
scope: me,
itemdblclick: me.onItemDblClick,
drop: me.syncValue
}
}
});
},
setupItems: function () {
var me = this;
me.fromField = me.createList(me.fromLabel);
me.toField = me.createList(me.toLabel);
return {
layout: {
type: 'hbox',
align: 'stretch'
},
items: [
me.fromField,
{
xtype: 'container',
margins: '0 4',
width: 22,
layout: {
type: 'vbox',
pack: 'center'
},
items: me.createButtons()
},
me.toField
]
};
},
createButtons: function () {
var me = this,
buttons = [];
if (!me.hideNavIcons) {
Ext.Array.forEach(me.buttons, function (name) {
buttons.push({
xtype: 'button',
tooltip: me.buttonsText[name],
handler: me['on' + Ext.String.capitalize(name) + 'BtnClick'],
cls: Ext.baseCSSPrefix + 'form-itemselector-btn',
iconCls: Ext.baseCSSPrefix + 'form-itemselector-' + name,
navBtn: true,
scope: me,
margin: '4 0 0 0'
});
});
}
return buttons;
},
/**
* Get the selected records from the specified list.
*
* Records will be returned *in store order*, not in order of selection.
* @param {Ext.view.BoundList} list The list to read selections from.
* @return {Ext.data.Model[]} The selected records in store order.
*
*/
getSelections: function (list) {
var store = list.getStore();
return Ext.Array.sort(list.getSelectionModel().getSelection(), function (a, b) {
a = store.indexOf(a);
b = store.indexOf(b);
if (a < b) {
return -1;
} else if (a > b) {
return 1;
}
return 0;
});
},
onTopBtnClick: function () {
var list = this.toField.boundList,
store = list.getStore(),
selected = this.getSelections(list);
store.suspendEvents();
store.remove(selected, true);
store.insert(0, selected);
store.resumeEvents();
list.refresh();
this.syncValue();
list.getSelectionModel().select(selected);
},
onBottomBtnClick: function () {
var list = this.toField.boundList,
store = list.getStore(),
selected = this.getSelections(list);
store.suspendEvents();
store.remove(selected, true);
store.add(selected);
store.resumeEvents();
list.refresh();
this.syncValue();
list.getSelectionModel().select(selected);
},
onUpBtnClick: function () {
var list = this.toField.boundList,
store = list.getStore(),
selected = this.getSelections(list),
rec,
i = 0,
len = selected.length,
index = 0;
// Move each selection up by one place if possible
store.suspendEvents();
for (; i < len; ++i, index++) {
rec = selected[i];
index = Math.max(index, store.indexOf(rec) - 1);
store.remove(rec, true);
store.insert(index, rec);
}
store.resumeEvents();
list.refresh();
this.syncValue();
list.getSelectionModel().select(selected);
},
onDownBtnClick: function () {
var list = this.toField.boundList,
store = list.getStore(),
selected = this.getSelections(list),
rec,
i = selected.length - 1,
index = store.getCount() - 1;
// Move each selection down by one place if possible
store.suspendEvents();
for (; i > -1; --i, index--) {
rec = selected[i];
index = Math.min(index, store.indexOf(rec) + 1);
store.remove(rec, true);
store.insert(index, rec);
}
store.resumeEvents();
list.refresh();
this.syncValue();
list.getSelectionModel().select(selected);
},
onAddBtnClick: function () {
var me = this,
selected = me.getSelections(me.fromField.boundList);
me.moveRec(true, selected);
me.toField.boundList.getSelectionModel().select(selected);
},
onRemoveBtnClick: function () {
var me = this,
selected = me.getSelections(me.toField.boundList);
me.moveRec(false, selected);
me.fromField.boundList.getSelectionModel().select(selected);
},
moveRec: function (add, recs) {
var me = this,
fromField = me.fromField,
toField = me.toField,
fromStore = add ? fromField.store : toField.store,
toStore = add ? toField.store : fromField.store;
fromStore.suspendEvents();
toStore.suspendEvents();
fromStore.remove(recs);
toStore.add(recs);
fromStore.resumeEvents();
toStore.resumeEvents();
fromField.boundList.refresh();
toField.boundList.refresh();
me.syncValue();
},
// Synchronizes the submit value with the current state of the toStore
syncValue: function () {
var me = this;
me.mixins.field.setValue.call(me, me.setupValue(me.toField.store.getRange()));
},
onItemDblClick: function (view, rec) {
this.moveRec(view === this.fromField.boundList, rec);
},
setValue: function (value) {
var me = this,
fromField = me.fromField,
toField = me.toField,
fromStore = fromField.store,
toStore = toField.store,
selected;
// Wait for from store to be loaded
if (!me.fromStorePopulated) {
me.fromField.store.on({
load: Ext.Function.bind(me.setValue, me, [value]),
single: true
});
return;
}
value = me.setupValue(value);
me.mixins.field.setValue.call(me, value);
selected = me.getRecordsForValue(value);
// Clear both left and right Stores.
// Both stores must not fire events during this process.
fromStore.suspendEvents();
toStore.suspendEvents();
fromStore.removeAll();
toStore.removeAll();
// Reset fromStore
me.populateFromStore(me.store);
// Copy selection across to toStore
Ext.Array.forEach(selected, function (rec) {
// In the from store, move it over
if (fromStore.indexOf(rec) > -1) {
fromStore.remove(rec);
}
toStore.add(rec);
});
// Stores may now fire events
fromStore.resumeEvents();
toStore.resumeEvents();
// Refresh both sides and then update the app layout
Ext.suspendLayouts();
fromField.boundList.refresh();
toField.boundList.refresh();
Ext.resumeLayouts(true);
},
onBindStore: function (store, initial) {
var me = this;
if (me.fromField) {
me.fromField.store.removeAll()
me.toField.store.removeAll();
// Add everything to the from field as soon as the Store is loaded
if (store.getCount()) {
me.populateFromStore(store);
} else {
me.store.on('load', me.populateFromStore, me);
}
}
},
populateFromStore: function (store) {
var fromStore = this.fromField.store;
// Flag set when the fromStore has been loaded
this.fromStorePopulated = true;
fromStore.add(store.getRange());
// setValue waits for the from Store to be loaded
fromStore.fireEvent('load', fromStore);
},
onEnable: function () {
var me = this;
me.callParent();
me.fromField.enable();
me.toField.enable();
Ext.Array.forEach(me.query('[navBtn]'), function (btn) {
btn.enable();
});
},
onDisable: function () {
var me = this;
me.callParent();
me.fromField.disable();
me.toField.disable();
Ext.Array.forEach(me.query('[navBtn]'), function (btn) {
btn.disable();
});
},
onDestroy: function () {
this.bindStore(null);
this.callParent();
}
});