7

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) );
})));
Electron
  • 969
  • 1
  • 8
  • 22

6 Answers6

8

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);
Nina Scholz
  • 376,160
  • 25
  • 347
  • 392
6

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));
})));
Vanojx1
  • 5,574
  • 2
  • 23
  • 37
  • I like the simplicity of this code. It only seems to sort in one direction though. Highest first. – Electron Mar 23 '20 at 10:21
  • Forget that, seems to be working. I didn't clear my cache! Just testing it fully now. – Electron Mar 23 '20 at 10:41
  • Actually, my first assumption seems to be right. It only sorts in one direction. Highest first. – Electron Mar 23 '20 at 11:22
  • Testing it on your page works fine for me. I've removed the prev event listener using removeEventListener – Vanojx1 Mar 23 '20 at 12:48
  • I put your code live (under Top 200 tab), but for me, I can only order it from highest to lowest. If I click again on the same column, nothing happens. https://lbrynomics.com/one/ – Electron Mar 23 '20 at 13:55
  • Use this code: document.querySelectorAll('th').forEach(th => th.removeEventListener('click', () => {}, true)); before testing my code. It removes your listeners before apply mine. Works for me using Chrome. – Vanojx1 Mar 23 '20 at 14:10
  • Nope, didn;t work for me. If you update your code, I can see how you're applying it. – Electron Mar 23 '20 at 14:51
  • removing the eventListener is not the way to go. You should beware of your 'this' keyword which is likely to be window. If you do not want to represent the state of the sort order in memory (idem a variable), you can store it as a data attribute on the th e.g – grodzi Mar 24 '20 at 06:54
  • Sorry, that was my mistake. But even when I put the eventListener back on, it didn't work. It still only went one way. – Electron Mar 24 '20 at 12:32
1

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

Jinxmcg
  • 1,830
  • 1
  • 21
  • 24
1

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>
grodzi
  • 5,633
  • 1
  • 15
  • 15
  • I like the concept of this. I added it to the live code. However, I had to alter it a little, because the code striped out the classes. To get around this, I added the classes to my constructor javascript. However, the code you provided, introduces some styling issues where things don't align and the headers are now missing from the table, so nothing can be sorted. – Electron Mar 25 '20 at 04:44
  • @InvariantChange If you doubt about webcomponents, you may just write a basic class, which takes a table node as property instead of extending it. I also don't see your code including this sample so I can't help you more on the webcomponent approach. See the edit with basic approach above which you may use instead. As a side note, your page feels very sluggish, I'd advise cleaning up your DOM but it is out of scope for this question – grodzi Mar 25 '20 at 06:38
  • I've put the code live again. Different result this time. Seems to be doubling up, then breaking. – Electron Mar 25 '20 at 09:43
  • please provide link, if possible linking to sources on a separate folder or something, I can't see what you did! And live code as of 12h30 seems to be working so probably not reflecting the "breaking" code @InvariantChange Please also notice than the standard procedure is to provide a minimal reproducing example. If you did so, we would have avoided those come back&forth since I (and other people) would have solved the problem on the minimal example already :) – grodzi Mar 25 '20 at 11:31
  • I tried minimal reproducing example when I first did the question, but it didn't work in Stack Overflows code snippet. The site with your live code is here under the Top 200 tab. To find the Javascript, search for "// Sort table" in source. https://lbrynomics.com/one/ – Electron Mar 25 '20 at 13:22
  • That's not my code at all ? I never ever read the cellContent to sort the rows. That is the essence of my answer. Neither do I compare NaN values while sorting @Invariant Change – grodzi Mar 25 '20 at 13:27
  • This is because you have the code $('#sub-stats').append(sub_data); ... running which likely removes the theads. You should add the class attributes to td(s) inside loadRows instead and not touch #sub-stats at all @InvariantChange – grodzi Mar 25 '20 at 14:11
  • I've tried everything. I think there is an issue somewhere else in my code elsewhere. Your answer and many of the others don't solve the issue, but they often do when standing alone. I think removing the nulls at source is going to be the only way forward. Really appreciate your help. – Electron Mar 25 '20 at 15:28
  • I think the issue is the jquery one, but as you said, if reproducing the error (as a minimal example) is hard, you may rely on the backend side, either by removing the NULL values or by tricking the null value as some number like 1e9 or infty (as proposed by almost everybody else) @InvariantChange – grodzi Mar 25 '20 at 19:03
0

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)
  }
}
JerryCauser
  • 811
  • 5
  • 17
0

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