A bit of functional fun

Sorting Table Columns

A Functional Approach

I was toying with whether to post this in the ‘Sorting data in ascending and descending order’ thread, but I think given the added complexity and that getBypath and currying are involved it would be better placed here.

For reference here is a link to that thread. Anyway onwards…

Ascend and Descend

Looking through RamdaJS’ docs I came across two functions ascend and decend.

They are comparator functions that have three parameters (fn, a, b). The first is a callback that will be invoked on the other two arguments a and b.

It is maybe helpful if I show an example from the official RamdaJS site

const byAge = R.ascend(R.prop('age'));
const people = [
  { name: 'Emma', age: 70 },
  { name: 'Peter', age: 78 },
  { name: 'Mikhail', age: 62 },
];
const peopleByYoungestFirst = R.sort(byAge, people);
  //=> [{ name: 'Mikhail', age: 62 },{ name: 'Emma', age: 70 }, { name: 'Peter', age: 78 }]

If we look under the hood, you can maybe get a better idea. These are basic functions with the added benefit of being curried.

var ascend = _curry3(function ascend(fn, a, b) {
  var aa = fn(a);
  var bb = fn(b);
  return aa < bb ? -1 : aa > bb ? 1 : 0;
})

The currying enables us to pass in a callback, returning a binary function we can then pass straight into sort. To illustrate.

// pass in a callback that will be invoked on both a and b
const lettersAscending = ascend((letter) => letter.toLowerCase())

['d', 'C', 'b', 'a'].sort(lettersAscending) // ['a', 'b', 'C', 'd']

I appreciate that keeping things ‘stupid simple’, is often the best way, but given what I have learned about localCompare and the pitfalls of an (a < b) and (a > b) approach, I opted for a re-write. In fact I opted to go with Intl.Collator.

Intl.Collator is very similar to localeCompare, with the difference being that rather than calling it directly we call it’s constructor and it returns an object with a compare method e.g.

const comparator = new Intl.Collator(undefined, { numeric: true }).compare
// or with destructuring
const { compare } = new Intl.Collator(undefined, { numeric: true })

['d', 'C', 'b', 'a'].sort(compare)
// ['a', 'b', 'C', 'd']

['101', '212', '3', '2'].sort(compare)
// ['2', '3', '101', '212']

With that sorted I was then able to re-write the ascend and descend functions.

const ascend = curry3((fn, a, b) => compare(fn(a), fn(b)))
const descend = curry3((fn, a, b) => compare(fn(b), fn(a)))

Isolating the Table Columns

Sorting the columns involves clicking on a table heading, this then toggles the column between ascending and descending order.

The table headings have cell indexes and these indexes correspond with the table row cell indexes for a particular column.

tableHead  HeadingA    HeadingB    HeadingC
         cellindex 0 cellindex 1 cellindex 2
tabelRow1  cells[0]    cells[1]    cells[2]
tabelRow2  cells[0]    cells[1]    cells[2]

So we access the textcontent from a specific column with

currTableRow.cells[currHeading.cellIndex].textContent

Given we have a function handy, I opted to use getByPath to do this. That way I could pass the returned function into ascend or descend as a callback.

const getTextFromColumn = getByPath(['cells', currentHeading.cellIndex, 'textContent'])

rows.sort(ascend(getTextFromColumn))

Sort Handler

To cut to the chase my final handler module ended up looking like this

// import Ascend and Descend comparator functions
import { getByPath, ascend, descend } from './functional.js'

const tBody = document.querySelector('#table-people tbody')

const appendRow = tBody.appendChild.bind(tBody) // bind appendChild method to tBody

export const sortHandler = function (event) {
    const currentHeading = event.currentTarget
    // if no data-ascend attribute leave
    if (!currentHeading.hasAttribute('data-ascend')) return

    const getTextFromColumn = getByPath(['cells', currentHeading.cellIndex, 'textContent'])
    const ascending = (currentHeading.dataset.ascend === 'true')
    const compare = (ascending) ? descend : ascend

    Array
        .from(tBody.rows)
        .sort(compare(getTextFromColumn))
        .forEach(appendRow)

    currentHeading.dataset.ascend = !ascending // toggle order direction
}

Here is a codepen. The data comes from a handy API called Mockaroo.

If you are interested here is a Github Repo. There are two versions on there, one Vanilla JS which uses the fetch API and a Node JS version using Axios to pull in the data from Mockaroo. Getting a bit of practice in :slight_smile:

Conclusion

I know there are quicker and dirtier ways to do this, but again love the simple declarative code in the final Array sort. Add to that the re-usability and composability of these functions.

1 Like