Wiki-like table sorting for electron-based app

Viewed 199

I am writing a plugin for Obsidian (using their API), hoping to achieve wiki-like table sorting, i.e. clickable headers that can sort the table in ascending (1st click), then descending (2nd click), then finally restoring the original order (3rd click).

I wrote the following code that registers a click DOM event with a callback that checks whether the click was performed inside a table and if so, it sorts the table, essentially by reordering tr elements. However, I am not sure how to restore the table to the original state on the third click.

this.registerDomEvent(document, 'click', (evt: MouseEvent) => {
    const htmlEl = (<HTMLInputElement>evt.target);

    const th = htmlEl.closest('thead th');
    if (th == null) { return; }

    const table = htmlEl.closest('table');
    const tbody = table.querySelector('tbody');
    const thArray = Array.from(th.parentNode.children);
    const thIdx = thArray.indexOf(th);

    // all other th's are reset
    thArray.forEach((th, i) => {
        if (i != thIdx) {
            th.removeAttribute("class");
        }
    });

    // set clicked th class
    th.className = this.classNames[
        (this.classNames.indexOf(th.className) + 1) % this.classNames.length
    ];

    const ascending = th.className === "header-sort-up";

    Array.from(tbody.querySelectorAll('tr:nth-child(n)'))
        .sort(this.compareFn(thIdx, ascending))
        .forEach(tr => tbody.appendChild(tr));
});

One idea I had was to make use of the localStorage to store the original innerHTML of the table, but I think this will not scale well for persistent, long-term usage, and lots of tables. Moreover, I don't want to add extra dependencies, if possible.

1 Answers

For this problem, I'm going to assume that the table will have a <thead> (for the clickable elements) and a single <tbody>.

Native JS API for interacting with <table>

A good thing to know is that a <table> element in JS has a special interface HTMLTableElement that allows us to perform a few selections / actions easily. So once we select our element:

const table = document.querySelector('table')

We have access to its entire structure. For example:

  • table.tHead returns the <thead>
  • table.tBodies[0] returns the <tbody>
  • table.tBodies[0].deleteRow(-1) removes the last <tr> from the body
  • table.tBodies[0].rows[0].cells[0].cellIndex returns the index of the first cell (th or td) in the first row (tr) of the tbody
  • ...

Defining a component state

Also, because of the behavior you describe, we know we're going to need to store a few "state" variables:

  • initialList the initial order of rows

    This one is easy with the HTMLTableSectionElement interface .rows, stored into an array, and we're never touching it again

    Array.from(tBody.rows)
    
  • currentOrder the current order in which to sort the rows (either the initial, ascending, or descending order)

    For this, I'm going to simply use either 0, 1, or 2 based on the three possible orders. Which makes it easy to iterate over: we just need to run the following line on every order change:

    currentOrder = (currentOrder + 1) % 3
    
  • currentSortReference the current column that we want to use to sort the rows

    For this one, I'll go with storing the index of the column used for sorting. To get that value, we just need to know the index of the <tr> clicked in <thead>. This is made easy by the HTMLTableCellElement interface cellIndex:

    const index = currentTarget.cellIndex
    

Basic utility functions

For a problem like this, I like to start by writing a few simple utility functions. They are nothing complicated, just using standard JS (and again, native HTMLTableElement interfaces). Here we will need

  • emptyTable to empty the table body

    function emptyTable(tBody, rows) {
        rows.forEach(() => tBody.deleteRow(-1))
    }
    
  • fillTable to fill the table body with ordered rows

    function fillTable(tBody, rows) {
        rows.forEach((row) => tBody.appendChild(row))
    }
    
  • valueFromCell to parse a value from the text in a cell

    function valueFromCell(element) {
        return Number(element.textContent)
    }
    
  • compareRows to compare the values of 2 rows (based on the values in a specific column). This is meant to be the compare function of an Array.sort() which is why it returns these values

    function compareRows(a, b, index) {
        const valueA = valueFromCell(a.cells[index])
        const valueB = valueFromCell(b.cells[index])
        return valueA >= valueB
            ? 1
            : -1
    }
    

Component logic

Now that we have all these pieces, it's just a matter of putting them together:

  1. onclick event onHeadClick

    • get the column that was clicked (this is event.currentTarget.cellIndex like mentioned above)
    • if it's the same that was clicked before (currentSortReference), just iterate the order (currentOrder)
    • if it's not, reset the order
    • sort the table based on the current state (see below)
    function onHeadClick({currentTarget}) {
        const index = currentTarget.cellIndex
        if (currentSortReference === index) {
            currentOrder = (currentOrder + 1) % 3
        } else {
            currentSortReference = index
            currentOrder = 1
        }
        sortTable(currentSortReference, currentOrder, tBody, initialList)
    }
    
  2. sort the table sortTable

    • empty the table with emptyTable
    • if it's the initial order, just fill the table (fillTable) with initialList
    • if it's not, create a new array from initialList and sort it with the native Array.sort and the simple function we defined before compareRows, and then fill the table (fillTable).
    function sortTable(sort, order, tBody, rows) {
        emptyTable(tBody, rows)
        if (order === 0) {
            fillTable(tBody, rows)
        } else {
            const newList = [...rows]
            newList.sort((a, b) => compareRows(a, b, sort))
            if(order === 2) {
                newList.reverse()
            }
            fillTable(tBody, newList)
        }
    }
    

Solution

That's it! It's quite a bit of code, but once you take it bit by bit, it's all fairly straightforward.

const table = document.querySelector('table')
const heads = table.tHead.rows[0].cells
const tBody = table.tBodies[0]

let currentSortReference = null
let currentOrder = 0
const initialList = Array.from(tBody.rows)

Array.from(heads).forEach(th => {
    th.addEventListener('click', onHeadClick)
})

/**
 * @param {MouseEvent} event
 */
function onHeadClick({currentTarget}) {
    const index = currentTarget.cellIndex
    if (currentSortReference === index) {
        currentOrder = (currentOrder + 1) % 3
    } else {
        currentSortReference = index
        currentOrder = 1
    }
    sortTable(currentSortReference, currentOrder, tBody, initialList)
}

/**
 * @param {Number} sort index of head to base sort on
 * @param {0 | 1 | 2} order 0: initial, 1: descending, 2: ascending
 * @param {HTMLTableSectionElement} tBody 
 * @param {HTMLTableRowElement[]} rows all rows, in initial order
 */
function sortTable(sort, order, tBody, rows) {
    emptyTable(tBody, rows)
    if (order === 0) {
        fillTable(tBody, rows)
    } else {
        const newList = [...rows]
        newList.sort((a, b) => compareRows(a, b, sort))
        if(order === 2) {
            newList.reverse()
        }
        fillTable(tBody, newList)
    }
}


/**
 * @param {HTMLTableRowElement} a 
 * @param {HTMLTableRowElement} b 
 * @param {Number} index of column to use in comparison
 */
function compareRows(a, b, index) {
    const valueA = valueFromCell(a.cells[index])
    const valueB = valueFromCell(b.cells[index])
    return valueA >= valueB
        ? 1
        : -1
}

/**
 * @param {HTMLTableCellElement} element 
 */
function valueFromCell(element) {
    return Number(element.textContent)
}

/**
 * @param {HTMLTableSectionElement} tBody 
 * @param {HTMLTableRowElement[]} rows
 */
function emptyTable(tBody, rows) {
    rows.forEach(() => tBody.deleteRow(-1))
}

/**
 * @param {HTMLTableSectionElement} tBody 
 * @param {HTMLTableRowElement[]} rows
 */
function fillTable(tBody, rows) {
    rows.forEach((row) => tBody.appendChild(row))
}
<table>
    <caption>Council budget (in £) 2018</caption>
    <thead>
        <tr>
            <th scope="col">Items</th>
            <th scope="col">Expenditure</th>
            <th scope="col">Qty</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <th scope="row">Donuts</th>
            <td>3000</td>
            <td>42</td>
        </tr>
        <tr>
            <th scope="row">Stationery</th>
            <td>18000</td>
            <td>3</td>
        </tr>
        <tr>
            <th scope="row">Chairs</th>
            <td>100</td>
            <td>248</td>
        </tr>
    </tbody>
</table>

Related