/** * Simple Select field wrapper. Example usage: * * @example * Ext.create('Ext.form.Panel', { * fullscreen: true, * items: [{ * xtype: 'fieldset', * title: 'Select', * items: [{ * xtype: 'selectfield', * label: 'Choose one', * options: [{ * text: 'First Option', * value: 'first' * }, { * text: 'Second Option', * value: 'second' * }, { * text: 'Third Option', * value: 'third' * }] * }] * }] * }); */Ext.define('Ext.field.Select', { extend: 'Ext.field.Picker', xtype: 'selectfield', alternateClassName: 'Ext.form.Select', requires: [ 'Ext.Panel', 'Ext.picker.Picker', 'Ext.picker.Tablet', 'Ext.data.Store', 'Ext.data.StoreManager', 'Ext.dataview.BoundList' ], /** * @event change * Fires when selection has changed. * * This includes keystrokes that edit the text (if editable). * @param {Ext.field.Select} this * @param {Ext.data.Model} newValue The corresponding record for the new value * @param {Ext.data.Model} oldValue The corresponding record for the old value */ /** * @event select * Fires when an option from the drop down list has been selected. * @param {Ext.field.Select} this * @param {Ext.data.Model} newValue The corresponding record for the new value */ /** * @event focus * Fires when this field receives input focus. This happens both when you tap on the field and when you focus on the field by using * 'next' or 'tab' on a keyboard. * * Please note that this event is not very reliable on Android. For example, if your Select field is second in your form panel, * you cannot use the Next button to get to this select field. This functionality works as expected on iOS. * @param {Ext.field.Select} this This field * @param {Ext.event.Event} e */ config: { /** * @cfg {Boolean} hideTrigger * `true` to hide the expand trigger. */ hideTrigger: false, /** * @cfg {Object|Ext.util.Collection} A {@link Ext.util.Collection} instance, or configuration object * used to create the collection of selected records. * * This is used by the {@link #cfg!picker} as the core of its selection handling, and also as the collection * of selected values for this widget. * * @readonly */ valueCollection: true, /** * @cfg {String/Number} [valueField=value] The underlying {@link Ext.data.Field#name data value name} to bind to this * Select control. If configured as `null`, the {@link #cfg!displayField} is used. * @accessor */ valueField: 'value', /** * @cfg {String/Ext.XTemplate} [itemTpl] * An XTemplate definition string (Or an {@link Ext.XTemplate}) which specifies how to display a list * item from a record values object. This is automatically generated to display the {@link #cfg!displayField} * if not specified. */ itemTpl: false, /** * @cfg {String/String[]/Ext.XTemplate} [displayTpl] * The template to be used to display the selected record inside the text field. * * If not specified, the {@link #cfg!displayField} is shown in the text field. */ displayTpl: null, /** * @cfg {String/Number} [displayField=text] The underlying {@link Ext.data.Field#name data value name} to bind to this * Select control. If configured as `null`, the {@link #cfg!valueField} is used. * * This resolved value is the visibly rendered value of the available selection options. * @accessor */ displayField: 'text', /** * @cfg {Ext.data.Store/Object/String} store The store to provide selection options data. * Either a Store instance, configuration object or store ID. * @accessor */ store: null, /** * @cfg {Array} options An array of select options. * * [ * {text: 'First Option', value: 'first'}, * {text: 'Second Option', value: 'second'}, * {text: 'Third Option', value: 'third'} * ] * * __Note:__ Option object member names should correspond with defined {@link #valueField valueField} and {@link #displayField displayField} values. * This config will be ignored if a {@link #store store} instance is provided. * @accessor */ options: null, /** * @cfg {String} hiddenName Specify a `hiddenName` if you're using the {@link Ext.form.Panel#standardSubmit standardSubmit} option. * This name will be used to post the underlying value of the select to the server. * @accessor */ hiddenName: null, /** * @cfg {Boolean} [autoSelect=true] * `true` to auto select the first value in the {@link #store} or {@link #options} when they are changed. Only happens when * the {@link #value} is set to `null`. */ autoSelect: true, /** * @cfg {Ext.data.Model} selection * @accessor * The selected model. `null` if no value exists. */ selection: null, /** * @cfg {Boolean} autoLoadOnValue * This option controls whether to *initially* load the store when a value is set so that * the display value can be determined from the appropriate record. * The store will only be loaded in a limited set of circumstances: * - The store is not currently loading. * - The store does not have a pending {@link Ext.data.Store#autoLoad}. * - The store has not been loaded before. */ autoLoadOnValue: false, /** * @cfg {Boolean} forceSelection * By default the value must always be the {@link #cfg!valueField} of one of the records in the store. * Configure as `false` to allow the value to be set to arbitrary text, and have this component * auto create an associated record with the typed value as the {@link #cfg!valueField}. * * This config is only supported for use in {@link Ext.field.ComboBox} but is defined * here (as private) because of its many entanglements with value processing. * @private * @since 6.5.0 */ forceSelection: true, /** * @cfg {String} [valueNotFoundText] * If the value passed to setValue is not found in the store, valueNotFoundText will * be displayed as the field text if defined. If this default text is used, it means there * is no value set and no validation will occur on this field. */ valueNotFoundText: null, /** * @cfg {Boolean} selectOnTab * Whether the Tab key should select the currently highlighted item. */ selectOnTab: true, /** * @cfg {Boolean} [multiSelect=false] * @hide * Configure as `true` to allow selection of multiple items from the picker. This results in each * selected item being represented by a "tag" in the combobox's input area. */ multiSelect: null, /** * @cfg {Boolean} [collapseOnSelect=false] * Has no effect if {@link #cfg!multiSelect} is `false` * * Configure as true to automatically hide the picker after a selection is made. */ collapseOnSelect: null }, editable: false, floatedPicker: { xtype: 'boundlist', infinite: false, // BoundListNavigationModel binds to input field // Must only be enabled when list is visible navigationModel: { disabled: true }, scrollToTopOnRefresh: false, loadingHeight: 70, maxHeight: 300, floated: true, axisLock: true, hideAnimation: null }, edgePicker: { xtype: 'picker', hideAnimation: 'fadeOut' }, classCls: Ext.baseCSSPrefix + 'selectfield', twoWayBindable: { selection: 1 }, publishes: { selection: 1 }, applyValueCollection: function(valueCollection) { if (!valueCollection.isCollection) { valueCollection = new Ext.util.Collection(valueCollection); } // Add this ComboBox as an observer immediately so that we are informed of any // mutations which occur in this event run. // We must sync the selection property and the rawValue upon mutation. valueCollection.addObserver(this); return valueCollection; }, /** * @private * Respond to deselection. Call the onItemDeselect template method */ onCollectionRemove: function(valueCollection, chunk) { var selection = valueCollection.getRange(); // If this remove is part of a splice, wait until the collection add to sync the selection. if (!chunk.replacement) { // Must ensure that null is passed if the valueCollection is empty this.setSelection(selection.length ? (this.getMultiSelect() ? selection : selection[0]) : null); } }, /** * @private * Respond to selection. Call the onItemSelect template method */ onCollectionAdd: function(valueCollection, adds) { var selection = valueCollection.getRange(); this.setSelection(this.getMultiSelect() ? selection : selection[0]); }, clearValue: function () { // We clear things differently vs superclass. The value of Select fields depends // upon the value collection. this.setValue(null); this.syncDefaultTriggers(); }, /* TODO fixup these docs and move to value config * Sets the value of the field. * @param {Mixed/Ext.data.Model} newValue The new value. This may be specified as either * an existing store record, or the required {@link #cfg!valueField} value. * * Either way, both {@link #cfg!valueField} value *and* the associated record will be ascertained. * * The {@link #cfg!valueField} value is published to the ViewModel as is the {@link #cfg-selection associated record}. * * The record published to the selection property will be `null` if the value did not * match a record, and the field is not configured to create new records for unmatched * values using `{@link #cfg!forceSelection}: false` */ applyValue: function(value, oldValue) { // Ensure that a store is formed from any options before we get the store. this.getOptions(); var me = this, autoLoadOnValue = me.getAutoLoadOnValue(), valueField = me.getValueField(), displayField = me.getDisplayField(), store = me.getStore(), record, Model, isLoaded, pendingLoad, needsLoad, dataObj, notFoundText; // We were passed a record. // Set the selection which updates the value from the valueField. if (value && value.isEntity) { me.setSelection(value); return; } // A non-empty value has to be matched to the valueField of a record else if (value != null) { if (store) { // Stores can be programatically populated without going through a load. // And they can have loaded and still have zero records. isLoaded = store.getCount() > 0 || store.isLoaded(); // If it turns out that we need to kick off a load, we don't need to bother is this is true pendingLoad = store.hasPendingLoad(); // If we are configured to autoLoad when the value arrives, prepare to do so needsLoad = autoLoadOnValue && !isLoaded && !pendingLoad; } if (me.isConfiguring) { me.originalValue = value; } // Find the value in the store. // *or in the valueCollection which may contain the new records enabled // by setValue being passed a record, or by forceSelection: false, or // createNewOnEnter or createNewOnTab* // The method may be overridden in subclasses to also search // in the valueCollection. record = me.findRecordByValue(value); // record was not found if (!record) { if (isLoaded && !me.getForceSelection()) { // user has typed in something that isn't in the store Model = store.getModel(); dataObj = {}; dataObj[displayField] = value; if (valueField && displayField !== valueField) { dataObj[valueField] = value; } record = new Model(dataObj); // mark record as entered text vs. an existing one from the store record.isEntered = true; } // If the store has not yet arrived from a bind flush // or, it has not yet been loaded then we need to cache // the value and apply it on store load. // The onStoreLoad will push the cachedValue through setValue. else { me.cachedValue = value; } } else if (store && me.getForceSelection() && store.indexOf(record) === -1) { record = null; value = null; } } // Kick off a load unless we are clearing the value. // Doesn't matter whether proxy is remote - it needs loading // so we can select the correct record for the value in the load event handler. if (value && needsLoad) { store.load(); } // If we have a record, set the value property, so that our set of the selection // does not recurse. if (record && !record.isEntered) { me._value = value; me.setSelection(record); } else if (me.getForceSelection()) { me._value = null; me.setSelection(null); // If we could not match the value, update the valueNotFOund text notFoundText = me.getValueNotFoundText(); if (notFoundText) { me.inputElement.dom.value = notFoundText; } } else { me._value = value = me.transformValue(value); // In !forceSelection mode when we have a value that does not match a record // we do not report the temporary record as the selection. But we also need // to prevent the updateSelection process from zapping the value. me._ignoreSelection = true; me.setSelection(null); me._ignoreSelection = false; me.setFieldDisplay(record); } // Because we set the value property in here before setting the selection in order // to prevent infinite recursion, and return undefined, the config's setter will // not invoke the updater. // We have to invoke our updater directly if the value has changed. if (value !== oldValue) { me.updateValue(value, oldValue); } }, updateValue: function(value, oldValue) { // Note that we must not invoke superclass updateValue because that updates the // field UI in ways that SelectFields cannot handle. // We must directly invoke the base class's updateValue. That fires the change // event and validates the value which we still need to happen. Ext.field.Field.prototype.updateValue.call(this, value, oldValue); }, /** * Finds the record in the {@link #cfg!store}, or the {@link #cfg!valueCollection} which has the {@link #cfg!valueField} * matching the passed value. * * The {@link #cfg!valueCollection} is included because of the {@link #cfg!createNewOnEnter}, * {@link #cfg!createNewOnBlur}, and {@link #cfg!forceSelection} configs which allow for insertion into the * {@link #cfg!valueCollection} of newly created records which are not in the configured {@link #cfg!store}. * * Also, a currently selected value may be filtered out of visibility in the configured {@link #cfg!store} * * @param {String} value The value to match the {@link #valueField} against. * @return {Ext.data.Model} The matched record or null. */ findRecordByValue: function(value) { var me = this, store = me.getStore(), valueField = me.getValueField(), result, ret = null; if (store) { result = store.byValue.get(value); // If there are duplicate keys, tested behaviour is to return the *first* match. if (result) { ret = result[0] || result; } } // Not found in the base store. // See if there's a match in the valueCollection. // This is because we allow new records to be created if forceSelection is false // And we allow value to be set to a record which is then inserted into the valueCollection. if (!ret) { ret = me.getValueCollection().findBy(function(record) { return record.get(valueField) === value; }); } return ret; }, /** * Finds the record by searching values in the {@link #displayField}. * @param {Object} value The value to match the field against. * @return {Ext.data.Model/false} The matched record or `false`. */ findRecordByDisplay: function(value) { var store = this.getStore(), result, ret = false; if (store) { result = this.store.byText.get(value); // If there are duplicate keys, tested behaviour is to return the *first* match. if (result) { ret = result[0] || result; } } return ret; }, applySelection: function(selection, oldSelection) { var multiValues = selection && this.getMultiSelect(); selection = multiValues ? Ext.Array.from(selection) : selection; if (multiValues ? (!oldSelection || !Ext.Array.equals(selection, oldSelection)) : selection !== oldSelection) { return selection || null; } }, /** * @private * Updates the fields input UI according to the current selection. * In the case of single selection, simply updates the input field's value. * * For multiSelection, a more complex input UI is needed. * @param selection */ setFieldDisplay: function(selection) { var me = this, inputValue = '', displayTpl; if (me.getMultiSelect()) { //<debug> Ext.raise('multiselect is not yet supported'); //</debug> } else { if (selection) { displayTpl = me.getDisplayTpl(); if (displayTpl) { inputValue = displayTpl.apply(me.getRecordDisplayData(selection)); } else { inputValue = selection.get(me.getDisplayField()); } } me.setInputValue(inputValue); } }, /** * @private * Update the UI to reflect the new selection. The selection arrives as mutation notifications * from the {@link #cfg!valueCollection} which is the {@link Ext.util.Collection} at the heart * of the picker's {@link Ext.mixin.Selectable} persona. */ updateSelection: function(selection) { if (this._ignoreSelection) { return; } var me = this, valueCollection = me.getValueCollection(), isNull = selection == null, spliceArgs = [0, valueCollection.getCount()], valueField = me.getValueField(), // Only get the picker if it has been created. picker = me.getConfig('picker', false, true); if (isNull || !valueCollection.contains(selection)) { if (!isNull) { // Ext.Array.push uses .call/.apply to push either // the passed single value, *or* the passed array. Ext.Array.push(spliceArgs, selection); } // Replace all valueCollection content with the new selection. // We are an observer of the valueCollection. // // This will feed through to our onCollectionRemove, which will only // push through to the selection property if there's no upcoming add. // // If there's an add, then our onCollectionAdd will be called // which will push the valueCollection's data through to // our selection property. valueCollection.splice.apply(valueCollection, spliceArgs); } if (!me.destroyed && !me.destroying && valueField) { if (selection) { if (!selection.isEntered) { me.setValue(selection.get(valueField)); if (me.fireEvent('select', me, selection) === false) { me.setValue(null); selection = null; } if (me.destroyed) { return; } } } else { me.clearValue(); } } // Update the field's input UI. // Note that this may be a DOM <input> value, but may also // be a UI like a TagField which is produced from the // selected record(s) me.setFieldDisplay(selection); // If the picker has been created, either collapse it, // or scroll to the latest selection. if (picker) { if (!me.getMultiSelect() || me.getCollapseOnSelect() || !me.getStore().getCount()) { me.collapse(); } else { me.setPickerLocation(); } } }, /** * Gets data for each record to be used for constructing the display value with * the {@link #displayTpl}. This may be overridden to provide access to associated records. * @param {Ext.data.Model} record The record. * @return {Object} The data to be passed for each record to the {@link #displayTpl}. * * @protected * @template */ getRecordDisplayData: function(record) { return record.getData(); }, createFloatedPicker: function() { var me = this, multiSelect = me.getMultiSelect(), result = Ext.merge({ ownerCmp: me, store: me.getStore(), selectable: { selected: me.getValueCollection(), selectedRecord: me.getSelection(), deselectable: !!multiSelect, mode: multiSelect ? 'multi' : 'single' }, itemTpl: me.getItemTpl() }, me.getFloatedPicker()); // Allow SPACE to navigate unless it's needed // to edit the inputElement. result.navigationModel.navigateOnSpace = !me.getEditable(); return result; }, createEdgePicker: function() { var me = this; return Ext.merge({ ownerCmp: me, slots: [{ align: me.getPickerSlotAlign(), name: me.getValueField(), valueField: me.getValueField(), displayField: me.getDisplayField(), value: me.getValue(), store: me.getStore() }], listeners: { change: me.onPickerChange, scope: me }, setStore: function(store) { this.child('pickerslot').setStore(store); }, deselectAll: function() { this.child('pickerslot').deselectAll(); } }, me.getEdgePicker()); }, setPickerLocation: function() { var me = this, picker = me.getConfig('picker', false, true), selection = me.getSelection(), store; if (picker && me.expanded) { store = me.getStore(); if (store && store.getCount() > 0) { if (me.pickerType === 'floated') { picker.getNavigationModel().setLocation(selection, { select: true }); } else { this.updatePickerValue(picker); } } } }, updatePickerValue: function (picker, value) { var name = this.getValueField(), pickerValue = {}; if (!value) { value = this.getValue(); } pickerValue[name] = value; picker.setValue(pickerValue); }, onPickerShow: function(picker) { this.callParent([picker]); // Enable the picker's key mappings in this field's KeyMap, // unless it's an edge picker that doesn't support keyboard if (this.pickerType === 'floated') { picker.getNavigationModel().enable(); } }, onPickerHide: function(picker) { var navModel; this.callParent([picker]); // Set the location to null because there's no onFocusLeave // to do this because the picker does not get focused. // Disable the picker's key mappings in this field's KeyMap if (!picker.destroying && this.pickerType === 'floated') { navModel = picker.getNavigationModel(); navModel.setLocation(null); navModel.disable(); } }, /** * @private * Used when the edge picker is used. */ onPickerChange: function(picker, value) { this.setValue(this.findRecordByValue(value[this.getValueField()])); }, applyItemTpl: function (itemTpl) { if (itemTpl === false) { itemTpl = '<span class="x-list-label">{' + this.getDisplayField() + ':htmlEncode}</span>'; } return itemTpl; }, applyDisplayTpl: function (displayTpl) { if (displayTpl && !displayTpl.isTemplate) { displayTpl = new Ext.XTemplate(displayTpl); } return displayTpl; }, applyOptions: function(options) { if (options) { var len = options.length, valueField = this.getValueField(), displayField = this.getDisplayField(), i, value, option; // Convert an array of strings to record data objects options = Ext.Array.slice(options); for (i = 0; i < len; i++) { value = options[i]; if (typeof value === 'string') { options[i] = option = {}; option[valueField] = value; if (displayField && displayField !== valueField) { option[displayField] = value; } } } } return options; }, /** * Updates the underlying `<options>` list with new values. * * @param {Array} newOptions An array of options configurations to insert or append. * * selectBox.setOptions([ * {text: 'First Option', value: 'first'}, * {text: 'Second Option', value: 'second'}, * {text: 'Third Option', value: 'third'} * ]).setValue('third'); * * __Note:__ option object member names should correspond with defined {@link #valueField valueField} and * {@link #displayField displayField} values. * * @return {Ext.field.Select} this */ updateOptions: function(newOptions) { if (newOptions) { // *peek* at the store. Do not cause the config to be processed. // The options data must be available to onStoreUpdate if we in fact // have to pull the configuration through updateStore. var me = this, store = me.getConfig('store', true); // We already had a store. Just update it if (store && store.isStore) { store.setData(newOptions); me.onStoreDataChanged(store); me.setOptions(null); } // Pull any configured store through updateStore which will // load it with the options data. else { store = me.getStore(); me.setOptions(null); // If we had a store, it will have picked up the optionsData and added them. // If not, we create it here. if (!store) { me.setStore({ fields: [me.getValueField(), me.getDisplayField()], autoDestroy: true, data: newOptions }); } } } }, applyStore: function(store) { if (store) { store = Ext.data.StoreManager.lookup(store); } return store; }, updateStore: function(store, oldStore) { var me = this, picker = me.getConfig('picker', false, true), valueField = me.getValueField(), displayField = me.getDisplayField(), optionsData = me.getOptions(), extraKeySpec; if (oldStore) { if (oldStore.getAutoDestroy()) { oldStore.destroy(); } else { oldStore.byValue = oldStore.byText = Ext.destroy(oldStore.byValue, oldStore.byText); } } if (store) { // Add a byValue index to the store so that we can efficiently look up records by the value field // when setValue passes string value(s). // The two indices (Ext.util.CollectionKeys) are configured unique: false, so that if duplicate keys // are found, they are all returned by the get call. // This is so that findByText and findByValue are able to return the *FIRST* matching value. By default, // if unique is true, CollectionKey keeps the *last* matching value. extraKeySpec = { byValue: { rootProperty: 'data', unique: false, property: valueField } }; if (displayField !== valueField) { extraKeySpec.byText = { rootProperty: 'data', unique: false, property: displayField }; } store.setExtraKeys(extraKeySpec); // If display and value fields are the same, the same index goes by both names. if (displayField === valueField) { store.byText = store.byValue; } store.on({ scope: this, add: 'onStoreDataChanged', filterchange: 'onStoreDataChanged', remove: 'onStoreDataChanged', update: 'onStoreRecordUpdated', // Must be informed after list, and selection has been updated load: { fn: 'onStoreLoad', priority: -1 } }); // Add options from the last setOptions call. if (optionsData) { store.setData(optionsData); } // If the store is already loaded, fix up any value we may have. // cachedValue will be set if there was no store at init time. // If we had a selected record, rematch it. // Otherwise auto select first record if configured to do so. if (store.getCount()) { // cachedValue could be 0 or false if (me.cachedValue != null) { me.onStoreLoad(store); } // If we don't have a value, and we are not going to receive a value from // binding and we are autoSelect: true, autoSelect the first record. else if (!(me.getValue() != null || me.getSelection() || me.isBound('value') || me.isBound('selection')) && me.getAutoSelect()) { me.setSelection(store.getAt(0)); } } // If not loaded, and there's a value waiting to be matched // and we should autoload on value, load the store and onStoreLoad // will match it up. else if (me.cachedValue != null && me.getAutoLoadOnValue() && !store.isLoaded() && !store.hasPendingLoad()) { store.load(); } } if (picker) { picker.setStore(store); } }, applyValueField: function(valueField) { // If either valueField or displayField are configured as null, then // this Select component uses the remaining configured field name for both purposes. if (valueField == null) { valueField = this.getDisplayField(); } return valueField; }, updateValueField: function(valueField) { var store = this.getStore(); // Keep the byValue index synced if (store && !this.isConfiguring) { store.byValue.setCollection(null); store.setExtraKeys({ byValue: { rootProperty: 'data', unique: false, property: valueField } }); } }, applyDisplayField: function(displayField) { // If either valueField or displayField are configured as null, then // this Select component uses the remaining configured field name for both purposes. if (displayField == null) { displayField = this.getValueField(); } return displayField; }, updateDisplayField: function(displayField) { var store = this.getStore(); // Keep the byValue index synced if (store && !this.isConfiguring) { store.byText.setCollection(null); store.setExtraKeys({ byText: { rootProperty: 'data', unique: false, property: displayField } }); } }, updatehHideTrigger: function(hideTrigger) { this.getTriggers().expand.setVisible(!hideTrigger); }, /** * @private * Whenever the store loads, we need to refresh the selection by pushing a value through * the setValue machinery. Upon initialization, there may be a cached initial value. * Otherwise use the current value. */ onStoreLoad: function(store) { var me = this; // cachedValue could be 0 or false me.setValue(me.cachedValue == null ? me.getValue() : me.cachedValue); me.cachedValue = null; }, /** * @private * Called when the internal {@link #store}'s data has changed. */ onStoreDataChanged: function () { if (this.getForceSelection()) { var value = this.getValue(); // Push the textual value from the selected record through applyValue // to match with a new record from the new data. if (value != null) { this.setValue(value); } } }, /** * @private * Called when a internal {@link #store}'s record has been mutated. * Keep the field UI synced */ onStoreRecordUpdated: function(store, record) { if (this.getValueCollection().contains(record)) { this.updateSelection(this.getSelection()); } }, /** * Resets the Select field to the value of the first record in the store. * @return {Ext.field.Select} this * @chainable */ reset: function() { var me = this, picker = me.getConfig('picker', false, true), record = me.originalValue || null, store; if (me.getAutoSelect()) { store = me.getStore(); record = (record != null) ? record : store && store.getAt(0); } else { if (picker) { picker.deselectAll(); } } me.setValue(record); return me; }, doDestroy: function() { var store = this.getStore(); if (store && !store.destroyed && store.getAutoDestroy()) { store.destroy(); } this.callParent(); }});