define([
"underscore",
"jquery",
"backbone",
"markdownTableFromJson",
"markdownTableToJson",
"text!templates/tableEditor.html"
],
function(
_,
$,
Backbone,
markdownTableFromJson,
markdownTableToJson,
Template
){
/**
* @class TableEditorView
* @classdesc A view of an HTML textarea with markdown editor UI and preview tab
* @classcategory Views
* @extends Backbone.View
* @constructor
*/
var TableEditorView = Backbone.View.extend(
/** @lends TableEditorView.prototype */
{
/**
* The type of View this is
* @type {string}
* @readonly
*/
type: "TableEditor",
/**
* The HTML classes to use for this view's element
* @type {string}
*/
className: "table-editor",
/**
* References to templates for this view. HTML files are converted to
* Underscore.js templates
* @type {Underscore.Template}
*/
template: _.template(Template),
/**
* The current number of rows displayed in the spreadsheet, including the
* header row
* @type {number}
*/
rowCount: 0, // No of rows
/**
* The current number of columns displayed in the spreadsheet, including the
* row number column
* @type {number}
*/
colCount: 0, // No of cols
/**
* The same data shown in the table as a stringified JSON object.
* @type {string}
*/
tableData: "",
/**
* Map for storing the sorting history of every column
* @type {map}
*/
sortingHistory: new Map(),
/**
* The events this view will listen to and the associated function to call.
* @type {Object}
*/
events: {
"click #reset": "resetData",
"focusout table": "updateData",
"click .table-body": "handleBodyClick",
"click .table-headers": "handleHeadersClick",
"click *": "closeDropdown",
},
/**
* Default row & column count for empty tables
* @type {object}
*/
defaults: {
initialRowCount: 7,
initialColCount: 3
},
/**
* Initialize is executed when a new tableEditor is created.
* @constructs TableEditorView
* @param {Object} options - A literal object with options to pass to the view
*/
initialize: function(options) {
try {
options = _.extend(this.defaults, options);
// Get all the options and apply them to this view
if (options) {
var optionKeys = Object.keys(options);
_.each(optionKeys, function(key, i) {
this[key] = options[key];
}, this);
}
} catch (e) {
console.log("Failed to initialize the table editor view, error message: " + e);
}
},
/**
* render - Renders the tableEditor - add UI for creating and editing tables
*/
render: function() {
try {
// Insert the template into the view
this.$el.html(this.template({
cid: this.cid
})).data("view", this);
// If initalized with markdown, convert to JSON and use as table data
// Parse the table string into a javascript object so that we can pass it
// into the table editor view to be edited by the user.
if (this.markdown && this.markdown.length > 0) {
var tableArray = this.getJSONfromMarkdown(this.markdown);
if (tableArray && Array.isArray(tableArray) && tableArray.length) {
this.saveData(tableArray);
this.createSpreadsheet();
// Add the column that we use for row numbers in the editor
this.addColumn(0, "left");
}
} else {
this.createSpreadsheet();
}
} catch (e) {
console.log("Failed to render the table editor view, error message: " + e);
}
},
/**
* createSpreadsheet - Creates or re-creates the table & headers with data,
* if there is any.
*/
createSpreadsheet: function() {
try {
const spreadsheetData = this.getData();
this.rowCount = spreadsheetData.length - 1 || this.initialRowCount;
this.colCount = spreadsheetData[0].length - 1 || this.initialColCount;
const tableHeaderElement = this.$el.find(".table-headers")[0];
const tableBodyElement = this.$el.find(".table-body")[0];
const tableBody = tableBodyElement.cloneNode(true);
tableBodyElement.parentNode.replaceChild(tableBody, tableBodyElement);
const tableHeaders = tableHeaderElement.cloneNode(true);
tableHeaderElement.parentNode.replaceChild(tableHeaders, tableHeaderElement);
tableHeaders.innerHTML = "";
tableBody.innerHTML = "";
tableHeaders.appendChild(this.createHeaderRow(this.colCount));
this.createTableBody(tableBody, this.rowCount, this.colCount);
this.populateTable();
} catch (e) {
console.log("Failed to create a spreadsheet in the table editor view, error message: " + e);
}
},
/**
* populateTable - Fill data in created table from saved data
*/
populateTable: function() {
try {
const data = this.getData();
if (data === undefined || data === null) return;
for (let i = 0; i < data.length; i++) {
for (let j = 1; j < data[i].length; j++) {
const cell = this.$el.find(`#r-${i}-${j}`)[0];
let value = data[i][j];
if (i > 0) {
cell.innerHTML = data[i][j];
} else {
// table headers
if (!value) {
value = "Col " + j;
}
$(cell).find(".column-header-span")[0].innerHTML = value;
}
}
}
} catch (e) {
console.log("Failed to populate the table in the table editor view, error message: " + e);
}
},
/**
* getData - Get the saved data and parse it. If there's no saved data,
* create it.
*/
getData: function() {
try {
let data = this.tableData;
if (data === undefined || data === null || data.length == 0) {
return this.initializeData();
}
return JSON.parse(data);
} catch (e) {
console.log("Failed to get and parse data in the Table Editor View, error message: " + e);
}
},
/**
* initializeData - Create some empty arrays to hold data
*/
initializeData: function() {
try {
const data = [];
for (let i = 0; i <= this.rowCount; i++) {
const child = [];
for (let j = 0; j <= this.colCount; j++) {
child.push("");
}
data.push(child);
}
return data;
} catch (e) {
console.log("Failed to create new data in the Table Editor View, error message: " + e);
}
},
/**
* updateData - When the user focuses out, presume they've changed the data,
* and updated the saved data.
*
* @param {event} e The focus out event that triggered this function
*/
updateData: function(e) {
try {
if (e.target) {
let item;
let newValue;
if (e.target.nodeName === "TD") {
item = e.target;
newValue = item.textContent;
} else if (e.target.classList.contains("column-header-span")) {
item = e.target.parentNode;
newValue = e.target.textContent;
}
if (item) {
const indices = item.id.split("-");
let spreadsheetData = this.getData();
spreadsheetData[indices[1]][indices[2]] = newValue;
this.saveData(spreadsheetData);
}
}
} catch (e) {
console.log("Failed to update data in the Table Editor View, error message: " + e);
}
},
/**
* saveData - Save the data as a string.
*
* @param {type} data description
* @return {type} description
*/
saveData: function(data) {
try {
this.tableData = JSON.stringify(data);
} catch (e) {
console.log("Failed to save data in the Table Editor View, error message: " + e);
}
},
/**
* resetData - Clear the saved data and reset the table to the default
* number of rows & columns
*
* @param {event} e - the event that triggered this function
*/
resetData: function(e) {
try {
confirmation = confirm("This will erase all data and reset the table. Are you sure?");
if (confirmation == true) {
this.tableData = "";
this.rowCount = this.initialRowCount;
this.colCount = this.initialColCount;
this.createSpreadsheet();
} else {
return
}
} catch (e) {
console.log("Failed to reset data in the Table Editor View, error message: " + e);
}
},
/**
* createHeaderRow - Create a header row for the table
*/
createHeaderRow: function() {
try {
const tr = document.createElement("tr");
tr.setAttribute("id", "r-0");
for (let i = 0; i <= this.colCount; i++) {
const th = document.createElement("th");
th.setAttribute("id", `r-0-${i}`);
th.setAttribute("class", `${i === 0 ? "" : "column-header"}`);
if (i !== 0) {
const span = document.createElement("span");
span.innerHTML = `Col ${i}`;
span.setAttribute("class", "column-header-span");
span.setAttribute("contentEditable", "true");
const dropDownDiv = document.createElement("div");
dropDownDiv.setAttribute("class", "dropdown");
dropDownDiv.innerHTML = `
`;
th.appendChild(span);
th.appendChild(dropDownDiv);
}
tr.appendChild(th);
}
return tr;
} catch (e) {
console.log("Failed to create header row in the Table Editor View, error message: " + e);
}
},
/**
* createTableBodyRow - Create a row for the table
*
* @param {number} rowNum The table row number to add to the table, where 0 is the header row
*/
createTableBodyRow: function(rowNum) {
try {
const tr = document.createElement("tr");
tr.setAttribute("id", `r-${rowNum}`);
for (let i = 0; i <= this.colCount; i++) {
const cell = document.createElement(`${i === 0 ? "th" : "td"}`);
// header
if (i === 0) {
cell.contentEditable = false;
const span = document.createElement("span");
const dropDownDiv = document.createElement("div");
span.innerHTML = rowNum;
dropDownDiv.setAttribute("class", "dropdown");
dropDownDiv.innerHTML = `
`;
cell.appendChild(span);
cell.appendChild(dropDownDiv);
cell.setAttribute("class", "row-header");
} else {
cell.contentEditable = true;
}
cell.setAttribute("id", `r-${rowNum}-${i}`);
tr.appendChild(cell);
}
return tr;
} catch (e) {
console.log("Failed to create table row in the Table Editor View, error message: " + e);
}
},
/**
* createTableBody - Given a table element, add table rows
*
* @param {HTMLElement} tableBody A table HTML Element
*/
createTableBody: function(tableBody) {
try {
for (let rowNum = 1; rowNum <= this.rowCount; rowNum++) {
tableBody.appendChild(this.createTableBodyRow(rowNum));
}
} catch (e) {
console.log("Failed to create table body in the Table Editor View, error message: " + e);
}
},
/**
* addRow - Utility function to add row
*
* @param {number} currentRow The row number at which to add a new row
* @param {string} direction Can be "top" or "bottom", indicating whether to new row should be above or below the current row
*/
addRow: function(currentRow, direction) {
try {
let data = this.getData();
const colCount = data[0].length;
const newRow = new Array(colCount).fill("");
if (direction === "top") {
data.splice(currentRow, 0, newRow);
} else if (direction === "bottom") {
data.splice(currentRow + 1, 0, newRow);
}
this.rowCount++;
this.saveData(data);
this.createSpreadsheet();
} catch (e) {
console.log("Failed to add row in the Table Editor View, error message: " + e);
}
},
/**
* deleteRow - Utility function to delete row
*
* @param {number} currentRow The row number to delete
*/
deleteRow: function(currentRow) {
try {
let data = this.getData();
// Don't allow deletion of the last row
if (data.length <= 2) {
this.resetData();
return;
}
data.splice(currentRow, 1);
this.rowCount--;
this.saveData(data);
this.createSpreadsheet();
} catch (e) {
console.log("Failed to delete row in the Table Editor View, error message: " + e);
}
},
/**
* addColumn - Utility function to add columns
*
* @param {number} currentCol The column number at which to add a new column
* @param {string} direction Can be "left" or "right", indicating whether to new column should be to the left or right of the current column
*/
addColumn: function(currentCol, direction) {
try {
let data = this.getData();
for (let i = 0; i <= this.rowCount; i++) {
if (direction === "left") {
data[i].splice(currentCol, 0, "");
} else if (direction === "right") {
data[i].splice(currentCol + 1, 0, "");
}
}
this.colCount++;
this.saveData(data);
this.createSpreadsheet();
} catch (e) {
console.log("Failed to add column in the Table Editor View, error message: " + e);
}
},
/**
* deleteColumn - Utility function to delete column
*
* @param {number} currentCol The number of the column to delete
*/
deleteColumn: function(currentCol) {
try {
let data = this.getData();
// Don't allow deletion of the last column
if (data[0].length <= 2) {
this.resetData();
return;
}
for (let i = 0; i <= this.rowCount; i++) {
data[i].splice(currentCol, 1);
}
this.colCount--;
this.saveData(data);
this.createSpreadsheet();
} catch (e) {
console.log("Failed to delete column in the Table Editor View, error message: " + e);
}
},
/**
* sortColumn - Utility function to sort columns
*
* @param {number} currentCol The column number of the column to delete
*/
sortColumn: function(currentCol) {
try {
let spreadSheetData = this.getData();
let data = spreadSheetData.slice(1);
let headers = spreadSheetData.slice(0, 1)[0];
if (!data.some(a => a[currentCol] !== "")) return;
if (this.sortingHistory.has(currentCol)) {
const sortOrder = this.sortingHistory.get(currentCol);
switch (sortOrder) {
case "desc":
data.sort(this.ascSort.bind(this, currentCol));
this.sortingHistory.set(currentCol, "asc");
break;
case "asc":
data.sort(this.dscSort.bind(this, currentCol));
this.sortingHistory.set(currentCol, "desc");
break;
}
} else {
data.sort(this.ascSort.bind(this, currentCol));
this.sortingHistory.set(currentCol, "asc");
}
data.splice(0, 0, headers);
this.saveData(data);
this.createSpreadsheet();
} catch (e) {
console.log("Failed to sort column in the Table Editor View, error message: " + e);
}
},
/**
* ascSort - Compare Functions for sorting - ascending
*
* @param {number} currentCol The number of the column to sort
* @param {*} a One of two items to compare
* @param {*} b The second of two items to compare
* @return {number} A number indicating the order to place a vs b in the list. It it returns less than zero, then a will be placed before b in the list.
*/
ascSort: function(currentCol, a, b) {
try {
let _a = a[currentCol];
let _b = b[currentCol];
if (_a === "") return 1;
if (_b === "") return -1;
// Check for strings and numbers
if (isNaN(_a) || isNaN(_b)) {
_a = _a.toUpperCase();
_b = _b.toUpperCase();
if (_a < _b) return -1;
if (_a > _b) return 1;
return 0;
}
return _a - _b;
} catch (e) {
console.log("The ascending compare function in Table Editor View failed, error message: " + e);
return 0;
}
},
/**
* dscSort - Descending compare function
*
* @param {number} currentCol The number of the column to sort
* @param {*} a One of two items to compare
* @param {*} b The second of two items to compare
* @return {number} A number indicating the order to place a vs b in the list. It it returns less than zero, then a will be placed before b in the list.
*/
dscSort: function(currentCol, a, b) {
try {
let _a = a[currentCol];
let _b = b[currentCol];
if (_a === "") return 1;
if (_b === "") return -1;
// Check for strings and numbers
if (isNaN(_a) || isNaN(_b)) {
_a = _a.toUpperCase();
_b = _b.toUpperCase();
if (_a < _b) return 1;
if (_a > _b) return -1;
return 0;
}
return _b - _a;
} catch (e) {
console.log("The descending compare function in Table Editor View failed, error message: " + e);
return 0;
}
},
/**
* convertToMarkdown - Returns the table data as markdown
*
* @return {string} The markdownified table as string
*/
getMarkdown: function() {
try {
// Ensure there are at least two dashes below the table header,
// i.e. use | -- | not | - |
// Showdown requries this to avoid ambiguous markdown.
const minStringLength = function(s) {
l = s.length <= 1 ? 2 : s.length;
return l
}
// Get the current table data
var tableData = this.getData();
// Remove the empty column that we use for row numbers first
if (this.hasEmptyCol1(tableData)) {
for (let i = 0; i <= (tableData.length - 1); i++) {
tableData[i].splice(0, 1);
}
}
// Convert json data to markdown, for options see https://github.com/wooorm/markdown-table
// TODO: Add alignment information that we will store in view as an array
// Include in markdownTableFromJson() options like this - align: ['l', 'c', 'r']
var markdown = markdownTableFromJson(tableData, {
stringLength: minStringLength
});
// Add a new line to the end
return markdown + "\n";
} catch (e) {
console.log("Failed to convert json to markdown in the Table Editor View, error message: " + e);
return "";
}
},
/**
* getJSONfromMarkdown - Converts a given markdown table string to JSON.
*
* @param {string} markdown description
* @return {Array} The markdown table as an array of arrays, where the header is the first array and each row is an array that follows.
*/
getJSONfromMarkdown: function(markdown) {
try {
parsedMarkdown = markdownTableToJson(markdown);
if (!parsedMarkdown) return;
// TODO: Add alignment information to the view, returned as parsedMarkdown.align
return parsedMarkdown.table;
} catch (e) {
console.log("Failed to parse markdown in the Table Editor View, error message: " + e);
return [];
}
},
/**
* hasEmptyCol1 - Checks whether the first column is empty.
*
* @param {Object} data The table data in the form of an array of arrays
* @return {boolean} returns true if the first column is empty, false if at least one cell in the first column contains a value
*/
hasEmptyCol1: function(data) {
try {
var firstColEmpty = true;
// Check if the first item in each row is blank
for (let i = 0; i <= (data.length - 1); i++) {
if (data[i][0] != "") {
firstColEmpty = false;
break;
}
}
return firstColEmpty;
} catch (e) {
console.log("Failed to detect if there's an empty first column in the Table Editor View. Assuming the first column has data, but this could cause some issues. Error message: " + e);
return false;
}
},
/**
* closeDropdown - Close the dropdown menu if the user clicks outside of it
*
* @param {type} e The event that triggered this function
*/
closeDropdown: function(e) {
try {
if (!e.target.matches(".dropbtn") || !e) {
var dropdowns = document.getElementsByClassName("dropdown-content");
var i;
for (i = 0; i < dropdowns.length; i++) {
var openDropdown = dropdowns[i];
if (openDropdown.classList.contains("show")) {
openDropdown.classList.remove("show");
}
}
}
} catch (e) {
console.log("Failed to close a dropdown menu in the Table Editor View, error message: " + e);
}
},
/**
* handleHeadersClick - Called when the table header is clicked. Depending
* on what is clicked, shows or hides the dropdown menus in the header,
* or calls one of the functions listed in the menu (e.g. delete column).
*
* @param {event} e The event that triggered this function
*/
handleHeadersClick: function(e) {
try {
var view = this;
if (e.target) {
var classes = e.target.classList;
if (classes.contains("column-header-span")) {
// If the header element is clicked...
} else if (classes.contains("dropbtn")) {
const idArr = e.target.id.split("-");
document
.getElementById(`col-dropdown-${idArr[2]}`)
.classList.toggle("show");
} else if (classes.contains("col-dropdown-option")) {
const index = e.target.parentNode.id.split("-")[2];
if (classes.contains("col-insert-left")) {
view.addColumn(index, "left");
} else if (classes.contains("col-insert-right")) {
view.addColumn(index, "right");
} else if (classes.contains("col-sort")) {
view.sortColumn(index);
} else if (classes.contains("col-delete")) {
view.deleteColumn(index);
}
}
}
} catch (e) {
console.log("Failed to handle a click in the table header in the Table Editor View, error message: " + e);
}
},
/**
* handleHeadersClick - Called when the table body is clicked. Depending
* on what is clicked, shows or hides the dropdown menus in the body,
* or calls one of the functions listed in the menu (e.g. delete row).
*
* @param {type} e description
* @return {type} description
*/
handleBodyClick: function(e) {
try {
var view = this;
if (e.target) {
var classes = e.target.classList;
if (classes.contains("dropbtn")) {
const idArr = e.target.id.split("-");
view.$el.find(`#row-dropdown-${idArr[2]}`)[0]
.classList.toggle("show");
} else if (classes.contains("row-dropdown-option")) {
const index = parseInt(e.target.parentNode.id.split("-"))[2];
if (classes.contains("row-insert-top")) {
view.addRow(index, "top");
}
if (classes.contains("row-insert-bottom")) {
view.addRow(index, "bottom");
}
if (classes.contains("row-delete")) {
view.deleteRow(index);
}
}
}
} catch (e) {
console.log("Failed to handle a click in the table body in the Table Editor View, error message: " + e);
}
}
});
return TableEditorView;
});