// 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-search Package

 Copyright (c) 2014, Jozef Sakalos, Saki

 Package:  saki-grid-search
 Author:   Jozef Sakalos, Saki
 Contact:  http://extjs.eu/contact
 Date:     05. May 2014

 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 a project are prohibited.
 See http://extjs.eu/licensing for details.
 */

/**
 * # Grid Search Plugin
 *
 * The plugin consists of search field where user types the search value,
 * and the button with menu containing the list of fields to search. User can
 * select in which fields to search by checking/unchecking the checkboxes.
 *
 * The plugin is pre-configured for use with bottom paging toolbar when it
 * requires no additional config options for the best user experience.
 *
 * However, the plugin is highly configurable so it can fit virtually any
 * usage scenario.
 *
 * Usage:
 *
 *      Ext.define('My.Grid', {
 *          extend:'Ext.grid.Panel'
 *         ,requires:['Ext.saki.grid.Search']
 *
 *         ,store: // your store here
 *         ,columns:[] // your columns here
 *
 *         ,plugins:[{
 *             ptype:'saki-gridsearch'
 *             // other plugin configuration options here
 *         }]
 *      });
 */
Ext.define('Ext.saki.grid.Search',{
     extend:'Ext.AbstractPlugin'
    ,alternateClassName:'Ext.ux.grid.Search'
    ,alias:['plugin.saki-gridsearch', 'plugin.ux-gridsearch']

    ,uses:[
         'Ext.form.field.Trigger'
        ,'Ext.tip.QuickTipManager'
    ]

    /**
     * @cfg {String} targetCt Target container selector.
     * {@link Ext.ComponentQuery Ext.ComponentQuery} compatible selector that
     * returns container (usually toolbar) in which to place the search field
     * and button with fields menu.
     *
     * The default value causes the gridsearch is placed in the first bottom
     * toolbar, that is most likely the paging toolbar.
     */
    ,targetCt:'toolbar[dock=bottom]'

    /**
     * @cfg {String} targetCtScope Where to look for the
     * {@link #targetCt target container}. Valid
     * values are 'grid' and 'global'.
     *
     * If the targetCtScope is 'grid' then {@link #targetCt
     * targetCt} selector is used down from the grid, if it is 'global'
     * then an attempt is made to find the target container anywhere
     * in the whole application.
     */
    ,targetCtScope:'grid'

    /**
     * @cfg {Boolean} autoCreate If true and {@link #targetCt target container} is not
     * found, the plugin creates toolbar and adds it to the grid according to targetCt
     * dock value (top, right, bottom, left) or bottom if selector does not contain
     * a dock value. The search input is added to the toolbar at the
     * {@link #searchPosition searchPosition}.
     */
    ,autoCreate:true

    /**
     * @cfg {String/Number} searchPosition Search input field position within the
     * {@link #targetCt target container}. Valid values are: 'first' or 'last' to prepend
     * or append the search field to the items of the container.
     *
     * If it is a number, the search field is inserted at that index.
     *
     * If it is a string other than 'first' or 'last' then the string is used as
     * {@link Ext.ComponentQuery Ext.ComponentQuery} selector to find the container
     * item. If the item is found the search field is places **before** that item.
     *
     * If everything else fails the search field is added as the last item of
     * the container.
     *
     * The default searchPosition plays best with standard {@link Ext.toolbar.Paging
     * paging toolbar} as it places the search after the refresh button.
     */
    ,searchPosition:'tbfill'

    /**
     * @cfg {String/undefined} searchAlign Set it to 'right' if you want
     * a tbfill ('->') to be prepended before the search button.
     */

    /**
     * @cfg {Boolean} triggerHidden Set the value to true if you want to hide
     * trigger of the search field. Trigger is hidden automatically if
     * {@link #triggerSearch triggerSearch} is set to 'auto'
     */
    ,triggerHidden:false

    /**
     * Prepends the toolbar separator before the button. Set it to false
     * if you do not want the separator.
     * @cfg {Boolean} beforeSeparator
     */
    ,beforeSeparator:true

    /**
     * Appends the toolbar separator after the search field. Set it to true
     * if you want the separator.
     * @cfg {Boolean} afterSeparator
     */

    /**
     * @cfg {String} triggerSearch If it is 'auto' then searching is triggered
     * after user types {@link #minChars minChars} characters. However, pressing
     * Enter triggers the search even if user has typed less characters.
     *
     * If it is 'manual' user must press Enter or click the search trigger to
     * initiate the search.
     */
    ,triggerSearch:'auto'

    /**
     * @cfg {Number} minChars Number of characters to initiate the search if
     * {@link #triggerSearch triggerSearch} is set to 'auto'.
     */
    ,minChars:2

    /**
     * @cfg {Boolean} focusOnLoad If true, the plugins tries to focus the
     * search field on grid store load. Set it to false to disable this feature.
     */
    ,focusOnLoad:true

    /**
     * @cfg {Boolean} buttonHidden Set it to true if you want to hide the button
     * with field names menu.
     */
    ,buttonHidden:false

    /**
     * @cfg {String} buttonIconCls iconCls to apply to the fields menu button. Used
     * only if {@link #fontIcons fontIcons} is set to false.
     */

    /**
     * @cfg {String} buttonCls cls to apply to the fields menu button.
     */
    ,buttonCls:'saki-gridsearch-btn'

    /**
     * @cfg {Boolean} fontIcons Set it to false if you do not want to use font icons.
     */
    ,fontIcons:true

    /**
     * @cfg {String/Number} buttonGlyph Glyph to use for loupe icon. Only used if
     * {@link #fontIcons fontIcons} is set to true
     */
    ,buttonGlyph:'xe800@fontello'

    /**
     * @cfg {String} mode Mode of searching. Valid values are 'remote' when a request
     * is sent to the server to perform the search, or 'local' when the grid store
     * is searched locally.
     */
    ,mode:'remote'

    /**
     * @cfg {String} queryParam Name of parameter that contains the search value. Only
     * used if {@link #mode mode} is set to 'remote'.
     */
    ,queryParam:'query'

    /**
     * @cfg {String} fieldsParam Name of parameter that contains encoded array of fields
     * to search in. Only used if {@link #mode mode} is set to 'remote'.
     */
    ,fieldsParam:'fields'

    /**
     * @cfg {String/String[]} checkIndexes Array of dataIndex values to initially check
     * in the menu or 'all' to check all menu items.
     */
    ,checkIndexes:'all'

    /**
     * @cfg {String[]} disableIndexes Array of dataIndexes to exclude from both search and
     * menu items.
     */
    ,disableIndexes:[]

    /**
     * @cfg {String} menuStyle Set it to 'radio' if you always want to search in one field
     * only. By default, user can select any number of fields to search in.
     */

    /**
     * The date format to use when converting date fields to text before searching.
     * See {@link Ext.Date Ext.Date} header for available format codes.
     *
     * This value is used if set. If it is undefined the attempt is made to get the date format
     * from grid column configuration then from record field configuration. If everything else
     * fails the format is taken from {@link Ext.Date#defaultFormat Ext.Date.defaultFormat}.
     * @cfg {String} dateFormat
     */

    /**
     * @cfg {Number} buffer Number of milliseconds to wait before search field change
     * is processed. Multiple changes that occur within this period are treated as one.
     * It prevents triggering of search on each fast typed character.
     *
     * Used only if {@link #triggerSearch triggerSearch} is set to 'auto'
     */
    ,buffer:400

    /**
     * @cfg {String} inputType Type of the search field. Valid values are: 'search'
     * for <input type="search"> or 'text' for <input type="text">. Set it
     * to 'text' if want to support old browsers that do not handle "search" fields.
     *
     * Standard text fields do not have clear icon.
     */
    ,inputType:'search'

    /**
     * @cfg {Boolean} showSelectAll Set it to false if you do not want "Select all"
     * menu item.
     */
    ,showSelectAll:true

    /**
     * @cfg {String} noCtText Error message to display if the
     * {@link #targetCt target container} is not found.
     *
     * Override this if you want to localize the plugin.
     */
    ,noCtText:'Target container was not found. Check values of '
          +'autoCreate and targetCt config options.'

    /**
     * @cfg {String} searchText Text to display on the search button.
     *
     * Override this if you want to localize the plugin.
     */
    ,searchText:'Search'

    /**
     * @cfg {String} selectAllText Text to display in "Select all" menu item.
     *
     * Override this if you want to localize the plugin.
     */
    ,selectAllText:'Select all'

    /**
     * @cfg {Boolean/undefined} disableTip Set it to true if you want to disable
     * the search field tooltip
     */

    /**
     * @cfg {String} autoTipText Tooltip text to display if
     * {@link #triggerSearch triggerSearch} is set to 'auto'
     *
     * Override this if you want to localize the plugin.
     */
    ,autoTipText:'Type at least {0} characters'

    /**
     * @cfg {String} manualTipText Tooltip text to display if
     * {@link #triggerSearch triggerSearch} is set to 'manual'
     *
     * Override this if you want to localize the plugin.
     */
    ,manualTipText:'Type a text and press Enter'

    /**
     * Reference to the search field
     * @property {Ext.form.field.Trigger} field
     * @readonly
     */

    /**
     * Reference to the button with fields menu
     * @property {Ext.button.Button} button
     * @readonly
     */

    /**
     * Reference to the target container
     * @property {Ext.toolbar.Toolbar/Ext.container.Container} targetCt
     * @readonly
     */

    /**
     * Reference to the menu with fields to search
     * @property {Ext.menu.Menu} menu
     * @readonly
     */
    /**
     * Initializes the plugin
     * @private
     * @param {Ext.grid.Panel} cmp
     */
    ,init:function(cmp) {
        var  me = this
            ,targetCt
        ;

        me.callParent(arguments);

        me.targetCt = targetCt = me.getTargetCt();

        if(!targetCt) {
            Ext.Error.raise(me.noCtText);
        }

        me.disableIndexes = Ext.Array.slice(me.disableIndexes);

        me.createField();

        cmp.on('reconfigure', me.reconfigure, me);

    } // eo function init

    /**
     * Call this functions if you want to execure the search
     * programmatically. Value of the search field is set to
     * the the query argument.
     * @param {[String]} query The text to search for
     */
    ,doSearch:function(query) {
        var  me = this
            ,menu = me.menu
            ,field = me.field
            ,store = me.getCmp().getStore()
            ,proxy = store.getProxy()
            ,grid = me.getCmp()
            ,queryParam = me.queryParam
            ,fieldsParam = me.fieldsParam
            ,fields = menu.getFields()
        ;
        if(query) {
            field.setRawValue(query);
        }
        else {
            query = field.getValue();
        }

        // remote search
        if('remote' === me.mode) {
            delete proxy.extraParams[queryParam];
            delete proxy.extraParams[fieldsParam];

            if(query) {
                proxy.extraParams[queryParam] = query;
            }
            if(fields) {
                proxy.extraParams[fieldsParam] = Ext.encode(fields);
            }
            store.loadPage(1);
        }

        // local search
        else if(fields) {
            store.clearFilter();
            if(query) {

                // we need custom filter function as we implement OR condition in fact
                store.filterBy(function(record){
                    var  retval = false
                        ,recordVal
                        ,re
                        ,dateFormat = me.dateFormat
                    ;

                    // fields loop
                    // if any part of any selected field matches, record matches
                    Ext.each(fields, function(field){
                        if(retval) {
                            return;
                        }
                        recordVal = record.get(field);

                        // we need to format dates for a meaningful search
                        if(Ext.isDate(recordVal)) {
                            // attempt to get user configured date format
                            // if not configured for this plugin, get it from grid column config
                            dateFormat = dateFormat
                                || grid.headerCt.items.findBy(function(i){return i.dataIndex === field}).format
                            ;

                            // if we couldn't get it from column, get it from record field config
                            dateFormat = dateFormat
                                || record.fields.get(field).dateFormat
                            ;

                            // if we still don't have it use Ext's default
                            dateFormat = dateFormat || Ext.Date.defaultFormat;

                            recordVal = Ext.Date.format(recordVal, dateFormat);
                        }

                        re = new RegExp(me.escapeRegExp(query), 'gi');
                        retval = re.test(recordVal);
                    });
                    return !!retval;
                }); // eo filterBy

            } // eo if(query)
        }
    } // eo function doSearch

    /**
     * Sets up the menu items
     * Runs when plugin initializes and on grid reconfigure event.
     * @private
     */
    ,reconfigure:function() {
        var  me = this
            ,grid = me.getCmp()
            ,columns = grid.headerCt.items
            ,menu = me.menu
            ,group = 'radio' === me.menuStyle ? 'g' + (new Date()).getTime() : undefined
        ;

        menu.removeAll();

        if(me.showSelectAll) {
            menu.add([{
                 xtype:'menucheckitem'
                ,text:me.selectAllText
                ,checked:'all' === me.checkIndexes
                ,group:group
                ,handler:function(item) {
                    item.parentMenu.items.each(function(mi) {
                        if(mi.setChecked && !mi.isDisabled() && mi !== item) {
                            mi.setChecked(item.checked, true);
                        }
                    });
                }
            },'-']);
        }
        columns.each(function(col) {
            var checked = 'all' === me.checkIndexes || Ext.Array.contains(me.checkIndexes, col.dataIndex);
            if(col.text && col.dataIndex && !Ext.Array.contains(me.disableIndexes, col.dataIndex)) {
                menu.add({
                     xtype:'menucheckitem'
                    ,text:col.text
                    ,group:group
                    ,checked:checked
                    ,dataIndex:col.dataIndex
                });
            }
        });

        if(me.focusOnLoad) {
            grid.getStore().on({
                load:{
                    fn:function() {
                        me.field.focus()
                    }
                    ,delay:100
                }
            })
        }
    } // eo function reconfigure

    // creates the button and search field
    ,createField:function() {
        var  me = this
            ,targetCt = me.targetCt
            ,position = me.searchPosition
            ,add = me.createAdder()
            ,cfg = []
        ;

        if('last' === position && 'right' === me.searchAlign) {
            cfg.push('->')
        }
        if(me.beforeSeparator) {
            cfg.push('-');
        }
        me.menu = Ext.widget({
             xtype:'menu'
            ,listeners:{
                 scope:me
                ,hide:me.onMenuHide
                ,show:me.onMenuShow
            }
            ,getFields:function() {
                var  me = this
                    ,fields = []
                ;
                me.items.each(function(mi) {
                   if(mi.checked && mi.dataIndex) {
                       fields.push(mi.dataIndex);
                   }
                });
                return fields;
            }
        });
        cfg.push({
             xtype:'button'
            ,text:me.searchText
            ,menu:me.menu
            ,hidden:me.buttonHidden
            ,cls:me.buttonCls
            ,glyph:me.fontIcons ? me.buttonGlyph : undefined
            ,iconCls:me.fontIcons ? undefined : me.buttonIconCls
        });
        cfg.push({
             xtype:'triggerfield'
            ,inputType:me.inputType
            ,inputCls:'x-form-text'
            ,isFormField:false
            ,hideTrigger:me.triggerHidden || 'auto' === me.triggerSearch
            ,triggerCls:'x-form-search-trigger'
            ,onTriggerClick:Ext.Function.bind(me.doSearch, me, [])
            ,listeners:{
                 scope:me
                ,buffer:me.buffer
                ,change:me.onChange
                ,specialkey:me.onSpecialKey
                ,render:me.onFieldRender
            }
        });

        if(me.afterSeparator) {
            cfg.push('-');
        }
        // invoke the adder
        add(cfg);

        // save button and field as instance variables
        Ext.apply(me, {
             field:targetCt.down('triggerfield[inputType=' + me.inputType + ']')
            ,button:targetCt.down('button[cls=' + me.buttonCls + ']')
        });

        // initial menu configuration
        me.reconfigure();

    } // eo function createField

    // creates search field tooltip
    ,onFieldRender:function(field) {
        var  me = this;
        if(!me.disableTip) {
            Ext.tip.QuickTipManager.register({
                 target:field.inputEl.dom
                ,text:'auto' === me.triggerSearch
                    ? Ext.String.format(me.autoTipText, me.minChars)
                    : me.manualTipText

            });
        }
    } // eo function onFieldRender

    // triggers the search on menu hide
    ,onMenuHide:function(menu) {
        var  me = this
            ,fields = menu.getFields()
        ;

        if(Ext.encode(me.lastFields) !== Ext.encode(fields) && fields.length && me.field.getValue()) {
            me.doSearch();
        }

        // update ui
        me.setFieldDisabled(!fields.length);

    } // eo function onMenuHide

    // save the fields so that we can check if user
    // changed any on menu hide
    ,onMenuShow:function(menu) {
        this.lastFields = menu.getFields();
    } // eo function onMenuShow

    // triggers the search on Enter press
    ,onSpecialKey:function(field, e) {
        if (e.getKey() == e.ENTER) {
            this.doSearch();
        }
    } // eo function onSpecialKey

    // triggers the search on the search field change
    ,onChange:function(field) {
        var  me = this
            ,query = field.getValue()
            ,minChars = me.minChars
        ;

        if('auto' === me.triggerSearch && minChars && query.length >= minChars || 0 === query.length) {
            me.doSearch();
        }

    } // eo function onChange

    // creates adder according to targetCt and searchPosition
    ,createAdder:function() {
        var  me = this
            ,position = me.searchPosition
            ,targetCt = me.targetCt
            ,beforeItem
            ,beforeItemIndex
        ;
        if(Ext.isNumber(position)) {
            return Ext.Function.bind(targetCt.insert, targetCt, [position], 0);
        }
        if('first' === position) {
            return Ext.Function.bind(targetCt.insert, targetCt, [0], 0);
        }
        if('last' === position) {
            return Ext.Function.bind(targetCt.add, targetCt);
        }
        beforeItem = targetCt.down(position);
        if(beforeItem) {
            beforeItemIndex = targetCt.items.indexOf(beforeItem);
            return Ext.Function.bind(targetCt.insert, targetCt, [beforeItemIndex], 0);
        }
        else {
            return Ext.Function.bind(targetCt.add, targetCt);
        }

    } // eo function createAdder

    // returns target container according to targetCt and targetCtScope
    ,getTargetCt:function() {
        var  me = this
            ,grid = me.getCmp()
            ,targetCt = 'global' === me.targetCtScope
                ? Ext.ComponentQuery.query(me.targetCt)[0]
                : grid.down(me.targetCt)
            ,position
            ;

        if(!targetCt && me.autoCreate) {
            position = me.targetCt.match(/(top|left|bottom|right)/);
            position = position ? position[0] : 'bottom';

            targetCt = grid.dockedItems.add(
                Ext.widget({xtype:'toolbar', dock:position})
            );
        }

        return targetCt;

    } // eo function getToolbar

    /**
     * Returns true if the search field is disabled
     * @returns {Boolean}
     */
    ,isDisabled:function() {
        return this.field.isDisabled();
    } // eo function isDisabled

    /**
     * Returns true if the search field is visible
     * @returns {Boolean}
     */
    ,isVisible:function() {
        return this.field.isVisible();
    } // eo function isVisible

    /**
     * Disables or enables the button with fields menu
     * @param {Boolean} disable True to disable, false to enable
     */
    ,setButtonDisabled:function(disable) {
        this.button.setDisabled(disable);
    } // eo function setButtonDisabled

    /**
     * Disables the button with fields menu
     */
    ,disableButton:function() {
        this.button.disable();
    } // eo function disableButton

    /**
     * Enables the button with fields menu
     */
    ,enableButton:function() {
        this.button.enable();
    } // eo function enableButton

    /**
     * Disables of enables the search field
     * @param {Boolean} disable True to disable, false to enable
     */
    ,setFieldDisabled:function(disable) {
        this.field.setDisabled(disable);
    } // eo function setFieldDisabled

    /**
     * Disables the search field
     */
    ,disableField:function() {
        this.field.disable();
    } // eo function disableField

    /**
     * Enables the search field
     */
    ,enableField:function() {
        this.field.enable();
    }

    /**
     * Disables or enables both button with the fields menu
     * and the search field
     * @param {Boolean} disable True to disable, false to enable
     */
    ,setDisabled:function(disable) {
        var me = this;
        me.setButtonDisabled(disable);
        me.setFieldDisabled(disable);
    } // eo function setDisabled

    /**
     * Disables both button with the fields menu
     * and the search field
     */
    ,disable:function() {
        var me = this;
        me.disableButton();
        me.disableField();
    } // eo function disable

    /**
     * Enables both button with the fields menu
     * and the search field
     */
    ,enable:function() {
        var me = this;
        me.enableButton();
        me.enableField();
    } // eo function enable

    /**
     * Hides or shows both button with the fields
     * menu and the search field
     * @param {Boolean} hidden True to hide, false to show
     */
    ,setHidden:function(hidden) {
        var me = this;
        me.setButtonHidden(hidden);
        me.setFieldHidden(hidden);
    } // eo function setHidden

    /**
     * Hides both button with the fields
     * menu and the search field
     */
    ,hide:function() {
        this.setHidden(true);
    } // eo function hide

    /**
     * Shows both button with the fields
     * menu and the search field
     */
    ,show:function() {
        this.setHidden(false);
    } // eo function show

    /**
     * Hides or shows the button with fields menu
     * @param {Boolean} hidden True to hide, false to show
     */
    ,setButtonHidden:function(hidden) {
        var  me = this;
        if(hidden) {
            me.button.hide();
        }
        else {
            me.button.show();
        }
    } // eo function setButtonHidden

    /**
     * Hides the button with fields menu
     */
    ,hideButton:function() {
        this.setButtonHidden(true);
    } // eo function hideButton

    /**
     * Shows the button with fields menu
     */
    ,showButton:function() {
        this.setButtonHidden(false);
    } // eo function showButton

    /**
     * Hides or shows the search field
     * @param {Boolean} hidden True to hide, false to show
     */
    ,setFieldHidden:function(hidden) {
        var  me = this;
        if(hidden) {
            me.field.hide();
        }
        else {
            me.field.show();
        }
    } // eo function setFieldHidden

    /**
     * Hides the search field
     */
    ,hideField:function() {
        this.setFieldHidden(true);
    } // eo function hideField

    /**
     * Shows the search field
     */
    ,showField:function() {
        this.setFieldHidden(false);
    } // eo function showField

    // escape regular expression
    ,escapeRegExp:function(s) {
        if('string' !== typeof s) {
            return s;
        }
        return s.replace(/([.*+?\^=!:${}()|\[\]\/\\])/g, '\\$1');
    }

}); // eo define

// eof