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}
 *   - `.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.page_size = Math.returnSafeNumber(options.page_size, 50);
		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 () {
		const current_data = [this.v]; // Wrap 2D Table data into 3D Spreadsheet format
		
		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 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}
	 */
	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 this;
	}
	
	/**
	 * Draws the current component.
	 * - Method of: {@link ve.Table}
	 * 
	 * @alias draw
	 * @memberof ve.Component.ve.draw
	 * 
	 * @returns {ve.Table}
	 */
	draw () {
		//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) => {
			let local_th_el = document.createElement("th");
				local_th_el.innerHTML = local_text;
				
			//Sortable handling
			if (this.options.sortable) {
				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);
		});
		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) => {
				let local_td_el = document.createElement("td");
					local_td_el.innerHTML = cell_data;
				local_tr_el.appendChild(local_td_el);
			});
			tbody_el.appendChild(local_tr_el);
		});
		table_el.appendChild(tbody_el);
		this.element.appendChild(table_el);
		this.drawPages();
	}
	
	/**
	 * Draws the pagination menu at the bottom.
	 * - Method of: {@link ve.Table}
	 * 
	 * @alias drawPages
	 * @memberof ve.Component.ve.drawPages
	 * 
	 * @returns {ve.Table}
	 */
	drawPages () {
		//Declare local instance variables
		let total_pages = Math.ceil(this._rows.length/this.options.page_size);
		
		if (total_pages <= 1) return; //Internal guard clause if there is only one page
		
		//Declare local instance variables
		let current_page_label = document.createElement("span");
			current_page_label.className = "label-current-page";
			current_page_label.innerHTML = loc("ve.registry.localisation.Table_pagination_info", String.formatNumber(this.current_page + 1), String.formatNumber(total_pages));
		let nav_el = document.createElement("div");
			nav_el.className = "pagination-controls";
			nav_el.style.marginTop = "var(--cell-padding)";
		let next_btn = new ve.Button(() => {
			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(() => {
			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);
		this.element.appendChild(nav_el);
	}
	
	/**
	 * Sorts the current Table component and calls this.draw().
	 * - Method of: {@link ve.Table}
	 * 
	 * @alias sort
	 * @memberof ve.Component.ve.sort
	 * 
	 * @param {number} arg0_index
	 */
	sort (arg0_index) {
		//Convert from parameters
		let index = Math.returnSafeNumber(arg0_index);
		
		if (!this.options.sortable) return; //Internal guard clause if table is not sortable
		
		//Declare local instance variables
		if (this.options.sort_column === index) {
			this.options.sort_ascending = (!this.options.sort_ascending);
		} else {
			this.options.sort_column = index;
			this.options.sort_ascending = true;
		}
		
		//Sort rows
		this._rows.sort((a, b) => {
			let a_value = a[index];
			let b_value = 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);
};