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;
});