/*
* Copyright 2013 Anyware Services
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* This abstract class is used by fields providing a combo box for single or multiple selections with querying and type-ahead support.
* Implement the #getStore method.
*/
Ext.define('Ametys.form.AbstractQueryableComboBox', {
extend: 'Ametys.form.AbstractFieldsWrapper',
canDisplayComparisons: true,
/**
* @cfg {Boolean} [multiple=false] True to allow multiple selection.
*/
multiple: false,
/**
* @cfg {Number} [minChars=2] The minimum number of characters the user must type before autocomplete activates.
*/
minChars: 2,
/**
* @cfg {Number} [pageSize=0] If greater than 0, a Ext.toolbar.Paging is displayed in the footer of the dropdown list and the filter queries will execute with page start and limit parameters.
*/
pageSize: 0,
/**
* @cfg {Number} [maxResult=50] The maximum number of records to display in the dropdown list.
*/
maxResult: 50,
/**
* @cfg {String} noResultText The text when there is no result found.
*/
noResultText: "{{i18n PLUGINS_CORE_UI_FORM_QUERYABLE_COMBOBOX_NO_RESULT}}",
/**
* @cfg {String} loadingText The text while loading results
*/
loadingText: "{{i18n PLUGINS_CORE_UI_FORM_QUERYABLE_COMBOBOX_LOADING}}",
/**
* @cfg {String} emptyText The default text to place into an empty field.
*/
/**
* @cfg {Number} [valueField=id] The underlying data value name to bind to the ComboBox.
*/
valueField: 'id',
/**
* @cfg {Number} [displayField=label] The underlying data field name to bind to the ComboBox.
*/
displayField: 'label',
/**
* @cfg {Boolean/String} [stacked=false] If set to `true`, the labeled items will fill to the width of the list instead of being only as wide as the displayed value.
*/
/**
* @cfg {String} [growMin=false] If not set to `false`, the min height in pixels of the box select
*/
/**
* @cfg {String} [growMax=false] If not set to `false`, the max height in pixels of the box select
*/
/**
* @cfg {String} [queryMode=remote] The query mode in which the ComboBox uses the configured Store
*/
/**
* @cfg {Boolean} [anyMatch=true] True to allow matching of the typed characters at any position in the valueField's value (with {@link #cfg-queryMode} local only)
*/
anyMatch: true,
/**
* @cfg {Boolean} [triggerOnClick=true] Set to `true` to activate the trigger when clicking in empty space in the field.
*/
triggerOnClick: true,
/**
* @cfg {Boolean} [hideTrigger=false] Set to `true` to hide the trigger
*/
/**
* @cfg {Boolean} [lines=2] When multiple, the number of line to display
*/
/**
* @property {Ext.form.field.Tag} combobox The queryable combobox
* @private
*/
/**
* @cfg {Object} [listConfig] A set of properties that will be passed to the boundlist configuration
*/
constructor: function(config)
{
config.height = config.height || (config.multiple ? 22 * (config.lines || (config.searchTool ? 1 : 2)) + 2: 24) + this._getHeightDiff(config.labelAlign);
config.minHeight = config.minHeight || config.height;
config.resizable = (config.resizable === false || config.resizable === "false") ? false : true;
this._isResizable = config.resizable;
config.resizable = null; // The default resizable config is for the component version
config.editable = (config.editable === false || config.editable === "false") ? false : true;
config.cls = config.cls || "";
config.cls += " ametys-abqc-field";
this.callParent(arguments);
},
/**
* @private
* Compute the diff for height
* @param {String} labelAlign The new label position
*/
_getHeightDiff: function(labelAlign)
{
var size = 24;
if (labelAlign == 'top' && !this.heightIncludeLabel)
{
this.heightIncludeLabel = true;
return size;
}
else if (labelAlign != 'top' && this.heightIncludeLabel)
{
this.heightIncludeLabel = false;
return - size;
}
else
{
return 0;
}
},
setConfig: function(config)
{
var val = this._getHeightDiff(config.labelAlign);
if (val)
{
config.height = (config.height || (this.rendered ? this.getHeight() : this.getInitialConfig('height'))) + val;
config.minHeight = config.minHeight || config.height;
this.setHeight(config.height);
this.setMinHeight(config.minHeight);
}
this.callParent(arguments);
},
initComponent: function()
{
this.items = this.getItems();
this.callParent(arguments);
},
/**
* Get the items composing the fields
* @return {Object[]} The items
*/
getItems: function ()
{
this.combobox = Ext.create('Ext.form.field.Tag', this.getComboBoxConfig());
// https://issues.ametys.org/browse/CMS-8760 [Widget] Typing a comma in the select-referencetable-content widget
// forcing delimiterRegexp to `null` will prevent a call to #setValue on the input before the comma and to send a wrong request to the server
this.combobox.delimiterRegexp = null;
this.combobox.on("afterrender", this._onComboboxRender, this);
this.combobox.getStore().on("load", this._onComboboxStoreLoaded, this);
var items = [this.combobox];
if (this._isResizable)
{
items.push(
{
xtype: 'splitter',
cls: "x-field-aqcb-splitter",
height: 0,
border: true,
performCollapse: false,
collapseDirection: 'top',
collapseTarget: 'prev',
width: 40,
size: '100%',
tracker: { xclass: 'Ametys.form.AbstractQueryableComboBox.SplitterTracker', componentToResize: this }
}
);
}
return [{
xtype: 'container',
flex: 1,
layout: {
type: 'vbox',
align: 'stretch'
},
itemId: 'items',
items: items
}];
},
/**
* @private
* Listener after render
*/
_onComboboxRender: function()
{
if (this.multiple)
{
var me = this.combobox,
ddGroup = 'ametys-box-select-' + me.getId();
new Ext.dd.DragZone(me.listWrapper, {
ddGroup: ddGroup,
getDragData: function(e)
{
var sourceEl = e.getTarget(".x-tagfield-item", 10), d;
if (sourceEl)
{
d = sourceEl.cloneNode(true);
d.id = Ext.id();
return (me.dragData = {
sourceEl: sourceEl,
repairXY: Ext.fly(sourceEl).getXY(),
ddel: d,
rec: me.getRecordByListItemNode(sourceEl)
});
}
},
getRepairXY: function()
{
return me.dragData.repairXY;
}
});
new Ext.dd.DropZone(me.listWrapper, {
ddGroup: ddGroup,
getTargetFromEvent: function(e)
{
return e.getTarget('.x-tagfield-item') || e.getTarget('.x-tagfield-input') || e.getTarget('.x-tagfield-list');
},
onNodeEnter : function(target, dd, e, data)
{
var t = Ext.fly(target);
var r = t.getRegion();
if (t.hasCls('x-tagfield-item') && e.getX() > r.left + (r.right - r.left) / 2)
{
t.removeCls('x-tagfield-target-hoverbefore');
t.addCls('x-tagfield-target-hoverafter');
}
else
{
t.addCls('x-tagfield-target-hoverbefore');
t.removeCls('x-tagfield-target-hoverafter');
}
},
onNodeOut : function(target, dd, e, data)
{
Ext.fly(target).removeCls(['x-tagfield-target-hoverbefore', 'x-tagfield-target-hoverafter']);
},
onNodeOver : function(target, dd, e, data)
{
this.onNodeEnter(target, dd, e, data);
return Ext.dd.DropZone.prototype.dropAllowed;
},
onNodeDrop : function(target, dd, e, data)
{
var targetRecord;
var t = Ext.get(target);
if (t.hasCls("x-tagfield-item"))
{
targetRecord = me.getRecordByListItemNode(target)
}
var currentValue = me.getValue();
var movedValue = data.rec.get(me.valueField);
var newPosition = targetRecord != null ? currentValue.indexOf(targetRecord.get(me.valueField)) : currentValue.length;
var currentPosition = currentValue.indexOf(movedValue);
currentValue = Ext.Array.remove(currentValue, movedValue);
newPosition += (newPosition <= currentPosition ? 0 : -1) + (newPosition != currentPosition && t.hasCls('x-tagfield-target-hoverafter') ? 1 : 0);
currentValue = Ext.Array.insert(currentValue, newPosition, [movedValue]);
// This is to avoid setValue to be pointless
me.suspendEvents(false);
me.setValue(null);
me.resumeEvents();
me.setValue(currentValue);
return true;
}
});
}
},
/**
* Get select combo box
* @return {Ext.form.field.Tag} The box select
* @private
*/
getComboBoxConfig: function ()
{
var minChars = this.minChars || 3;
if (Ext.isString(minChars))
{
minChars = parseInt(this.minChars);
}
return {
queryMode: this.queryMode || 'remote',
anyMatch: this.anyMatch,
minChars: minChars,
delimiter: ',',
style: {
marginBottom: 0
},
autoLoadOnValue: true,
encodeSubmitValue: true,
editable: this.editable,
selectOnFocus: this.editable,
autoSelect: false,
multiSelect: this.multiple,
pageSize: this.pageSize,
growMin: this.growMin && !isNaN(parseInt(this.growMin)) ? parseInt(this.growMin) : null,
growMax: this.growMax && !isNaN(parseInt(this.growMax)) ? parseInt(this.growMax) : null,
grow: false,
stacked: this.stacked || this.stacked == 'true',
store: this.getStore(),
valueField: this.valueField,
displayField: this.displayField,
listeners: {
'beforedestroy': function(combo) {
combo.store.destroy();
}
},
labelTpl: this.getLabelTpl(),
tipTpl: this.getTipTpl(),
labelHTML: true,
flex: 1,
allowBlank: this.allowBlank,
emptyText: this.emptyText,
listConfig: Ext.applyIf(this.listConfig || {}, {
loadMask: true,
loadingText: this.loadingText,
emptyText: '<span class="x-tagfield-noresult-text">' + this.noResultText + '<span>',
enterCanDeselect: false
}),
readOnly: this.readOnly || false,
triggerOnClick: !this.editable ? true : this.triggerOnClick,
hideTrigger: this.hideTrigger
};
},
/**
* Get the remote store.
* Should ALWAYS be a newly created store, since destroying this component will destroy the store.
* @return {Ext.data.Store} The remote store.
* @protected
* @template
*/
getStore: function ()
{
throw new Error("The method #getStore is not implemented in " + this.self.getName());
},
/**
* Get the template for selected field
* @protected
* @template
*/
getLabelTpl: function ()
{
return null;
},
/**
* Get the tooltip template for selected field
* @protected
* @template
*/
getTipTpl: function()
{
return undefined; // no tip
},
markInvalid: function (msg)
{
this.callParent(arguments);
// Some widgets that inherit from this class might have the combobox set to null depending on the context
if (this.combobox)
{
this.combobox.markInvalid(msg);
}
},
/**
* Specifically focus the box select field when an item is selected.
* @param {Ext.form.field.Tag} field The combo box field.
* @param {Ext.data.Model[]} records The selected records.
* @private
*/
_onValueSelectionChange: function(field, records)
{
// Focus the field when a box item is selected, so that we always get the blur event.
if (records.length > 0)
{
this.focus();
}
},
/**
* @inheritdoc
* Sets a data value into the field and update the comboxbox field
*/
setValue: function (value)
{
var me = this;
value = Ext.isEmpty(value) ? [] : Ext.Array.from(value);
// Values can be either String (of id) or the model
var ids = [];
Ext.Array.each(value, function(v) {
if (Ext.isString(v))
{
ids.push(v);
}
else
{
ids.push(v[me.valueField]);
me.combobox.getStore().add(v);
}
});
me.callParent([ids]);
me.combobox.setValue(ids);
},
/**
* @protected
* Convert a value to be comparable
*/
_convertToComparableValue: function(item)
{
if (!item)
{
return "";
}
else if (Ext.isString(item))
{
return item;
}
else
{
return item[this.valueField]; // this is a guess since value are never records
}
},
_updateComparisonRendering: function()
{
if (!this.combobox || !this.combobox.bodyEl)
{
return;
}
if (this._baseValue !== undefined || this._futureValue !== undefined)
{
var base = this._baseValue !== undefined;
var comparison = Ametys.form.widget.Comparison.compareCanonicalValues(this.getValue(), base ? this._baseValue : this._futureValue, base, Ext.bind(this._convertToComparableValue, this));
for (var c = 1; c <= comparison.length; c++)
{
var elt = this.combobox.bodyEl.query('.x-tagfield-item:nth-child(' + c + ')', false, true);
if (elt == null)
{
// not really rendered
return;
}
elt.removeCls(["ametys-tagfield-new", "ametys-tagfield-old", "ametys-tagfield-mod"]);
switch (comparison[c-1])
{
case "added": elt.addCls("ametys-tagfield-new"); break;
case "moved": elt.addCls("ametys-tagfield-mod"); break;
case "deleted": elt.addCls("ametys-tagfield-old"); break;
default:
case "none": break;
}
}
}
},
afterRender: function()
{
this.callParent(arguments);
this._updateComparisonRendering();
},
/**
* When used in readonly mode, settting the comparison value will display ins/del tags
* @param {String} otherValue The value to compare the current value with
* @param {boolean} base When true, the value to compare is a base version (old) ; when false it is a future value
*/
setComparisonValue: function(otherValue, base)
{
if (base)
{
this._baseValue = otherValue || null;
this._futureValue = undefined;
}
else
{
this._baseValue = undefined;
this._futureValue = otherValue || null;
}
this.combobox.updateValue();
},
_onComboboxStoreLoaded: function()
{
window.setTimeout(Ext.bind(this._updateComparisonRendering, this), 1);
},
getValue: function ()
{
if (this.combobox.getStore().isLoading() || !this.combobox.getStore().isLoaded())
{
return this.multiple ? this.value : (this.value && this.value.length > 0 ? this.value[0] : null);
}
else
{
// tag field is now always return multiple values, but that's not fine for ours widgets
var value = this.combobox.getValue();
return this.multiple ? value : (value && value.length > 0 ? value[0] : null);
}
},
getErrors: function (value)
{
value = value || this.getValue();
// Some widgets that inherit from this class might have the combobox set to null depending on the context
if (this.combobox)
{
return Ext.Array.merge(this.callParent(arguments), this.combobox.getErrors(value));
}
else
{
return this.callParent(arguments);
}
},
getSubmitValue: function ()
{
return this.multiple ? Ext.encode(this.getValue()) : (this.getValue() || '');
},
getReadableValue: function (separator)
{
separator = separator || ',';
var readableValues= [];
var value = this.combobox.getValue();
if (value && Ext.isArray(value))
{
var readableValues= []
for (var i=0; i < value.length; i++)
{
var index = this.combobox.getStore().find(this.valueField, value[i]);
var r = this.combobox.getStore().getAt(index);
if (r)
{
readableValues.push(Ext.String.escapeHtml(r.get(this.displayField)));
}
}
}
else if (value)
{
var index = this.combobox.getStore().find(this.valueField, value);
var r = this.combobox.getStore().getAt(index);
if (r)
{
readableValues.push(Ext.String.escapeHtml(r.get(this.displayField)));
}
}
return readableValues.join(separator);
}
});