Javascript Sorting Nulls Wrong In HTML Table

Viewed 845

I'm using the below JavaScript code to sort my tables alphabetically and numerical. However, it puts rows with null values at the top instead of the bottom.

In the below image, taken from this URL I am working on, when sorting the table from biggest to smallest in the Rank Change column, nulls are at the top instead of the bottom.

*In this table, Null values are the cells with the NEW tag or a dash. *The problem applies to all columns/rows

Shouldn't Nulls be classed as less than 1 and sorted as such? What am I doing wrong?

Any help is really appreciated.

enter image description here

const getCellValue = (tr, idx) => tr.children[idx].innerText || tr.children[idx].textContent;

const comparer = (idx, asc) => (a, b) => ((v1, v2) => 
    v1 !=='' && v2 !=='' && !isNaN(v1) && !isNaN(v2) ? v1 - v2 : v1.toString().localeCompare(v2)
    )(getCellValue(asc ? a : b, idx), getCellValue(asc ? b : a, idx));

document.querySelectorAll('th').forEach(th => th.addEventListener('click', (() => {
    const table = th.closest('table');
    Array.from(table.querySelectorAll('tr:nth-child(n+2)'))
        .sort(comparer(Array.from(th.parentNode.children).indexOf(th), this.asc = !this.asc))
        .forEach(tr => table.appendChild(tr) );
})));
6 Answers

You could take a check for null values first and the check both values for finiteness, like numbers or strings who are coercible to number and then take either the delta of the numbers or sort by string.

Examples:

 v1   v2  (v1 === null) - (v2 === null) isFinite(v1) && isFinite(v2)             result
---- ---- ----------------------------- ---------------------------------------- ------
null null       true -  true ->  0       true -> v1 - v2                             0
null abc        true - false ->  1                                                   1
null  2         true - false ->  1                                                   1
abc  null      false -  true -> -1                                                  -1
 2   null      false -  true -> -1                                                  -1
abc  abc       false - false ->  0      false -> v1.toString().localeCompare(v2)     0
abc   2        false - false ->  0      false -> v1.toString().localeCompare(v2)     1
 2   abc       false - false ->  0      false -> v1.toString().localeCompare(v2)    -1
 2    2        false - false ->  0       true -> v1 - v2                             0

Code:

const comparer = (idx, asc) => (a, b) => ((v1, v2) =>
    (v1 === null) - (v2 === null) ||
    (isFinite(v1) && isFinite(v2)
        ? v1 - v2
        : v1.toString().localeCompare(v2)
    )
)(getCellValue(asc ? a : b, idx), getCellValue(asc ? b : a, idx));

Working Example:

var array = [null, 2, 1, 20, 11, 'b', 'aaa', 'a', null];

array.sort((v1, v2) => 
    (v1 === null) - (v2 === null) ||
    (isFinite(v1) && isFinite(v2)
        ? v1 - v2
        : v1.toString().localeCompare(v2)
    )
);

console.log(...array);

Considering null as -Infinity should fix the sorting. I can suggest to use the asc property on the th element so you can avoid using 'this'.

// don't know if theres any other listeners on the th element so i clear them before use my code ( just for testing )
document.querySelectorAll('th').forEach((th, idx) => th.removeEventListener('click', () => {}, true));
document.querySelectorAll('th').forEach((th, idx) => th.addEventListener('click', (() => {
    const table = th.closest('table');
    th.asc = !th.asc;
    [...table.querySelectorAll('tr:nth-child(n+2)')]
        .sort((a, b) => +((th.asc ? a : b).children[idx].innerText || -Infinity) - +((th.asc ? b : a).children[idx].innerText || -Infinity))
        .forEach(tr => table.appendChild(tr));
})));

If it is NaN you should set the change to 0 Or +-Infinity depending where you want it top bottom or at 0 change. (the value change is applied only when comparing)

I did the code expanded for easy understanding.

Check this comparerNew and modify it to your needs.


const comparerNew = (idx, asc) => (a, b) => ((v1, v2) => {      
    if (isNaN(v1)) {
      v1=0; // or Infinity 
    }
    if (isNaN(v2)) {
      v2=0; // or Infinity 
    }
    return v1 !=='' && v2 !=='' ? v1 - v2 : v1.toString().localeCompare(v2)
}

   )(getCellValue(asc ? a : b, idx), getCellValue(asc ? b : a, idx));

document.querySelectorAll('th').forEach(th => th.addEventListener('click', (() => {
    const table = th.closest('table');
    Array.from(table.querySelectorAll('tr:nth-child(n+2)'))
        .sort(comparerNew(Array.from(th.parentNode.children).indexOf(th), this.asc = !this.asc))
        .forEach(tr => table.appendChild(tr) );
  console.log("sorded");
})));

Here is an example which does not coerce null to -1 nor rely on tricking to infty which still allows you to put the null values to the end.

  • the loadData payload should reflect your json (I copied from it)
  • the sort handler arguably stores the last sort (asc/decs) on the th itself. You may obviously consider an array as an alternative should it pleases you

Straightfoward way

  • We store the rows in memory
  • We sort the rows (in memory)
  • We replot the table by recreating all tr/tds

function loadData () {
  return fetch('https://brendonbrewer.com/lbrynomics/subscriber_counts.json').then(r => r.json())
  return Promise.resolve({
    rank_change: [null, null, 5,    6, 8, 2],
    views_change: [null, 5,    null, 6, 7, 2],
  })
}
let rows = []
const myTable = document.querySelector('table')

function render(myTable, rows) {
  myTable.tBodies[0].innerHTML = ''
  rows.forEach(row => {
    const tr = document.createElement('tr')
    row.forEach(f => {
      const td = document.createElement('td')
      td.innerText = f == null ? '--' : f
      tr.appendChild(td)
    })
    myTable.tBodies[0].appendChild(tr)
  })
}

loadData().then(iRows => {
  // store the fields in order of your DOM nodes
  rows = iRows.views_change.map((_,i) => [iRows.views_change[i], iRows.rank_change[i]])
  render(myTable, rows)
})

// sort the rows
document.querySelector('table thead').onclick = e => {
  const th = e.target
  if (th.nodeName !== 'TH') { return }
  const order = th.getAttribute('data-sort') === '-1' ? -1 : 1
  th.setAttribute('data-sort', order * -1)
  const fieldIndex = [...th.parentNode.children].findIndex(other => other === th)
  const cmp = (r, s) => {
    const a = r[fieldIndex]
    const b = s[fieldIndex]
    return a === null && b === null ? 0 :
      a === null && b !== null ? 1 :
      b === null && a !== null ? -1 :
      a - b
  }
  rows.sort(order === 1 ? cmp : (a,b) => -1 * cmp(a,b))
  console.time('render')
  render(myTable, rows)
  console.timeEnd('render')
}
<table id="sub-stats">
  <thead> <!-- first fix your th. They go to thead. -->
    <tr>
      <th class="views-change">Views Change</th>  
      <th class="rank-change">Rank Change</th>
    </tr>
  </thead>
  <tbody>
  </tbody>
</table>

Same but we reuse the existing rows

We basically map each row in memory to its DOM node (a tr that is). See rowToNode in code below

function loadData () {
  return fetch('https://brendonbrewer.com/lbrynomics/subscriber_counts.json').then(r => r.json())
  return Promise.resolve({
    rank_change: [null, null, 5,    6, 8, 2],
    views_change: [null, 5,    null, 6, 7, 2],
  })
}
let rows = []
let rowToNode = new Map()
const myTable = document.querySelector('table')

function init(myTable, rows) {
  const rowToNode = new Map()
  rows.forEach(row => {
    const tr = document.createElement('tr')
    row.forEach(f => {
      const td = document.createElement('td')
      td.innerText = f == null ? '--' : f
      tr.appendChild(td)
    })
    myTable.tBodies[0].appendChild(tr)
    rowToNode.set(row, tr)
  })
  return rowToNode
}

loadData().then(iRows => {
  // store the fields in order of your DOM nodes
  rows = iRows.views_change.map((_,i) => [iRows.views_change[i], iRows.rank_change[i]])
  rowToNode = init(myTable, rows)
})

// sort the rows
document.querySelector('table thead').onclick = e => {
  const th = e.target
  if (th.nodeName !== 'TH') { return }
  const order = th.getAttribute('data-sort') === '-1' ? -1 : 1
  th.setAttribute('data-sort', order * -1)
  const fieldIndex = [...th.parentNode.children].findIndex(other => other === th)
  const cmp = (r, s) => {
    const a = r[fieldIndex]
    const b = s[fieldIndex]
    return a === null && b === null ? 0 :
      a === null && b !== null ? 1 :
      b === null && a !== null ? -1 :
      a - b
  }
  rows.sort(order === 1 ? cmp : (a,b) => -1 * cmp(a,b))
  console.time('render')
  myTable.tBodies[0].innerHTML = ''
  const tpl = new DocumentFragment()
  rows.forEach(r => {
    tpl.appendChild(rowToNode.get(r))
  })
  myTable.tBodies[0].appendChild(tpl)
  console.timeEnd('render')
}
<!DOCTYPE html>
<html>
<body>
<table id="sub-stats">
  <thead> <!-- first fix your th. They go to thead. -->
    <tr>
      <th class="views-change">Views Change</th>  
      <th class="rank-change">Rank Change</th>
    </tr>
  </thead>
  <tbody>
  </tbody>
</table>
</body>
</html>

A bit of refactoring

Global variables are not the best, the pesky variables rows and its associated rowToNode are not the more glamorous and get a scope bigger than they need.

A possible way is to make a Component which scopes internally those variables, and eventually put it in its own file. Being a bit funky we could write a WebComponent

class MyTable extends HTMLTableElement {
  constructor () {
    super ()
    this._shadowRoot = this.attachShadow({ 'mode': 'open' });
    this._rows = []
    this.rowToNode = new Map()
    const template = document.createElement('template')
    template.innerHTML = `
      <thead>
        <tr>
          <th class="views-change">Views Change</th>  
          <th class="rank-change">Rank Change</th>
        </tr>
      </thead>
      <tbody>
      </tbody>
      <style>th{cursor:pointer;}</style>
    `
    this._shadowRoot.appendChild(template.content)
    this.tbody = this._shadowRoot.querySelector('tbody')
    this.render();
    this._shadowRoot.querySelector('thead').onclick = this.handleSort.bind(this)
  }
  loadRows (rows) {
    this.rowToNode = new Map()
    this._rows = rows
    rows.forEach(row => {
      const tr = document.createElement('tr')
      row.forEach(f => {
        const td = document.createElement('td')
        td.innerText = f == null ? '--' : f
        tr.appendChild(td)
      })
      this.tbody.appendChild(tr)
      this.rowToNode.set(row, tr)
    })
  }
  render () {
    this.tbody.innerHTML = ''
    const tpl = new DocumentFragment()
    this._rows.forEach(r => {
      tpl.appendChild(this.rowToNode.get(r))
    })
    this._shadowRoot.querySelector('tbody').appendChild(tpl)
  }
  handleSort (e) {
    const th = e.target
    if (th.nodeName !== 'TH') { return }
    const order = th.getAttribute('data-sort') === '-1' ? -1 : 1
    th.setAttribute('data-sort', order * -1)
    const fieldIndex = [...th.parentNode.children].findIndex(other => other === th)
    const cmp = (r, s) => {
      const a = r[fieldIndex]
      const b = s[fieldIndex]
      return a === null && b === null ? 0 :
        a === null && b !== null ? 1 :
        b === null && a !== null ? -1 :
        a - b
    }
    this._rows.sort(order === 1 ? cmp : (a,b) => -1 * cmp(a,b))
    console.time('render')
    this.render()
    console.timeEnd('render')
  }
}
customElements.define('my-table', MyTable, { extends: 'table' })



function loadData () {
  return fetch('https://brendonbrewer.com/lbrynomics/subscriber_counts.json').then(r => r.json())
  return Promise.resolve({
    rank_change: [null, null, 5,    6, 8, 2],
    views_change: [null, 5,    null, 6, 7, 2],
  })
}
loadData().then(iRows => {
  // store the fields in order of your DOM nodes
  rows = iRows.views_change.map((_,i) => [iRows.views_change[i], iRows.rank_change[i]])
  document.querySelector('my-table').loadRows(rows)
})
<my-table id="sub-stats"></my-table>

Alternative to webcomponents

Finally if we don't want webcomponents but a crude object, we can make a crude object. Make some class which takes a (table) node as argument and operate on it the same way we did.

Below a code closer (DOM wise) to the live production.

class MyTable {
  constructor (node) {
    this.node = node
    this._rows = []
    this.rowToNode = new Map()
    const template = document.createElement('template')
    template.innerHTML = `
      <thead>
        <tr>
          <th class="ranks">Channel rank</th>
          <th class="vanity_names">Top 200 LBRY Channels</th>
          <th class="subscribers">followers</th>
          <th class="views">Content view</th>
          <th class="views_change">Views Change</th>  
          <th class="rank_change">Rank Change</th>
        </tr>
      </thead>
      <tbody>
      </tbody>
    `
    this.node.appendChild(template.content)
    this.tbody = this.node.querySelector('tbody')
    this.render();
    this.node.querySelector('thead').onclick = this.handleSort.bind(this)
  }
  loadRows (rows) {
    this.rowToNode = new Map()
    this._rows = rows
    rows.forEach(row => {
      const tr = document.createElement('tr')
      row.forEach(f => {
        const td = document.createElement('td')
        td.innerText = f == null ? '--' : f
        tr.appendChild(td)
      })
      this.tbody.appendChild(tr)
      this.rowToNode.set(row, tr)
    })
  }
  render () {
    this.tbody.innerHTML = ''
    const tpl = new DocumentFragment()
    this._rows.forEach(r => {
      tpl.appendChild(this.rowToNode.get(r))
    })
    this.tbody.appendChild(tpl)
  }
  handleSort (e) {
    const th = e.target
    if (th.nodeName !== 'TH') { return }
    const order = th.getAttribute('data-sort') === '-1' ? -1 : 1
    th.setAttribute('data-sort', order * -1)
    const fieldIndex = [...th.parentNode.children].findIndex(other => other === th)
    const cmp = (r, s) => {
      const a = r[fieldIndex]
      const b = s[fieldIndex]
      return a === null && b === null ? 0 :
        a === null && b !== null ? 1 :
        b === null && a !== null ? -1 :
        a - b
    }
    this._rows.sort(order === 1 ? cmp : (a,b) => -1 * cmp(a,b))
    console.time('render')
    this.render()
    console.timeEnd('render')
  }
}

function loadData () {
  return fetch('https://brendonbrewer.com/lbrynomics/subscriber_counts.json').then(r => r.json())
  return Promise.resolve({
    rank_change: [null, null, 5,    6, 8, 2],
    views_change: [null, 5,    null, 6, 7, 2],
  })
}
console.log('go')

const table = new MyTable(document.querySelector('table'))
table.render()
loadData().then(iRows => {
  // store the fields in order of your DOM nodes
  const fields = ['ranks', 'vanity_names', 'subscribers', 'views', 'views_change', 'rank_change']
  rows = iRows.views_change.map((_,i) => fields.map(f => iRows[f][i]))
  console.log('go')
  table.loadRows(rows)
})
<link href="https://lbrynomics.com/wp-content/themes/Divi/style.dev.css" rel="stylesheet"/>

<body class="page-template-default page page-id-12177 custom-background et-tb-has-template et-tb-has-footer et_pb_button_helper_class et_fullwidth_nav et_fixed_nav et_show_nav et_primary_nav_dropdown_animation_fade et_secondary_nav_dropdown_animation_fade et_header_style_left et_cover_background et_pb_gutter windows et_pb_gutters3 et_pb_pagebuilder_layout et_smooth_scroll et_no_sidebar et_divi_theme et-db gecko">
<div id="et-main-area">
<div id="main-content">
<div class="entry-content">
<div class="et-l et-l--post">
<div class="et_builder_inner_content et_pb_gutters3">
<div class="et_pb_section et_pb_section_0 et_pb_with_background et_section_regular">
<div class="et_pb_row et_pb_row_0">
<div class="et_pb_column et_pb_column_4_4 et_pb_column_0  et_pb_css_mix_blend_mode_passthrough et-last-child">
<div class="et_pb_module et_pb_code et_pb_code_0">
<div class="et_pb_module et_pb_tabs et_pb_tabs_0 et_slide_transition_to_1">
<div class="et_pb_tab et_pb_tab_1 clearfix et-pb-active-slide">
<div class="et_pb_tab_content">
<div class="et_pb_section et_pb_section_4 et_pb_with_background et_section_regular">
<div class="et_pb_row et_pb_row_3">
<div class="et_pb_column et_pb_column_4_4 et_pb_column_3  et_pb_css_mix_blend_mode_passthrough et-last-child">
<div class="et_pb_module et_pb_code et_pb_code_1">
<div class="et_pb_code_inner">


<table id="sub-stats"></table>









<div/>
<div/>
<div/>
<div/>
<div/>
<div/>
<div/>
<div/>
<div/>
<div/>
<div/>
<div/>
<div/>
<div/>
<div/>
<div/>
<div/>

</body>

Your code looks unfriendly. It's hard to read. I make a little refactor and also fix your problem:

function getCellValue (tr, idx) {
  const val = tr.children[idx].innerText.trim() || tr.children[idx].textContent.trim()

  if (!val || val === '-' || val.toLowerCase() === 'new') {
    return null
  }

  return val
}

function comparer (idx, asc) {
  asc = asc ? 1 : -1

  return function (a, b) {
    a = getCellValue(a, idx)
    b = getCellValue(b, idx)

    if (b === null) {
      return asc
    }

    if (a === null) {
      return -asc
    }

    if (isFinite(Number(a)) && isFinite(Number(b))) {
      return asc * (parseInt(a, 10) - parseInt(b, 10))
    }


    return asc * a.toString().localeCompare(b)
  }
}

I checked your site, and I think your rank changes are number. So getCellValue should return number only and compare with number. Please check the following code.

function getCellValue (tr, idx) {
    const val = tr.children[idx].innerText.trim() || tr.children[idx].textContent.trim()
    return !isNaN(val)?val:0;
}

function comparer (idx, asc) {
    asc = asc ? 1 : -1

    return function (a, b) {
        a = getCellValue(a, idx)
        b = getCellValue(b, idx)
        return asc * ( a - b);
    }
}
Related