table-search.js

/**
 * Created by IvanP on 16.11.2016.
 */
import Highlight from 'r-highlight/src/main';
import HierarchyBase from './hierarchy-base';
import AggregatedTable from 'r-aggregated-table/src/aggregated-table';
import HierarchyRowMeta from './hierarchy-row-meta';


/**
 * This class initializes a prototype for search functionality for hierarchical column
 * @param {Boolean} enabled=false - flag to be set when enabling the search
 * @param {Boolean} immediate=false - flag to be set for serach to happen after each stroke rather than by `timeout`
 * @param {Number} timeout=300 - minimal time(in milliseconds) after last keystroke when searching takes place
 * @param {Boolean} [searching=false] - this property is mostly for internal use and is set when searching is in progress, which adds a class to the table hiding all rows not matching search
 * @param {String} [query=''] - search string
 * @param {HTMLInputElement} target - the input element that triggered the search.
 * @param {Boolean} [visible=false] - search box is visible
 * @param {Boolean} [highlight=true] - search matches will be highlighted
 * */
export default class TableSearch{
  constructor(options){
    let {source, refSource, immediate = false, timeout=300, searching=false, query='', target=null, visible=false, highlight=true, placeholder = 'Search categories...', parsed, flat} = options;
    this.source = source;
    this.refSource = refSource;
    this.timeout = timeout;
    this.immediate = immediate;
    this.searching = searching;
    this.visible = visible;
    [source,refSource].forEach(src=>{
      this.addSearchBox(src.querySelector('.reportal-hierarchical-header'), placeholder)
    });
    this.inputs = [].slice.call(source.parentNode.querySelectorAll('.reportal-hierarchical-header input'));
    this.query = query;
    this.target = target;
    this.highlight = highlight? new Highlight({element:[].slice.call(source.querySelectorAll('.reportal-hierarchical-cell')),type:'open'}) : null;
    // initialize searchfield on element
    this.parsed = parsed;
    this.flat = flat;
  }

  set query(val){
    if(val.length==0 && this.highlight){
      this.highlight.remove();
    } // clear highlighting when query length is 0
    this._query = val;
    this.inputs.forEach(input=>{
        input.value = val;
    });
  }
  get query(){
    return this._query;
  }

  get visible(){return this._visible}
  set visible(val){
    [].slice.call(this.source.parentNode.querySelectorAll('.hierarchy-search')).forEach(button=>{
      if(val){
        button.classList.add('visible');
        button.parentNode.classList.add('hierarchy-search-visible'); //to hide sorting arrow because it overlaps the search field
      }else{
        button.classList.remove('visible');
        button.parentNode.classList.remove('hierarchy-search-visible');
      }
    });
    this._visible = val;
  }

  get searching(){return this._searching}
  set searching(val){
    val?this.source.classList.add('reportal-hierarchy-searching'):this.source.classList.remove('reportal-hierarchy-searching');
    if(!val){
      HierarchyBase.collapseAll(this.parsed); // we want to collapse all expanded rows that could be expanded during search
    }
    this._searching = val;
  }

  /**
   * Nulls search and redoes it, used in toggling between `flat` and `tree` views in hierarchy, necessary because the search is done on different name strings
   * */
  clearSearch(){
    this.target = null;
    this.query = '';
    this.visible = false;
    this.searching = false;
    this.inputs.forEach(input=>input.value = '');
  }

  /**
   * Updates `search.target` && `search.query` in `hierarchy.search` to know which input triggered the search and update the `search.query` in the other
   * @param {Event} e - a debounced event triggered by input field when a person enters text
   * */
  updateSearchTarget(e){
    this.target = e.target;
    this.query = e.target.value;
  }

  /**
   * Adds a search icon and a search box to the header of the hierarchy column (`host`)
   * @param {HTMLTableCellElement} host - header of the hierarchy column
   * @param {String} placeholder - Placeholder text in the searchfield
   * */
  addSearchBox(host,placeholder){
    let button = document.createElement('span'),
      buttonContainer = document.createElement('span'),
      clearButton = document.createElement('span'),
      searchfield = document.createElement('input');

    searchfield.type='text';
    button.classList.add('icon-search');
    clearButton.classList.add('icon-add');
    clearButton.classList.add('clear-button');
    buttonContainer.classList.add('btn');
    buttonContainer.classList.add('hierarchy-search');

    //listener to display search field on search-icon click
    button.addEventListener('click',e=>{
      if(!this.visible){this.visible = true;}
      e.target.parentNode.querySelector('input').focus();
    });

    //listener to display search field on search-icon click
    clearButton.addEventListener('click',e=>{
      this.clearSearch();
    });

    buttonContainer.title = searchfield.placeholder = placeholder;

    let efficientSearch = this.search();
    //TODO: add cursor following the header (if a floating header appeared, cursor must focus there)
    searchfield.addEventListener('keyup',e=>{
      this.updateSearchTarget(e); //update search parameters
      efficientSearch();          // call search less frequently
    });

    searchfield.addEventListener('blur',e=>{
      if(e.target.value.length==0)this.clearSearch(); //update search parameters
    });

    buttonContainer.appendChild(button);
    buttonContainer.appendChild(searchfield);
    buttonContainer.appendChild(clearButton);
    host.appendChild(buttonContainer);
  }

  /**
   * Wrapping function that debounces search, sets `search.searching` [(click for info)]{@link HierarchyTable#setupSearch} and calls `hierarchy.searchRowheaders` [(click for info)]{@link HierarchyTable#searchRowheaders}
   * @return {Function}
   * */
  search(){
    return HierarchyBase.debounce(()=>{
      let value = this.query;
      if(value.length>0){
        if(!this.searching){this.searching=true;}
        this.searchRowheaders(value);
      } else {
        this.searching=false;
      }
    }, this.timeout, this.immediate);
  }

  /**
   * This function runs through the data and looks for a match in `row.meta.flatName` (for flat view) or `row.meta.name` (for tree view) against the `str`.
   * @param {String} str - expression to match against (is contained in `this.search.query`)
   * */
  searchRowheaders(str){
    HierarchyBase.collapseAll(this.parsed); //null search
    let regexp = new RegExp('('+str+')','i');
    for(let id in this.parsed){
      let row = this.parsed[id];
      if(this.flat){
        HierarchyRowMeta.setMatches.call(row,regexp.test(row.flatName));
        HierarchyRowMeta.setHidden.call(row,false)
      } else {
        // if it has a parent and maybe not matches and the parent has match, then let it and its children be displayed
        let matches = regexp.test(row.name);
        if(row.parent!=null && !matches && row.parent.matches){
          // just in case it's been covered in previous iteration
          if(!row.matches){HierarchyRowMeta.setMatches.call(row,true)}
          else if(row.hasChildren && !row.collapsed){
            HierarchyRowMeta.setCollapsed.call(row,true); //if a parent row is uncollapsed and has a match, but the current item used to be a match and was uncollapsed but now is not a match
          }
          HierarchyRowMeta.setHidden.call(row,row.parent.collapsed);
        } else { // if has no parent or parent not matched let's test it, maybe it can have a match, if so, display his parents and children
          HierarchyRowMeta.setMatches.call(row,matches);
          if(matches){
            HierarchyBase.uncollapseParents.call(row);
          }
        }
      }
    }
    if(this.highlight)this.highlight.apply(str);
  }



  /**
   * Allows focus to follow from a search field into floating header and back when header disappears.
   * */
  focusFollows(){
    if(this.refSource){
      ['visible','hidden'].forEach(eventChunk=>{
        this.source.addEventListener(`reportal-fixed-header-${eventChunk}`,()=>{
          if(this.searching && document.activeElement && this.inputs.indexOf(document.activeElement)!=-1){
            this.inputs.forEach(input=>{
              if(input!=document.activeElement){
                input.focus();
                this.target=input;
              }
            });
          }
        })
      });
    }
  }
}