Source: src/js/views/TableEditorView.js

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 = `
            <button class="dropbtn" id="col-dropbtn-${i}">
              <i class="icon pointer icon-caret-down"></i>
            </button>
              <div id="col-dropdown-${i}" class="dropdown-content">
                <button class="col-dropdown-option col-insert-left"><i class="icon icon-long-arrow-left icon-on-left"></i>Insert 1 column left</button>
                <button class="col-dropdown-option col-insert-right"><i class="icon icon-long-arrow-right icon-on-left"></i>Insert 1 column right</button>
                <button class="col-dropdown-option col-sort"><i class="icon icon-sort-by-attributes icon-on-left"></i>Sort column</button>
                <button class="col-dropdown-option col-delete"><i class="icon icon-remove icon-on-left"></i>Delete column</button>
              </div>
            `;
                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 = `
            <button class="dropbtn" id="row-dropbtn-${rowNum}">
              <i class="icon pointer icon-caret-right"></i>
            </button>
              <div id="row-dropdown-${rowNum}" class="dropdown-content">
                <button class="row-dropdown-option row-insert-top"><i class="icon icon-long-arrow-up icon-on-left"></i>Insert 1 row above</button>
                <button class="row-dropdown-option row-insert-bottom"><i class="icon icon-long-arrow-down icon-on-left"></i>Insert 1 row below</button>
                <button class="row-dropdown-option row-delete"><i class="icon icon-remove icon-on-left"></i>Delete row</button>
              </div>
            `;
                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;

  });