vercengen/components/ComponentTable.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`.
 * 
 * Table that be converted to and from {@link ve.Spreadsheet} and back. Only takes in a 2D array list. Any edits in spreadsheet mode will be converted back to the view-only mode if possible.
 * - Functional binding: <span color=00ffff>veTable</span>().
 * 
 * ##### Constructor:
 * - `arg0_value`: {@link Array}<{@link Array}<{@link any}>> 
 *   - Nested arrays: [n1] - Sheet, [n2] - Row
 * - `arg1_options`: {@link Object}
 *   - `.disable_hide_columns=[]`: {@link Array}<{@link number}> - The indices of columns to disable hiding for.
 *   - `.hide_columns=[]`: {@link Array}<{@link number}>|{@link string} - The indices of columns to hide. If set to 'all', columns are not able to be hidden.
 *   - `.non_sortable_columns`: {@link number} - The indices that shouldn't be sortable.
 *   - `.ondraw`: {@link function}(v:{@link ve.Table})
 *   - `.oncellclick`: {@link function}(v:{@link Array}<{@link any}>, e:{@link Event})
 *   - `.onrowclick`: {@link function}(v:{@link any}, e:{@link Event})
 *   - `.page_sizes=ve.registry.settings.Table.page_sizes`: {@link number[]} - Set by default to [10, 20, 50, 100].
 *   - `.page_size=50`: {@link number}
 *   - `.sortable=true`: {@link boolean}
 *   - `.sort_ascending=true`: {@link boolean}
 *   - `.sort_column`: {@link number} - Which column should have its sort indicator active. 0-indexed.
 *   - `.dark_mode=false`: {@link boolean} - Whether the spreadsheet editor should launch in dark mode.
 *   
 * ##### Instance:
 * - `.v`: {@link Array}<{@link Array}<{@link any}>>
 *   
 * ##### Methods:
 * - <span color=00ffff>{@link ve.Table.convertToSpreadsheet|convertToSpreadsheet}</span>() | {@link ve.Spreadsheet}
 * - <span color=00ffff>{@link ve.Table.convertToTable|convertToTable}</span>() | {@link ve.Table}
 * - <span color=00ffff>{@link ve.Table.draw|draw}</span>()
 * - <span color=00ffff>{@link ve.Table.drawPages|drawPages}</span>()
 * - <span color=00ffff>{@link ve.Table.sort|sort}<span>(arg0_index:{@link number})
 * 
 * @augments ve.Component
 * @memberof ve.Component
 * @type {ve.Table}
 */
ve.Table = class extends ve.Component {
	static demo_value = [
		["ID", "Name", "Score"], //First row is header
		[1, "Alice", 85],
		[2, "Bob", 92],
		[3, "Charlie", 78],
		[4, "David", 95],
		[5, "Eve", 88],
		[6, "Frank", 70]
	];
	
	constructor (arg0_value, arg1_options) {
		//Convert from parameters
		let value = (arg0_value) ? arg0_value : {};
		let options = (arg1_options) ? arg1_options : {};
			super(options);
		
		//Initialise options
		options.attributes = (options.attributes) ? options.attributes : {};
    options.disable_hide_columns = (options.disable_hide_columns) ? options.disable_hide_columns : [];
    options.hide_columns = (options.hide_columns) ? options.hide_columns : [];
		options.non_sortable_columns = (options.non_sortable_columns) ? options.non_sortable_columns : [];
		options.page_size = Math.returnSafeNumber(options.page_size, 50);
		options.page_sizes = ve.registry.settings.Table.page_sizes;
		options.sortable = (options.sortable !== undefined) ? options.sortable : true;
		options.sort_ascending = (options.sort_ascending !== undefined) ? 
			options.sort_ascending : true;
		options.sort_column = (options.sort_column !== undefined) ? 
			options.sort_column : null;
			
		//Declare local instance variables
		this.element = document.createElement("div");
			this.element.setAttribute("component", "ve-table");
			this.element.instance = this;
			HTML.setAttributesObject(this.element, options.attributes);
		this.options = options;
		
		this._data = []; //Full 2D array
		this._headers = [];
		this._rows = [];
		this.current_page = 0;
		
		this.from_binding_fire_silently = true;
		this.v = value;
		delete this.from_binding_fire_silently;
	}
	
	/**
	 * Returns a 2D array of current values.
	 * - Accessor of: {@link ve.Table}
	 * 
	 * @alias v
	 * @memberof ve.Component.ve.Table
	 * @type {Array.<any[]>}
	 */
	get v () {
		//Return statement
		return [this._headers, ...this._rows];
	}
	
	/**
	 * Sets the current component value given a 2D dataframe. The first row refers to the header.
	 * - Accessor of: {@link ve.Table}
	 * 
	 * @param {Array.<any[]>} arg0_value
	 */
	set v (arg0_value) {
		//Convert from parameters
		let value = arg0_value;
		
		//Parse value
		if (!Array.isArray(value) || value.length === 0) {
			this._headers = [];
			this._rows = [];
		} else {
			//First row is headers, remaining are data
			[this._headers, ...this._rows] = value;
		}
		this.current_page = 0;
		this.options.sort_column = null;
		this.draw();
	}
	
	/**
	 * Switches the view to the bound Spreadsheet. If no Spreadsheet exists, creates one.
	 * - Method of: {@link ve.Table}
	 * 
	 * @alias convertToSpreadsheet
	 * @memberof ve.Component.ve.Table
	 * 
	 * @returns {ve.Spreadsheet}
	 */
	convertToSpreadsheet () {
		//Declare local instance variables
		let current_data = [this.v]; //Wrap 2D Table data into 3D Spreadsheet format
		
		//Initialise ve.Spreadsheet component
		if (!this.spreadsheet_component) {
			// Create the counterpart and link them
			this.spreadsheet_component = new ve.Spreadsheet(current_data, this.options);
			this.spreadsheet_component.table_component = this;
		} else {
			// Sync current data to existing spreadsheet
			this.spreadsheet_component.v = current_data;
			setTimeout(() => this.spreadsheet_component.v = current_data, 1000);
		}
		
		//Swap DOM
		if (this.element.parentNode)
			this.element.replaceWith(this.spreadsheet_component.element);
		
		//Return statement
		return this.spreadsheet_component;
	}
	
	/**
	 * Restores the Table view. If the Spreadsheet is currently active, it syncs the data back from the Spreadsheet before swapping.
	 * - Method of: {@link ve.Spreadsheet}
	 * 
	 * @alias convertToTable
	 * @memberof ve.Component.ve.Table
	 * 
	 * @returns {ve.Table|ve.Component.Table}
	 */
	convertToTable () {
		//If we are coming back from a spreadsheet, sync the data first
		if (this.spreadsheet_component) {
			const spreadsheet_data = this.spreadsheet_component.convertToArray();
			if (spreadsheet_data && spreadsheet_data[0])
				this.v = spreadsheet_data[0];
			
			// Swap DOM back if the spreadsheet is currently visible
			if (this.spreadsheet_component.element.parentNode)
				this.spreadsheet_component.element.replaceWith(this.element);
		}
		
		//Return statement
		return this;
	}
	
	/**
	 * Draws the current component.
	 * - Method of: {@link ve.Table}
	 * 
	 * @alias draw
	 * @memberof ve.Component.ve.draw
	 * 
	 * @returns {ve.Table|undefined}
	 */
	draw () {
    //Declare local instance variables
    let max_pages = Math.ceil(this._rows.length/this.options.page_size);
      if (this.current_page >= max_pages) this.current_page = max_pages - 1;
      if (this.current_page < 0) this.current_page = 0;

		//Clear .innerHTML
		this.element.innerHTML = "";
		
		//Internal guard clause if this._headers is empty
		if (this._headers.length === 0) {
			this.element.innerHTML = `<p>${loc("ve.registry.localisation.Table_no_data")}</p>`;
			return;
		}
		
		//Declare local instance variables
		let table_el = document.createElement("table");
		let thead_el = document.createElement("thead");
		
		let header_row_el = document.createElement("tr");
		
		//Render header
		this._headers.forEach((local_text, i) => {
      //Hidden column handling
      if (this.options.hide_columns.includes(i)) return;

			let local_th_el = document.createElement("th");
				local_th_el.innerHTML = local_text;
				
			//Sortable handling
			if (this.options.sortable && !this.options.non_sortable_columns.includes(i)) {
				local_th_el.style.cursor = "pointer";
				local_th_el.addEventListener("click", () => this.sort(i));
				if (this.options.sort_column === i)
					local_th_el.innerHTML += (this.options.sort_ascending) ? " ▴" : " ▾";
			}
			header_row_el.appendChild(local_th_el);
		});
    if (this.options.hide_columns !== "all")
      header_row_el.addEventListener("contextmenu", () => this.openViewSettings());

		thead_el.appendChild(header_row_el);
		table_el.appendChild(thead_el);
		
		//Render body (paginated)
		let tbody_el = document.createElement("tbody");
		let start_index = this.current_page*this.options.page_size;
		
		let page_data = this._rows.slice(start_index, start_index + this.options.page_size);
		
		page_data.forEach((row_data) => {
			let local_tr_el = document.createElement("tr");
			
			row_data.forEach((cell_data, i) => {
        //Hidden column handling
        if (this.options.hide_columns.includes(i)) return;

				//Push cell
				let local_td_el = document.createElement("td");
					if (cell_data instanceof HTMLElement) {
						local_td_el.appendChild(cell_data);
					} else {
						local_td_el.innerHTML = cell_data;
					}
					
				//oncellclick handler
				if (this.options.oncellclick)
					local_td_el.addEventListener("click", (e) => {
						this.options.oncellclick(cell_data, e);
					});
				
				//Push row
				local_tr_el.appendChild(local_td_el);
			});
			
			//onrowclick handler
			if (this.options.onrowclick)
				local_tr_el.addEventListener("click", (e) => {
					this.options.onrowclick(row_data, e);
				});
			
			tbody_el.appendChild(local_tr_el);
		});
		table_el.appendChild(tbody_el);
		this.element.appendChild(table_el);
		this.drawPages();

    //Handle .ondraw()
    if (this.options.ondraw) this.options.ondraw(this);
	}
	
	/**
	 * Draws the pagination menu at the bottom.
	 * - Method of: {@link ve.Table}
	 * 
	 * @alias drawPages
	 * @memberof ve.Component.ve.Table
	 * 
	 * @returns {ve.Table}
	 */
	drawPages () {
		//Declare local instance variables
		let total_pages = Math.ceil(this._rows.length/this.options.page_size);
		
		//Declare local instance variables
		let current_page_label = document.createElement("span");
			current_page_label.className = "label-current-page";
			current_page_label.style.display = "inline-flex";
			
			let current_page_number = new ve.Number(this.current_page + 1, {
				attributes: {
					size: Math.returnSafeNumber((this.current_page + 1).toString().length, 1) + 1
				},
				name: loc("ve.registry.localisation.Table_page"),
				max: total_pages,
				min: 1,
				onuserchange: (v, e) => {
					this.current_page = v - 1;
					this.draw();
				}
			});
			current_page_number.element.querySelector("input").style.minWidth = "auto";
			current_page_number.bind(current_page_label);
			
			let max_label = new ve.HTML(loc("ve.registry.localisation.Table_page_of_max", String.formatNumber(total_pages)));
			max_label.bind(current_page_label);
		let nav_el = document.createElement("div");
			nav_el.className = "pagination-controls";
			nav_el.style.marginTop = "var(--cell-padding)";
		let next_btn = new ve.Button(() => {
			if (this.current_page < total_pages)
				this.current_page++;
			this.draw();
		}, {
			attributes: {
				[(this.current_page >= total_pages + 1) ? "disabled" : "enabled"]: true,
				id: "next-button"
			},
			name: loc("ve.registry.localisation.Table_next")
		});
		let prev_btn = new ve.Button(() => {
			if (this.current_page > 0)
				this.current_page--;
			this.draw();
		}, {
			attributes: {
				[(this.current_page === 0) ? "disabled" : "enabled"]: true,
				id: "previous-button"
			},
			name: loc("ve.registry.localisation.Table_previous")
		});
		
		prev_btn.bind(nav_el);
		nav_el.appendChild(current_page_label);
		next_btn.bind(nav_el);
		
		//Page size handler
		if (this.options.page_sizes) {
			let select_btn = new ve.Select(this.options.page_sizes, {
				name: loc("ve.registry.localisation.Table_max_per_page"),
				selected: this.options.page_size.toString(),
				
				onuserchange: (v) => {
					//Declare local instance variables
					let old_page_size = this.options.page_size;
					let new_page_size = parseInt(v);
					
					let first_item_index = this.current_page*old_page_size;
					
					this.options.page_size = new_page_size;
					this.current_page = Math.floor(first_item_index/new_page_size);
					this.draw();
				}
			});
			
			select_btn.bind(nav_el);
		}
		
		this.element.appendChild(nav_el);
	}
	
	/**
	 * Returns the current view state of the component.
	 * - Method of: {@link ve.Table}
	 * 
	 * @alias getViewState
	 * @memberof ve.Component.ve.Table
	 * 
	 * @returns {{current_page: number, items_per_page: number, sort_ascending: boolean, sort_column: number|undefined}}
	 */
	getViewState () {
		//Return statement
		return {
			current_page: this.current_page,
      hide_columns: this.options.hide_columns,
			items_per_page: this.options.page_size,
			sort_ascending: this.options.sort_ascending,
			sort_column: this.options.sort_column
		};
	}

  /**
   * Opens the view settings tab for the component.
   * - Method of: {@link ve.Table}
   * 
   * @alias openViewSettings
   * @memberof ve.Component.ve.Table
   */
  openViewSettings () {
    //Declare local instance variables
    let checkbox_components_obj = {};

    //Iterate over all this._headers
    for (let i = 0; i < this._headers.length; i++) {
      if (this.options.disable_hide_columns.includes(i)) continue; //Internal guard clause for disable_hide_columns

      let is_shown = (!this.options.hide_columns.includes(i));

      checkbox_components_obj[i] = new ve.Checkbox(is_shown, {
        name: this._headers[i],
        onuserchange: (v) => {
          //Hide is opposite of shown
          if (v === false) {
            if (!this.options.hide_columns.includes(i))
              this.options.hide_columns.push(i);
          } else {
            //Iterate over this.options.hide_columns and splice it
            for (let x = this.options.hide_columns.length - 1; x >= 0; x--)
              if (this.options.hide_columns[x] === i)
                this.options.hide_columns.splice(x, 1);
          }

          //Draw call
          this.draw();
        }
      });
    }

    let local_context_menu = new ve.ContextMenu({
      view_header: new ve.HTML(`<b>Show Columns:</b><br><br>`, { x: 0, y: 0 }),
      ...checkbox_components_obj
    }, {
      id: "table_view_columns"
    });
  }
	
	/**
	 * Sets the view state from an existing view object.
	 * - Method of: {@link ve.Table}
	 * 
	 * @alias setViewState
	 * @memberof ve.Component.ve.Table
	 * 
	 * @param {Object} arg0_view_obj
	 */
	setViewState (arg0_view_obj) {
		//Convert from parameters
		let view_obj = (arg0_view_obj) ? arg0_view_obj : {};
		
		//Set options and render
		if (view_obj.current_page !== undefined) this.current_page = view_obj.current_page;
    if (view_obj.hide_columns !== undefined) this.options.hide_columns = view_obj.hide_columns;
		if (view_obj.items_per_page !== undefined) this.options.page_size = view_obj.items_per_page;
		if (view_obj.sort_column !== undefined) {
			if (view_obj.sort_ascending !== undefined) this.options.sort_ascending = view_obj.sort_ascending;
			this.sort(view_obj.sort_column, { do_not_change_sort_order: true });
		} else {
			this.draw();
		}
	}
	
	/**
	 * Sorts the current Table component and calls this.draw(). If the value is an HTMLElement with attribute 'data-value', it will use that to sort instead of innerHTML.
	 * - Method of: {@link ve.Table}
	 * 
	 * @alias sort
	 * @memberof ve.Component.ve.Table
	 * 
	 * @param {number} [arg0_index]
	 * @param {Object} [arg1_options]
	 *  @param {boolean} [arg1_options.do_not_change_sort_order=false]
	 */
	sort (arg0_index, arg1_options) {
		//Convert from parameters
		let index = Math.returnSafeNumber(arg0_index);
		let options = (arg1_options) ? arg1_options : {};
		
		if (!this.options.sortable || this.options.non_sortable_columns.includes(index)) 
			return; //Internal guard clause if table is not sortable
		
		//Declare local instance variables
    let getSortValue = (local_value) => {
      //Declare local instance variables
      let sort_value = local_value;

      if (local_value instanceof HTMLElement) {
        let data_value = local_value.getAttribute("data-value");

        if (data_value) {
          sort_value = data_value;
        } else {
          sort_value = local_value.innerHTML;
        }
      }

      //Return statement
      return sort_value;
    };

		if (this.options.sort_column === index) {
			if (!options.do_not_change_sort_order)
				this.options.sort_ascending = (!this.options.sort_ascending);
		} else {
			this.options.sort_column = index;
			
			if (!options.do_not_change_sort_order)
				this.options.sort_ascending = true;
		}
		
		//Sort rows
		this._rows.sort((a, b) => {
			let a_value = getSortValue(a[index]);
			let b_value = getSortValue(b[index]);
			
			//Attempt numeric sort if possible
			let a_number = parseFloat(a_value);
			let b_number = parseFloat(b_value);
			
			if (!isNaN(a_number) && !isNaN(b_number)) {
				a_value = a_number;
				b_value = b_number;
			}
			
			//Return statement
			if (a_value < b_value) return (this.options.sort_ascending) ? -1 : 1;
			if (a_value > b_value) return (this.options.sort_ascending) ? 1 : -1;
			return 0;
		});
		this.draw();
	}
};

//Functional binding

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