// vim: sw=4:ts=4:nu:nospell:fdc=4
/*global Ext:true */
/*jslint browser: true, devel:true, sloppy: true, white: true, plusplus: true */
/*
This file is part of Saki Grid MultiSearch Package
Copyright (c) 2014, Jozef Sakalos, Saki
Package: saki-grid-multisearch
Author: Jozef Sakalos, Saki
Contact: http://extjs.eu/contact
Date: 7. December 2014
Version: 2.2.0
Commercial License
Developer, or the specified number of developers, may use this file in any number
of projects during the license period in accordance with the license purchased.
Uses other than including the file in the project are prohibited.
See http://extjs.eu/licensing for details.
Change Log:
2.2.0 - 7. December 2014
* added support for grouped headers
* added support for checkbox selection model
* fixed some bugs and improved stability
2.1.2 - 16. October 2014
* added filterField synonym for filter config option
this is required for Architect as it already recognizes "filter"
* fixed tabbing-caused misalignment bug
* merged Ext 4 and 5 versions into one file
2.0.1 - 14. September 2014
* added getFilter method to store because Ext 4.2.2 does not
create this method by default
* added config option parseOperator to make parsing operators optional
this is required for Ext 4.2.2 because it does not send operators to
server - see proxy.Server::encodeFilters()
2.0.0 - 11. September 2014
* initial release
*/
/**
*
* This plugin adds a row of form fields between the grid header and
* view where user can type filter values on per-column basis. Text
* fields are created by default but any form field type can
* be configured.
*
* ##Usage - simple config scenario:
*
* Ext.define('MyApp.view.GridView', {
* extend:'Ext.grid.Panel'
* ,requires:['Ext.saki.grid.MultiSearch']
* ,plugins:[{
* ptype:'saki-gms'
* ,filterOnEnter:false
* // other valid MultiSearch options
* }]
* ,bind:{
* store:'{cities}'
* }
* ,columns:[{
* text:'City'
* ,dataIndex:'name'
* ,sortable:true
* ,filterField:{
* // any form field xtype
* xtype:'textfield'
* }
* },{
* text:'Country'
* ,dataIndex:'countryCode'
* ,sortable:true
* // boolean true creates textfield
* ,filterField:true
* }]
* // ... etc
* });
*
*
*
* ##Usage - initComponent scenario:
*
* Ext.define('MyApp.view.GridView', {
* extend:'Ext.grid.Panel'
* ,requires:['Ext.saki.grid.MultiSearch']
* ,initComponent:function() {
* var me = this
* ,config = {
* store:Ext.create('MyApp.store.MyStore', {
* pageSize:15
* ,remoteSort:true
* ,remoteFilter:true
* })
* ,plugins:[{
* ptype:'saki-gms'
* ,filterOnEnter:false
* // other valid MultiSearch options
* }]
* ,columns:[{
* text:'City'
* ,dataIndex:'name'
* ,sortable:true
* ,filterField:{
* // any form field xtype
* xtype:'textfield'
* }
* },{
* text:'Country'
* ,dataIndex:'countryCode'
* ,sortable:true
* // boolean true creates textfield
* ,filterField:true
* }]
* // ... etc
* } // eo config
* ;
* Ext.apply(me, config);
* me.callParent(arguments);
*
* } // eo function initComponent
* }); // eo define
*
*/
Ext.define('Ext.saki.grid.MultiSearch', {
extend:'Ext.container.Container'
,alternateClassName:'Ext.ux.grid.MultiSearch'
,alias:['plugin.saki-gms', 'plugin.ux-gms']
// see also custom methods:
// applyStore, updateStore, updateColumns
,config:{
/**
* @private
* @cfg {Ext.data.Store} store Automatically set from the parent grid
*/
store:null
/**
* @private
* @cfg {Ext.grid.header.Column[]} columns Automatically
* set from the parent grid
*/
,columns:null
}
/**
* @hide
* @private
* @cfg {String} layout
*/
,layout:'hbox'
/**
* @hide
* @private
* @cfg {String} dock Determines where to put filter fields. The only tested
* and reasonable position is 'top'
*/
,dock:'top'
,baseCls:'saki-gms-ct'
/**
* @cfg {Number}
* Time in milliseconds to wait after the user stops typing, before
* triggering the filtering
*/
,buffer:500
/**
* @cfg {String} clearItemIconCls CSS class to use for "Clear Filter" menu item.
*/
,clearItemIconCls:'icon-clear-filter'
/**
* @cfg {String} clearItemT Text to use for "Clear Filter" menu item.
* This text can be localized by an override.
*/
,clearItemT:'Clear Filter'
/**
* @cfg {Boolean}
* If true, filtering is not triggered as user types but
* after he presses Enter key
*/
,filterOnEnter:false
/**
* @cfg {Number}
* Height of the row with filter fields. Change this value depending on the
* theme you use in your application. The default value looks good with
* Ext 5 Crisp Theme. Set it to 21 for Ext 4 classic and gray themes.
*/
,height:24
/**
* @cfg {Boolean}
* Set it to false if you do not want column with multisearch settings menu
*/
,iconColumn:true
/**
* @cfg {String} inSeparator Character used as separator to delimit "in" operator
* items.
*/
,inSeparator:','
/**
* @cfg {RegExp} operatorRe Regular expression of recognized operators
*/
,operatorRe:/^(=|!=|<=|>=|<|>|in |like )/
/**
* @cfg {String} parseOperator Set it to false to not parse operator from the
* user-typed value. Normally, operator is sent to the server separate from the
* value, however, if you use the plugin with Ext 4.x, or if your server parses
* operators itself, set parseOperator to false.
*/
,parseOperator:true
/**
* @cfg {Number}
* Must be high enough to render the filter row under the grid header
*/
,weight:1000
/**
* called from setStore
* @param {[Ext.data.Store]} store
* @returns {Ext.data.Store}
* @private
*/
,applyStore:function(store) {
store = store || this.grid.getStore();
// Ext 4.x compatibility
// Ext 4 Store does not have getFilters method, so add it
if(!store.getFilters) {
store.getFilters = function() {
return this.filters;
};
}
return store;
} // eo function applyStore
/**
* called from setStore
* @param {Ext.data.Store} newStore
* @param {Ext.data.Store} oldStore
*/
,updateStore:function(newStore, oldStore) {
// handling necessary on store change comes here
var me = this
,cfg = {
filterchange:{
scope:this
,fn:me.onStoreFilterChange
}
};
if(oldStore) {
oldStore.un(cfg);
}
if(newStore) {
newStore.on(cfg);
// new store can be filtered so we set
// values of our fields from store filters
me.setValuesFromStore();
}
} // updateStore
/**
* store filterchange handler. Updates filter form fields from
* filters applied to the store.
* @private
*/
,onStoreFilterChange:function() {
var me = this;
if(!me.filtering) {
me.setValuesFromStore();
}
} // eo function onStoreFilterChange
/**
* Sets values of filter fields based on the
* filtering status of the store
* @private
*/
,setValuesFromStore:function() {
var me = this
,values = me.getStoreFilters()
;
if(values) {
me.setValues(values, true);
}
else {
me.clearValues(true);
}
} // eo function setValuesFromStore
/**
* Iterates through store filters and creates the values object
* that is suitable as argument of setValues(values) call
* @returns {Object|null} values
*/
,getStoreFilters:function() {
var me = this
,store = me.getStore()
,values = null
;
if(store) {
store.getFilters().each(function (filter) {
var property = filter.getProperty ? filter.getProperty() : filter.property
,operator = filter.getOperator ? filter.getOperator() : filter.operator
,value = filter.getValue ? filter.getValue() : filter.value
,space = ''
;
if ('in' === operator) {
value = value.join(',');
}
if (Ext.Array.contains(['in', 'like'], operator)) {
space = ' ';
}
values = values || {};
values[property] = (operator ? operator + space : '') + value;
});
}
return values;
} // eo function getStoreFilters
/**
* called from setColumns
* @private
*/
,updateColumns:function() {
var me = this
,headerCt = me.headerCt
,selModel = me.grid.getSelectionModel()
;
// don't get interrupted by events
headerCt.suspendEvents();
// re-add column for icon in header
if (me.iconColumn) {
me.iconCol = headerCt.add(me.getIconCol());
}
// remove old and add new columns
me.removeAll(true);
me.add(me.getFields());
// inject space for checkbox selection model
if('Ext.selection.CheckboxModel' === selModel.$className) {
//console.log('pushing checkbox column')
me.items.insert(selModel.injectCheckbox, Ext.widget({
itemId:'item-' + selModel.injectCheckbox
,xtype:'component'
,cls:'saki-gms-nofilter'
,height:me.height
}));
}
// ok for events now
headerCt.resumeEvents();
// full update of UI
me.setValuesFromStore();
me.grid.getView().refresh();
// let dom settle
Ext.Function.defer(function(){
me.syncCols();
me.syncUi();
}, 1);
} // eo function updateColumns
/**
* Called automatically by the framework as part of the plugins initialization.
* @private
* @param {Ext.grid.Panel} grid The grid this plugin is in.
*/
,init:function(grid) {
var me = this
,headerCt = grid.getView().getHeaderCt()
,extVersion = Ext.versions.extjs.major
;
// safety check (mainly) for Architect who does not have RegExp type, only string
if(Ext.isString(me.operatorRe)) {
me.operatorRe = new RegExp(me.operatorRe.replace(/(^\/|\/$)/g,''));
}
// save some vars in the instance
Ext.apply(me, {
grid:grid
,headerCt:headerCt
,extVersion:extVersion
});
// install listeners on headerCt to sync sizes and positions
headerCt.on({
afterlayout:{
fn:me.afterHdLayout
,scope:me
}
,afterrender:{
fn:me.afterHdRender
,scope:me
,single:true
}
,columnmove:{
fn:me.onColumnMove
,scope:me
}
});
grid.on({
scope:me
,reconfigure:me.onReconfigure
});
me.on({
afterrender:{
fn:me.onAfterRender
,scope:me
,single:true
}
});
me.onReconfigure(grid, grid.store, grid.columns);
// install convenience method(s) on the grid
/**
* MultiSearch plugin getter
* @member Ext.grid.Panel
* @returns {Ext.saki.grid.MultiSearch}
*/
grid.getFilter = function() {
return me;
};
} // eo function init
/**
* Grid reconfigure event listener. Main entry point
* of the plugin creation and reconfiguration.
* @private
* @param {Ext.grid.column.Column[]} columns
* @param {Ext.data.Store} store
*/
,onReconfigure:function(grid, store, columns) {
// first we need to set new columns
this.setColumns(columns);
// then new store
this.setStore(store);
} // eo function onReconfigure
/**
* Generates array of instances of fields to be used as items
* in the filter row.
* @private
* @returns {Ext.form.Field[]} Array of instantiated fields
*/
,getFields:function() {
var me = this
,items = []
,gridCols = me.headerCt.getGridColumns()
,selModel = me.grid.getSelectionModel()
;
Ext.Array.each(gridCols, function(item, i) {
var filter = item.filterField || item.filter
,cfg = {xtype:'component'}
,field = null
;
// filter:true - create textfield
if(true === filter) {
cfg.xtype = 'textfield';
}
// filter is an instance of component
else if(filter && filter.isComponent) {
cfg = filter;
}
// filter is string - that's xtype in fact
else if('string' === typeof filter) {
cfg.xtype = filter;
}
// filter is a config object
else if(Ext.isObject(filter)) {
Ext.apply(cfg, filter);
}
// otherwise column shouldn't be filtered
else {
cfg.cls = 'saki-gms-nofilter';
cfg.height = me.height;
}
if('iconCol' === item.itemId) {
Ext.apply(cfg, me.getIcon());
}
//if('component' !== cfg.xtype) {
Ext.apply(cfg, {
itemId:item.itemId ? item.itemId : item.dataIndex || 'item' + i
});
//}
field = Ext.widget(cfg);
if(me.filterOnEnter) {
field.on('specialkey', me.onSpecialKey, me);
}
else {
field.on('change', me.onChange, me, {buffer:me.buffer});
}
items.push(field);
});
return items;
} // eo function getFields
/**
* change handler. Triggers filtering and resets dirty state of the field.
* @private
* @param {Ext.form.Field} field Field firing the event
*/
,onChange:function(field) {
var me = this;
// do nothing if not dirty
if(field.isDirty()){
field.resetOriginalValue();
me.doFieldChange(field);
}
} // eo function onChange
/**
* Sets the underlying filter to the parsed value of the field.
* That can include adding an operator or removing the filter.
* @private
* @param {Ext.form.field.Field} field
*/
,doFieldChange:function(field) {
var me = this
,value = field.getSubmitValue()
,property = field.getItemId()
,parse = me.parseOperator
,filter
;
filter = parse ? me.parseUserValue(value) : {value:value};
filter.property = property;
// Ext 4 compat
filter.id = property;
me.setFilter(filter);
me.updateClearIcon(field);
} // eo function doFieldChange
/**
* Returns array of filter config objects from values in filter fields
* @private
* @returns {Ext.util.Filter[]} Config objects
*/
,getFilters:function() {
var me = this
,filters = []
;
me.items.each(function(item){
var filter;
if(item.isFormField) {
filter = me.getFilterFromField(item);
if(filter) {
filters.push(filter);
}
}
});
return filters;
} // eo function getFilters
/**
* Get filter configuration from the passed form field
* @private
* @param {Ext.form.field.Field} field
* @returns {null|Ext.util.Filter} Filter config object
*/
,getFilterFromField:function(field) {
var me = this
,value = field.getSubmitValue()
,filter
;
if(value) {
filter = me.parseUserValue(value);
filter.property = field.getItemId();
return filter;
}
return null;
} // eo function getFilterFromField
/**
* @private
* @param {Ext.util.Filter|Ext.util.Filter[]} filter Filter config object
* or array of them. If it is an array, existing filtering is cleared first
* before the passed array of filters is applied.
*
* When argument is a single (non-array) filter, it's value is checked and
* if empty then the the filter is removed. Otherwise it is added or updated.
*
* This method should not be called from outside as it does not update
* filter form fields. Use {@link #setValues setValues} instead.
*/
,setFilter:function(filter) {
var me = this
,store = me.getStore()
;
if(Ext.isArray(filter)) {
store.clearFilter(0 < filter.length);
store.addFilter(filter);
}
else {
me.filtering = true;
if(!filter.value) {
if(4 === me.extVersion) {
store.filters.removeAtKey(filter.property);
if(store.filters.getCount()) {
store.filter();
}
else {
store.clearFilter();
}
}
else {
store.removeFilter(filter.property);
}
}
else {
store.addFilter(filter);
}
me.filtering = false;
}
} // eo function setFilter
/**
* Clears the passed field by setting its value to '' (empty string).
* It also triggers filtering unless prevented by the second argument.
* @param {Ext.form.Field} field
* @param {Boolean} preventFilter True to NOT trigger filtering
*/
,clearField:function(field, preventFilter) {
var me = this;
if(field && Ext.isFunction(field.setValue) && !field.readOnly && !field.disabled) {
if(true === preventFilter) {
field.suspendEvents();
}
field.setValue('');
field.resetOriginalValue();
if(true === preventFilter) {
field.resumeEvents();
}
if(true !== preventFilter) {
me.doFieldChange(field);
}
}
} // eo function clearField
/**
* Sets values of filter fields from the passed objects
* and, by default, filters the store accordingly
* @param {Object} values Object name/value pairs
* @param {Boolean} preventFilter Set it to true
* if you do not want to trigger the filtering
*/
,setValues:function(values, preventFilter) {
var me = this
,field
;
if(values && Ext.isObject(values)) {
me.clearValues(true);
Ext.Object.each(values, function(key, value){
field = me.items.get(key);
if(field && Ext.isFunction(field.setValue)) {
if(true === preventFilter) {
field.suspendEvents();
}
field.setValue(value);
field.resetOriginalValue();
if(true === preventFilter) {
field.resumeEvents();
}
}
});
}
}
/**
* Clears values and, by default, clears also store filtering
* @param {Boolean} [preventFilter] True to prevent the
* actual filtering from occurring
*/
,clearValues:function(preventFilter) {
var me = this;
me.items.each(function(field){
me.clearField(field, preventFilter);
});
if(!preventFilter) {
me.getStore().clearFilter();
}
} // eo function clearValues
// ============================== UI related methods ==============================
/**
* Installs event handler to handle scrolling caused
* by tabbing through filter fields
* @private
*/
,onAfterRender:function() {
var me = this
,scrollerEl
,event
;
// Ext 4 does not have getScrollerEl function
if(!Ext.isFunction(me.getScrollerEl)) {
me.getScrollerEl = function() {
return me.layout.innerCt;
};
}
scrollerEl = me.getScrollerEl();
// different events are listend to for Ext 5 and Ext 4
event = 5 === me.extVersion ? 'scroll' : 'keyup';
scrollerEl.on(event, me.onFilterScroll, me);
} // eo function onAfterRender
/**
* Called when tabbing-through-fields induced scrolling occurred.
* Scrolls grid view in sync with GMS scroll
* @private
*/
,onFilterScroll:function() {
var me = this
,scrollLeft = me.getScrollerEl().getScrollLeft()
;
if(5 === me.extVersion) {
me.grid.getView().scrollTo(scrollLeft, 0);
}
else {
me.grid.getView().getEl().scrollTo('left', scrollLeft);
}
} // eo function onFilterScroll
/**
* User can type operator and value in the filter form field.
* This method parses (optional) operator and value string
* returning {value:xxx, operator:yyy}
* @param {String} v The value to parse
* @returns {Object} Object with value and optional operator
*/
,parseUserValue:function(v) {
var me = this
,re = me.operatorRe
,sep = me.inSeparator
,va
,operator
,value
,trim = Ext.String.trim
;
if(!v) {
return {value:''};
}
va = v.split(re);
if(2 > va.length) {
return {value:v};
}
value = trim(va[2]);
operator = trim(va[1]);
if('in' !== operator) {
return {
value:value
,operator:operator
};
}
return {
value:trim(value).split(sep)
,operator:operator
};
} // eo function parseUserValue
/**
* specialkey hander. Used only if {@link #filterOnEnter filterOnEnter:true}
* @private
* @param {Ext.form.field.Field} field
* @param {Ext.EventObject} e
*/
,onSpecialKey:function(field, e) {
var me = this;
if(Ext.EventObject.ENTER === e.getKey()) {
me.setFilter(me.getFilters());
}
} // eo function onSpecialKey
/**
* click handler for icon column icon. Shows the filter menu.
* @private
* @param {Ext.EventObject} e
*/
,onIconClick:function(e) {
var me = this;
if(me.filterMenu) {
me.filterMenu.showBy(e.getTarget('div.x-tool'));
}
} // eo function onIconClick
/**
* Override it if you need a different icon column
* @template
* @returns {Object} Icon column config object
*/
,getIconCol:function() {
return {
width:21
,menuDisabled:true
,hideable:false
,sortable:false
,itemId:'iconCol'
,draggable:false
,hoverCls:''
,baseCls:''
};
} // eo function getIconCol
/**
* Override it if you need a different icon. For example,
* if you use Font Icons then you can override the icon configuration
* returned by this method.
* @template
* @returns {Object} Icon config object
*/
,getIcon:function() {
return {
autoEl:{
tag:'div'
,children:[{
tag:'img'
,src:'data:image/gif;base64,R0lGODlhAQABAID/AMDAwAAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=='
,cls:'saki-gms-icon x-tool-img x-tool-gear'
}]
}
,cls:'saki-gms-nofilter x-tool'
,overCls:'x-tool-over'
,listeners:{
click:{
fn:this.onIconClick
,scope:this
,element:'el'
}
}
};
} // eo function getIcon
* @private
* Creates filter control menu
* @todo: implement saving named filters
*/
,createFilterMenu:function() {
var me = this
,items = []
;
if(!me.filterMenu) {
items.push({
text:me.clearItemT
,iconCls:me.clearItemIconCls
,scope:me
,handler:function(){
me.clearValues(true);
me.getStore().clearFilter();
}
});
me.filterMenu = Ext.widget('menu', {
defaultAlign:'tr-br?'
,items:items
});
}
} // eo function createFilterMenu
/**
* Shows/hides clear icon in the passed field.
* @private
* @param {Ext.form.field.Field} field
*/
,updateClearIcon:function(field) {
var me = this
,cls = 'saki-gms-hasvalue'
,body = field.bodyEl ? field.bodyEl.down('input') : null//.up('div')
,value = field.getValue ? field.getValue() : null
;
if(body) {
body = me.extVersion === 4 ? body.up('td') : body.up('div');
if (false !== field.clearIcon) {
if (!field.clearIcon) {
field.clearIcon = body.createChild({
tag: 'div', cls: 'saki-gms-clear'
});
field.clearIcon.on('click', Ext.bind(me.clearField, me, [field]));
body.applyStyles({position: 'relative'});
}
if (value && !field.readOnly && !field.disabled) {
body.addCls(cls);
}
else {
body.removeCls(cls);
}
}
}
} // eo function updateClearIcon
/**
* Marks/unmarks grid column filtered by adding/removing
* css class to/from it
* @private
* @param {Ext.form.field.Field} field
*/
,markFiltered:function(field) {
var me = this
,value = field.getValue ? field.getValue() : null
,colEl = me.headerCt.getGridColumns()[me.items.indexOf(field)]//.getEl()
;
if(!colEl) {
return;
}
colEl = colEl.getEl();
colEl.removeCls('saki-gms-filtered');
if(value) {
colEl.addCls('saki-gms-filtered');
}
else {
colEl.removeCls('saki-gms-filtered');
}
} // eo function markFiltered
/**
* Synchronizes UI of filter with actual state of filtering.
* It shows or hides field clear icon.
* @private
*/
,syncUi:function() {
var me = this;
me.items.each(function(field) {
if(field && field.rendered) {
me.updateClearIcon(field);
me.markFiltered(field);
}
});
} // eo function syncUi
/**
* Synchronizes widths of filter fields with
* widths of columns.
* @private
*/
,syncCols:function() {
var me = this
,cols = me.headerCt.getGridColumns()//.headerCt.items
,hdWidth
;
if(!me.rendered) {
return;
}
hdWidth = me.headerCt.layout.innerCt.getWidth();
Ext.Array.each(cols, function(col, i){
var filter = me.items.getAt(i);
if(filter) {
filter.setWidth(col.getWidth());
}
});
me.layout.targetEl.setWidth(hdWidth);
} // eo function syncCols
/**
* Grid view scroll event listener that synchronizes
* grid view, header and filters scrolling.
* @private
*/
,onGridScroll:function() {
var me = this
,scroll = me.grid.getView().getEl().getScroll()
,scrollEl = me.getLayout().innerCt
;
scrollEl.scrollTo('left', scroll.left);
} // eo function onGridScroll
/**
* Move column event listener that moves filter
* in sync with column moves
* @private
*/
,onColumnMove:function() {
var me = this;
me.syncOrder();
me.grid.getView().refresh();
me.syncUi();
me.syncCols();
// syncing scrolling must be defered 1ms
Ext.Function.defer(me.onGridScroll, 1, me);
} // eo function onColumnMove
/**
* Synchronizes the order of filters with
* the order of the grid columns
* @private
*/
,syncOrder:function() {
var me = this
,cols = me.headerCt.getGridColumns()
,i
,field
;
for(i = 0; i < cols.length; i++) {
field = me.items.get(cols[i].dataIndex);
if(field) {
me.items.insert(i, field);
}
}
me.doLayout();
} // eo function syncOrder
/**
* Synchronizes columns and other UI features
* whenever the header changes layout
* @private
*/
,afterHdLayout:function() {
var me = this;
if(!me.grid.reconfiguring) {
me.syncCols();
me.syncUi();
}
} // eo function afterHdLayout
/**
* Runs once after grid header rendering. Adds gms to the
* grid header and performs other initialization.
* @private
*/
,afterHdRender:function() {
var me = this
,grid = me.grid
;
grid.dockedItems.add(me);
if(0 < Ext.versions.extjs.minor && 4 !== me.extVersion) {
grid.getView().on({
scroll: {
fn: me.onGridScroll, scope: me
}
});
}
else {
grid.getView().on({
bodyscroll: {
fn: me.onGridScroll, scope: me
}
});
}
me.createFilterMenu();
} // eo function afterHdRender
});
// eof