vercengen/components/ComponentCRUD.js

/**
 * Refer to <span color = "yellow">{@link ve.Component}</span> for methods or fields inherited from this Component's parent such as `.options.attributes` or `.element`.
 * 
 * Create, Read, Update, Delete (CRUD) interface used for editing complex instances.
 * - Functional binding: <span color=00ffff>veCRUD</span>().
 *
 * ##### Constructor:
 * - `arg0_value`: {@link Array}<{@link any}>
 * - `arg1_options`: {@link Object}
 *   - `.header=["Selected", "Index", ...]`: {@link Array}<{@link string}>
 *   - `.special_function`: {@link function}(v:{@link any}) | {@link Array}<{@link any}> - How to parse Array elements in the dataframe into rows, excluding the selection row.
 *
 *   - `.filters`: {@link Array}<{@link Object}> - [{ name: "All" }] by default.
 *     - `[n].name`: {@link string} - The name of the given tab.
 *     - `[n].special_function`: {@link function}(v:{@link any}) | {@link boolean} - Boolean determines whether to include result in tab. If this field does not exist, all elements are taken as valid.
 *   - `.filter_interface`: {@link ve.Interface} - The interface to provide for the filter.
 *   - `.hide_searchbar=false`: {@link boolean}
 *   - `.onsearch`: {@link function}(v:{@link string}, e:{@link ve.CRUD})
 *   - `.onselect`: {@link function}(v:{@link boolean}, e:{ checkbox:{@link ve.Checkbox}, value:{@link any} })
 *   - `.searchbar_filters`: {@link Array}<{@link number>} - The column indices to target when filtering search results.
 *   - `.searchbar_header_components`: {@link Array}<{@link ve.Component}>
 *   - `.searchbar_options`: {@link Object} - The options to pass to the {@link ve.SearchSelect} for the CRUD.
 *   - `.select_options`: {@link Object}
 *   - `.table_options`: {@link Object} - The options to pass to the {@link ve.Table} for the CRUD.
 *   
 * ##### Instance:
 * - `.do_not_draw=false`: {@link boolean} - Whether to call .draw() when set v() is fired.
 * - `.page_menu`: {@link ve.PageMenu} - The PageMenu component responsible for containing CRUD subpages.
 * - `.searchbar`: {@link ve.SearchSelect}
 * - `.table`: {@link ve.Table}
 * - `.table_array`: {@link Array}<{@link Array}<{@link any}>>
 * - `.table_map`: {@link Array}<{@link Object}{ <value_id>: { value:{@link any}, row:{@link any}[] } }>
 * - `.v`: {@link Array}<{@link any}>
 * 
 * ##### Methods:
 * - <span color=00ffff>{@link ve.CRUD.draw|draw}</span>()
 * - <span color=00ffff>{@link ve.CRUD.filterTable|filterTable}</span>(arg0_search_string:{@link string}, arg1_options:{@link Object}) | {@link Array}<{@link any}>
 * - <span color-00ffff>{@link ve.CRUD.gc|gc}</span>()
 * - <span color=00ffff>{@link ve.CRUD.getFilters|getFilters}</span>() | {@link Object}{ name:{@link string}, special_function:{@link function} }
 * - <span color=00ffff>{@link ve.CRUD.getTable|getTable}</span>(arg0_filter_function:{@link function}) | {@link Array}<{@link Array}<{@link any}>>
 * - <span color=00ffff>{@link ve.CRUD.redrawSelections|redrawSelections}</span>()
 * 
 * @augments ve.Component
 * @memberof ve.Component
 * @type {ve.CRUD}
 */
ve.CRUD = class extends ve.Component {
  constructor (arg0_value, arg1_options) {
    //Convert from parameters
    let value = (arg0_value) ? Array.toArray(arg0_value) : [];
    let options = (arg1_options) ? arg1_options : {};
      super(options);
    
    //Initialise options
    options._filters = this.getFilters();
    let new_header = ["Selected", "Index"];
      this.head_index = 1; //Head index of the header
      if (options.header) new_header = new_header.concat(options.header);
      options.header = new_header;
      options.searchbar_filters = (options.searchbar_filters) ? 
        Array.toArray(options.searchbar_filters) : [];
    
    //Declare local instance variables
    this.element = document.createElement("div");
      this.element.setAttribute("component", "ve-crud");
      this.element.instance = this;
      HTML.setAttributesObject(this.element, options.attributes);
    this.id = Class.generateRandomID(ve.CRUD);
    this.options = options;
    this.value = value;
    
    //Push to instances
    ve.CRUD.instances.push(this);
    
    //Call this.draw()
    this.from_binding_fire_silently = true;
    this.v = value;
    delete this.from_binding_fire_silently;
  }
  
  /**
   * Returns an array of current values.
   * - Accessor of: {@link ve.CRUD}
   * 
   * @alias v
   * @memberof ve.Component.ve.CRUD
   * @type {any[]}
   */
  get v () {
    //Return statement
    return this.value;
  }
  
  /**
   * Sets the array of current values.
   * - Accessor of: {@link ve.CRUD}
   * 
   * @alias v
   * @memberof ve.Component.ve.CRUD
   * 
   * @param arg0_value
   */
  set v (arg0_value) {
    //Convert from parameters
    let value = Array.toArray(arg0_value);
    
    //Set value and call draw
    this.value = value;
    if (!this.options.do_not_draw) this.draw();
  }
  
  /**
   * Draws the current component and forces a re-render.
   * - Method of: {@link ve.CRUD}
   * 
   * @alias draw
   * @memberof ve.Component.ve.CRUD
   */
  draw () {
    //Declare local instance variables
    this.element.innerHTML = "";
    this.page_obj = {};
    
    let last_filter;

    if (!this.searchbar) this.searchbar = new ve.SearchSelect({}, {
      header_components_obj: {
        filter_columns_when_searching: veButton(() => {
          if (this.filter_window) this.filter_window.close();
          
          //Declare local instance variables
          let components_obj = {};
          
          //Iterate over all categories in header and show them for searchbar options
          for (let i = this.head_index; i < this.options.header.length; i++)
            components_obj[i] = new ve.Checkbox(this.options.searchbar_filters.includes(i), {
              name: this.options.header[i],
              onuserchange: (v) => {
                if (v === true) {
                  if (!this.options.searchbar_filters.includes(i))
                    this.options.searchbar_filters.push(i);
                } else {
                  //Iterate over this.options.searchbar_filteres and splice it
                  for (let x = this.options.searchbar_filters.length - 1; x >= 0; x--)
                    if (this.options.searchbar_filters[x] === i)
                      this.options.searchbar_filters.splice(x, 1);
                }
                
                this.filterTable(this.searchbar.search_value);
              }
            });
          
          components_obj.mirror_visible_columns = new ve.Toggle(this.options.mirror_visible_columns, {
            name: "Mirror Visible Columns",
            onuserchange: (v) => {
              this.options.mirror_visible_columns = v;
              this.filterTable(this.searchbar.search_value);
            }
          });
          
          //Open this.filter_window
          this.filter_window = new ve.Window({
            ...components_obj
          }, {
            name: "Filter Search",
            can_rename: false,
            width: "20rem"
          });
        }, {
          name: "<icon>filter_alt</icon>",
          tooltip: "Filter Columns When Searching",
          style: {
            display: "block",
            marginLeft: "auto"
          }
        }),
        ...this.options.searchbar_header_components
      },
      hide_filter: true,
      onuserchange: (v, e) => {
        //Declare local instance variables
        let search_value = e.search_value;
        
        //Filter table by search_value
        if (this.options.onsearch) this.options.onsearch(search_value, this);
        this.filterTable(search_value);
      },
      ...this.options.searchbar_options
    });

    //Initialise table with header only; refresh logic will populate data
    this.table_array = [this.options.header];
    this.table = new ve.Table(this.table_array, {
      disable_hide_columns: [0],
      ...this.options.table_options
    });
    
    //Iterate over this.options._filters and check for .name, .special_function
    for (let i = 0; i < this.options._filters.length; i++) {
      let local_filter = this.options._filters[i];
      
      this.page_obj[local_filter.name] = {
        name: local_filter.name,
        components_obj: (i === 0) ? {
          searchbar: this.searchbar,
          table: this.table
        } : {}
      };
    }
    
    //Define localized update logic to prevent double execution
    let updateView = (v, e) => {
      if (last_filter === v) return;
      last_filter = v;

      //Iterate over all this.options._filters to fetch this.filter_obj
      for (let i = 0; i < this.options._filters.length; i++) {
        let local_filter = this.options._filters[i];
        
        if (local_filter.name === v) {
          this.filter_obj = local_filter;
          break;
        }
      }
      
      //Fetch new data and refresh table display
      this.table_array = this.getTable(this.filter_obj?.special_function);
      this.filterTable(this.searchbar.search_value, { do_not_refresh: true });
      
      this.searchbar.bind(e.element);
      this.table.bind(e.element);
    };

    //Preserve old_starting_page if possible
    let old_starting_page = (this.page_menu) ? this.page_menu.v : undefined;
    this.page_menu = new ve.PageMenu(this.page_obj, {
      do_not_wrap: true,
      onuserchange: (v, e) => updateView(v, e.interfaces_obj[v]),
      starting_page: old_starting_page
    });

    //Initial draw call
    let initial_page_name = this.page_menu.v;
    updateView(initial_page_name, this.page_menu.interfaces_obj[initial_page_name]);
    
    this.page_menu.bind(this.element);
  }
  
  /**
   * Filters the current table depending on the search string given.
   * - Method of: {@link ve.CRUD}
   * 
   * @alias filterTable
   * @memberof ve.Component.ve.CRUD
   * 
   * @param {string} [arg0_search_string=""]
   * @param {Object} [arg1_options]
   *  @param {boolean} [arg1_options.do_not_refresh=false]
   * 
   * @returns {any[]}
   */
  filterTable (arg0_search_string, arg1_options) {
    //Convert from parameters
    let search_string = (arg0_search_string) ? arg0_search_string : "";
      search_string = search_string.trim().toLowerCase();
    let options = (arg1_options) ? arg1_options : {};
    
    //Declare local instance variables
    let filtered_table_array = [];
    let searchbar_columns = [];
    
    //Internal guard clause if search_string is empty
    if (search_string.length === 0) {
      filtered_table_array = (!options.do_not_refresh) ? 
        this.getTable(this.filter_obj?.special_function) : this.table_array;
      this.table.v = filtered_table_array;
      
      //Return statement
      return filtered_table_array;
    }
    
    //Set searchbar_columns
    if (this.options.mirror_visible_columns) {
      //Iterate over all columns in header and push anything not in hide_columns
      for (let i = 0; i < this.options.header.length; i++) try {
        if (!this.table.options.hide_columns.includes(i))
          searchbar_columns.push(i);
      } catch (e) {}
    } else {
      if (!this.options.searchbar_filters || this.options.searchbar_filters?.length === 0) {
        for (let i = 0; i < this.options.header.length; i++)
          searchbar_columns.push(i);
      } else {
        searchbar_columns = this.options.searchbar_filters;
      }
    }
    
    //Push header to filtered_table_array first
    filtered_table_array.push(this.options.header);
    
    //Iterate over all rows in this.table_array
    for (let i = 1; i < this.table_array.length; i++) {
      let is_valid = false;
      
      //Iterate over all searchbar_columns in this.table_array for filters to see if "data-value" or .innerText has a valid substring
      for (let x = 0; x < searchbar_columns.length; x++) {
        let local_cell = this.table_array[i][searchbar_columns[x]];
        let local_values = [];
        
        if (local_cell instanceof HTMLElement) {
          let data_value = local_cell.getAttribute("data-value");
          
          if (data_value) local_values.push(data_value);
          local_values.push(local_cell.innerText);
        } else {
          local_values.push(String(local_cell));
        }
        
        //Iterate over local_values and determine if any of them are valid against search_string
        for (let y = 0; y < local_values.length; y++) {
          let local_value = local_values[y].trim().toLowerCase();
          
          if (local_value.indexOf(search_string) !== -1) {
            is_valid = true;
            break;
          }
        }
      }
      
      if (is_valid) filtered_table_array.push(this.table_array[i]);
    }
    
    //Set this.table.v
    this.table.v = filtered_table_array;
    
    //Return statement
    return filtered_table_array;
  }
  
  /**
   * Returns all internal filters and sets them to `this.options._filters`.
   * - Method of: {@link ve.CRUD}
   * 
   * @alias getFilters
   * @memberof ve.Component.ve.CRUD
   * 
   * @returns {Array.<{ name: string, special_function: function|undefined }>}
   */
  getFilters () {
    //Declare local instance variables
    let new_filters = [{ name: "All" }];
    
    //Concatenate new_filters
    if (this.options.filters) new_filters = new_filters.concat(this.options.filters);
    this.options._filters = new_filters; //Set internal cache _filters
    
    //Return statement
    return new_filters;
  }
  
  /**
   * Returns the current table array for the CRUD component.
   * - Method of: {@link ve.CRUD}
   * 
   * @alias getTable
   * @memberof ve.Component.ve.CRUD
   * 
   * @param {function} [arg0_filter_function] - The function to filter `this.value[i]` by. Returns: {@link boolean}.
   * 
   * @returns {Array.<any[]>}
   */
  getTable (arg0_filter_function) {
    //Convert from parameteres
    let filter_function = arg0_filter_function;
    
    //Declare local instance variables
    this.table_array = []; //[[select_button, ...draw_function(value[n])], ...]
    this.table_map = {}; //{ <value_id>: { value: any, row: any[] } }
    
    //Set header
    this.table_array.push(this.options.header);
    
    //Populate table_array from value
    for (let i = 0; i < this.value.length; i++) {
      let is_valid = true;
        if (filter_function !== undefined) is_valid = filter_function(this.value[i]);
      if (!is_valid) continue; //Internal guard clause if element isn't valid
      
      let local_array = [];
      let select_component;
      
      //Set local_array
      //Select column
      {
        select_component = veCheckbox(this.value[i]?.selected, {
          attributes: {
            "crud-select": "true",
            "data-value": String(this.value[i]?.selected)
          },
          gc: true,
          onuserchange: (v, e) => {
            e.element.setAttribute("data-value", String(v));
            
            if (this.options.onselect) {
              this.options.onselect(v, {
                checkbox: e,
                value: this.value[i]
              });
            } else {
              this.value[i].selected = v;
            }
            this.redrawSelections();
          },
          ...this.options.select_options
        });
        select_component.element.value = this.value[i];
        
        local_array.push(select_component.element);
      }
      
      //Push index
      local_array.push(i);
      
      //Push everything else from this.options.special_function
      let row_value = this.options.special_function(this.value[i]);
      
      if (row_value)
        for (let x = 0; x < row_value.length; x++) {
          if (row_value[x].instance) row_value[x].instance.gc();
          local_array.push(row_value[x]);
        }
      
      //Push local_array to table_array
      this.table_array.push(local_array);
      
      this.table_map[(this.value[i].id) ? this.value[i].id : i] = {
        value: this.value[i],
        row: local_array
      };
    }
    
    //Return statement
    return this.table_array;
  }
  
  /**
   * Redraws selection boxes for the present component.
   * - Method of: {@link ve.CRUD}
   * 
   * @alias redrawSelections
   * @memberof ve.Component.ve.CRUD
   */
  redrawSelections () {
    Object.iterate(this.table_map, (local_key, local_value) => {
      let is_selected = local_value.value?.selected;
      let local_checkbox = local_value.row[0].instance;
      
      if (local_checkbox) {
        local_checkbox.v = is_selected;
        if (local_checkbox.element) local_checkbox.element.setAttribute("data-value", is_selected);
      }
    });
  }
};

//Functional binding

/**
 * @returns {ve.CRUD}
 */
veCRUD = function () {
  //Return statement
  return new ve.CRUD(...arguments);
};