mirror of
https://git.proxmox.com/git/proxmox-widget-toolkit
synced 2025-05-03 22:17:08 +00:00

in fields with 'multiSelect: true', we get an array as value instead of a string, but a check of !![] results in true (since an array is an object), so we have to explicitely check for arraylength in 'setValue' (for correctly showing the trigger) and in the load handler (to not set an empty field wrongfully to invalid) Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
473 lines
14 KiB
JavaScript
473 lines
14 KiB
JavaScript
/*
|
|
* ComboGrid component: a ComboBox where the dropdown menu (the
|
|
* "Picker") is a Grid with Rows and Columns expects a listConfig
|
|
* object with a columns property roughly based on the GridPicker from
|
|
* https://www.sencha.com/forum/showthread.php?299909
|
|
*
|
|
*/
|
|
|
|
Ext.define('Proxmox.form.ComboGrid', {
|
|
extend: 'Ext.form.field.ComboBox',
|
|
alias: ['widget.proxmoxComboGrid'],
|
|
|
|
// this value is used as default value after load()
|
|
preferredValue: undefined,
|
|
|
|
// hack: allow to select empty value
|
|
// seems extjs does not allow that when 'editable == false'
|
|
onKeyUp: function(e, t) {
|
|
var me = this;
|
|
var key = e.getKey();
|
|
|
|
if (!me.editable && me.allowBlank && !me.multiSelect &&
|
|
(key == e.BACKSPACE || key == e.DELETE)) {
|
|
me.setValue('');
|
|
}
|
|
|
|
me.callParent(arguments);
|
|
},
|
|
|
|
config: {
|
|
skipEmptyText: false,
|
|
notFoundIsValid: false,
|
|
deleteEmpty: false,
|
|
},
|
|
|
|
// needed to trigger onKeyUp etc.
|
|
enableKeyEvents: true,
|
|
|
|
editable: false,
|
|
|
|
triggers: {
|
|
clear: {
|
|
cls: 'pmx-clear-trigger',
|
|
weight: -1,
|
|
hidden: true,
|
|
handler: function() {
|
|
var me = this;
|
|
me.setValue('');
|
|
}
|
|
}
|
|
},
|
|
|
|
setValue: function(value) {
|
|
var me = this;
|
|
let empty = Ext.isArray(value) ? !value.length : !value;
|
|
me.triggers.clear.setVisible(!empty && me.allowBlank);
|
|
return me.callParent([value]);
|
|
},
|
|
|
|
// override ExtJS method
|
|
// if the field has multiSelect enabled, the store is not loaded, and
|
|
// the displayfield == valuefield, it saves the rawvalue as an array
|
|
// but the getRawValue method is only defined in the textfield class
|
|
// (which has not to deal with arrays) an returns the string in the
|
|
// field (not an array)
|
|
//
|
|
// so if we have multiselect enabled, return the rawValue (which
|
|
// should be an array) and else we do callParent so
|
|
// it should not impact any other use of the class
|
|
getRawValue: function() {
|
|
var me = this;
|
|
if (me.multiSelect) {
|
|
return me.rawValue;
|
|
} else {
|
|
return me.callParent();
|
|
}
|
|
},
|
|
|
|
getSubmitData: function() {
|
|
var me = this;
|
|
|
|
let data = null;
|
|
if (!me.disabled && me.submitValue) {
|
|
let val = me.getSubmitValue();
|
|
if (val !== null) {
|
|
data = {};
|
|
data[me.getName()] = val;
|
|
} else if (me.getDeleteEmpty()) {
|
|
data = {};
|
|
data['delete'] = me.getName();
|
|
}
|
|
}
|
|
return data;
|
|
},
|
|
|
|
getSubmitValue: function() {
|
|
var me = this;
|
|
|
|
var value = me.callParent();
|
|
if (value !== '') {
|
|
return value;
|
|
}
|
|
|
|
return me.getSkipEmptyText() ? null: value;
|
|
},
|
|
|
|
setAllowBlank: function(allowBlank) {
|
|
this.allowBlank = allowBlank;
|
|
this.validate();
|
|
},
|
|
|
|
// override ExtJS protected method
|
|
onBindStore: function(store, initial) {
|
|
var me = this,
|
|
picker = me.picker,
|
|
extraKeySpec,
|
|
valueCollectionConfig;
|
|
|
|
// We're being bound, not unbound...
|
|
if (store) {
|
|
// If store was created from a 2 dimensional array with generated field names 'field1' and 'field2'
|
|
if (store.autoCreated) {
|
|
me.queryMode = 'local';
|
|
me.valueField = me.displayField = 'field1';
|
|
if (!store.expanded) {
|
|
me.displayField = 'field2';
|
|
}
|
|
|
|
// displayTpl config will need regenerating with the autogenerated displayField name 'field1'
|
|
me.setDisplayTpl(null);
|
|
}
|
|
if (!Ext.isDefined(me.valueField)) {
|
|
me.valueField = me.displayField;
|
|
}
|
|
|
|
// 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
|
|
}
|
|
};
|
|
extraKeySpec.byValue.property = me.valueField;
|
|
store.setExtraKeys(extraKeySpec);
|
|
|
|
if (me.displayField === me.valueField) {
|
|
store.byText = store.byValue;
|
|
} else {
|
|
extraKeySpec.byText = {
|
|
rootProperty: 'data',
|
|
unique: false
|
|
};
|
|
extraKeySpec.byText.property = me.displayField;
|
|
store.setExtraKeys(extraKeySpec);
|
|
}
|
|
|
|
// We hold a collection of the values which have been selected, keyed by this field's valueField.
|
|
// This collection also functions as the selected items collection for the BoundList's selection model
|
|
valueCollectionConfig = {
|
|
rootProperty: 'data',
|
|
extraKeys: {
|
|
byInternalId: {
|
|
property: 'internalId'
|
|
},
|
|
byValue: {
|
|
property: me.valueField,
|
|
rootProperty: 'data'
|
|
}
|
|
},
|
|
// Whenever this collection is changed by anyone, whether by this field adding to it,
|
|
// or the BoundList operating, we must refresh our value.
|
|
listeners: {
|
|
beginupdate: me.onValueCollectionBeginUpdate,
|
|
endupdate: me.onValueCollectionEndUpdate,
|
|
scope: me
|
|
}
|
|
};
|
|
|
|
// This becomes our collection of selected records for the Field.
|
|
me.valueCollection = new Ext.util.Collection(valueCollectionConfig);
|
|
|
|
// We use the selected Collection as our value collection and the basis
|
|
// for rendering the tag list.
|
|
|
|
//proxmox override: since the picker is represented by a grid panel,
|
|
// we changed here the selection to RowModel
|
|
me.pickerSelectionModel = new Ext.selection.RowModel({
|
|
mode: me.multiSelect ? 'SIMPLE' : 'SINGLE',
|
|
// There are situations when a row is selected on mousedown but then the mouse is dragged to another row
|
|
// and released. In these situations, the event target for the click event won't be the row where the mouse
|
|
// was released but the boundview. The view will then determine that it should fire a container click, and
|
|
// the DataViewModel will then deselect all prior selections. Setting `deselectOnContainerClick` here will
|
|
// prevent the model from deselecting.
|
|
deselectOnContainerClick: false,
|
|
enableInitialSelection: false,
|
|
pruneRemoved: false,
|
|
selected: me.valueCollection,
|
|
store: store,
|
|
listeners: {
|
|
scope: me,
|
|
lastselectedchanged: me.updateBindSelection
|
|
}
|
|
});
|
|
|
|
if (!initial) {
|
|
me.resetToDefault();
|
|
}
|
|
|
|
if (picker) {
|
|
picker.setSelectionModel(me.pickerSelectionModel);
|
|
if (picker.getStore() !== store) {
|
|
picker.bindStore(store);
|
|
}
|
|
}
|
|
}
|
|
},
|
|
|
|
// copied from ComboBox
|
|
createPicker: function() {
|
|
var me = this;
|
|
var picker;
|
|
|
|
var pickerCfg = Ext.apply({
|
|
// proxmox overrides: display a grid for selection
|
|
xtype: 'gridpanel',
|
|
id: me.pickerId,
|
|
pickerField: me,
|
|
floating: true,
|
|
hidden: true,
|
|
store: me.store,
|
|
displayField: me.displayField,
|
|
preserveScrollOnRefresh: true,
|
|
pageSize: me.pageSize,
|
|
tpl: me.tpl,
|
|
selModel: me.pickerSelectionModel,
|
|
focusOnToFront: false
|
|
}, me.listConfig, me.defaultListConfig);
|
|
|
|
picker = me.picker || Ext.widget(pickerCfg);
|
|
|
|
if (picker.getStore() !== me.store) {
|
|
picker.bindStore(me.store);
|
|
}
|
|
|
|
if (me.pageSize) {
|
|
picker.pagingToolbar.on('beforechange', me.onPageChange, me);
|
|
}
|
|
|
|
// proxmox overrides: pass missing method in gridPanel to its view
|
|
picker.refresh = function() {
|
|
picker.getSelectionModel().select(me.valueCollection.getRange());
|
|
picker.getView().refresh();
|
|
};
|
|
picker.getNodeByRecord = function() {
|
|
picker.getView().getNodeByRecord(arguments);
|
|
};
|
|
|
|
// We limit the height of the picker to fit in the space above
|
|
// or below this field unless the picker has its own ideas about that.
|
|
if (!picker.initialConfig.maxHeight) {
|
|
picker.on({
|
|
beforeshow: me.onBeforePickerShow,
|
|
scope: me
|
|
});
|
|
}
|
|
picker.getSelectionModel().on({
|
|
beforeselect: me.onBeforeSelect,
|
|
beforedeselect: me.onBeforeDeselect,
|
|
focuschange: me.onFocusChange,
|
|
selectionChange: function (sm, selectedRecords) {
|
|
var me = this;
|
|
if (selectedRecords.length) {
|
|
me.setValue(selectedRecords);
|
|
me.fireEvent('select', me, selectedRecords);
|
|
}
|
|
},
|
|
scope: me
|
|
});
|
|
|
|
// hack for extjs6
|
|
// when the clicked item is the same as the previously selected,
|
|
// it does not select the item
|
|
// instead we hide the picker
|
|
if (!me.multiSelect) {
|
|
picker.on('itemclick', function (sm,record) {
|
|
if (picker.getSelection()[0] === record) {
|
|
picker.hide();
|
|
}
|
|
});
|
|
}
|
|
|
|
// when our store is not yet loaded, we increase
|
|
// the height of the gridpanel, so that we can see
|
|
// the loading mask
|
|
//
|
|
// we save the minheight to reset it after the load
|
|
picker.on('show', function() {
|
|
if (me.enableLoadMask) {
|
|
me.savedMinHeight = picker.getMinHeight();
|
|
picker.setMinHeight(100);
|
|
}
|
|
});
|
|
|
|
picker.getNavigationModel().navigateOnSpace = false;
|
|
|
|
return picker;
|
|
},
|
|
|
|
clearLocalFilter: function() {
|
|
var me = this,
|
|
filter = me.queryFilter;
|
|
|
|
if (filter) {
|
|
me.queryFilter = null;
|
|
me.changingFilters = true;
|
|
me.store.removeFilter(filter, true);
|
|
me.changingFilters = false;
|
|
}
|
|
},
|
|
|
|
isValueInStore: function(value) {
|
|
var me = this;
|
|
var store = me.store;
|
|
var found = false;
|
|
|
|
if (!store) {
|
|
return found;
|
|
}
|
|
|
|
// Make sure the current filter is removed before checking the store
|
|
// to prevent false negative results when iterating over a filtered store.
|
|
// All store.find*() method's operate on the filtered store.
|
|
if (me.queryFilter && me.queryMode === 'local' && me.clearFilterOnBlur) {
|
|
me.clearLocalFilter();
|
|
}
|
|
|
|
if (Ext.isArray(value)) {
|
|
Ext.Array.each(value, function(v) {
|
|
if (store.findRecord(me.valueField, v)) {
|
|
found = true;
|
|
return false; // break
|
|
}
|
|
});
|
|
} else {
|
|
found = !!store.findRecord(me.valueField, value);
|
|
}
|
|
|
|
return found;
|
|
},
|
|
|
|
validator: function (value) {
|
|
var me = this;
|
|
|
|
if (!value) {
|
|
return true; // handled later by allowEmpty in the getErrors call chain
|
|
}
|
|
|
|
// we normally get here the displayField as value, but if a valueField
|
|
// is configured we need to get the "actual" value, to ensure it is in
|
|
// the store. Below check is copied from ExtJS 6.0.2 ComboBox source
|
|
//
|
|
// we also have to get the 'real' value if the we have a mulitSelect
|
|
// Field but got a non array value
|
|
if ((me.valueField && me.valueField !== me.displayField) ||
|
|
(me.multiSelect && !Ext.isArray(value))) {
|
|
value = me.getValue();
|
|
}
|
|
|
|
if (!(me.notFoundIsValid || me.isValueInStore(value))) {
|
|
return gettext('Invalid Value');
|
|
}
|
|
|
|
return true;
|
|
},
|
|
|
|
initComponent: function() {
|
|
var me = this;
|
|
|
|
Ext.apply(me, {
|
|
queryMode: 'local',
|
|
matchFieldWidth: false
|
|
});
|
|
|
|
Ext.applyIf(me, { value: ''}); // hack: avoid ExtJS validate() bug
|
|
|
|
Ext.applyIf(me.listConfig, { width: 400 });
|
|
|
|
me.callParent();
|
|
|
|
// Create the picker at an early stage, so it is available to store the previous selection
|
|
if (!me.picker) {
|
|
me.createPicker();
|
|
}
|
|
|
|
if (me.editable) {
|
|
// The trigger.picker causes first a focus event on the field then
|
|
// toggles the selection picker. Thus skip expanding in this case,
|
|
// else our focus listner expands and the picker.trigger then
|
|
// collapses it directly afterwards.
|
|
Ext.override(me.triggers.picker, {
|
|
onMouseDown : function (e) {
|
|
// copied "should we focus" check from Ext.form.trigger.Trigger
|
|
if (e.pointerType !== 'touch' && !this.field.owns(Ext.Element.getActiveElement())) {
|
|
me.skip_expand_on_focus = true;
|
|
}
|
|
this.callParent(arguments);
|
|
}
|
|
});
|
|
|
|
me.on("focus", function(me) {
|
|
if (!me.isExpanded && !me.skip_expand_on_focus) {
|
|
me.expand();
|
|
}
|
|
me.skip_expand_on_focus = false;
|
|
});
|
|
}
|
|
|
|
me.mon(me.store, 'beforeload', function() {
|
|
if (!me.isDisabled()) {
|
|
me.enableLoadMask = true;
|
|
}
|
|
});
|
|
|
|
// hack: autoSelect does not work
|
|
me.mon(me.store, 'load', function(store, r, success, o) {
|
|
if (success) {
|
|
me.clearInvalid();
|
|
|
|
if (me.enableLoadMask) {
|
|
delete me.enableLoadMask;
|
|
|
|
// if the picker exists,
|
|
// we reset its minheight to the saved var/0
|
|
// we have to update the layout, otherwise the height
|
|
// gets not recalculated
|
|
if (me.picker) {
|
|
me.picker.setMinHeight(me.savedMinHeight || 0);
|
|
delete me.savedMinHeight;
|
|
me.picker.updateLayout();
|
|
}
|
|
}
|
|
|
|
var def = me.getValue() || me.preferredValue;
|
|
if (def) {
|
|
me.setValue(def, true); // sync with grid
|
|
}
|
|
var found = false;
|
|
if (def) {
|
|
found = me.isValueInStore(def);
|
|
}
|
|
|
|
if (!found) {
|
|
var rec = me.store.first();
|
|
if (me.autoSelect && rec && rec.data) {
|
|
def = rec.data[me.valueField];
|
|
me.setValue(def, true);
|
|
} else if (!me.allowBlank && ((Ext.isArray(def) && def.length) || def)) {
|
|
me.setValue(def);
|
|
if (!me.notFoundIsValid) {
|
|
me.markInvalid(gettext('Invalid Value'));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|