jquery.tablesorter: Add ability for cells with colspan in tbody
authorMatthiasDD <Matthias_K2@gmx.de>
Sat, 22 Aug 2015 21:46:27 +0000 (23:46 +0200)
committerDerk-Jan Hartman <hartman.wiki@gmail.com>
Thu, 10 Sep 2015 18:33:10 +0000 (20:33 +0200)
* Add manageColspans() to handle colspaned cells in table body.
  Add config.columns - number of colums with extended colspan, inclusive unsortable columns
  buildParserCache() iterate for all columns (not only the first body row, Bug: T105731)
  buildCache() also (Bug: T74534)
  Rows with not enougth cells get after first click additional empty cells.
* Clear unused header data 'sortDisabled' .
* Add $.tablesorter.getParsers() for better table diagnosis.

fix 3 litle bugs:
* Improve multi column sorting with colspan in header (add columnToHeader[..] to s[0] )
* Unsortable headers get after sorting no title tag. ($headers contain only sortable headers)
* Parser detection in tables with max. 5 rows and empty cells works now.

Bug: T105731
Bug: T74534
Change-Id: I518029616d4c10a48eeaad8e92962f4e580f9413

resources/src/jquery/jquery.tablesorter.js
tests/qunit/suites/resources/jquery/jquery.tablesorter.test.js

index 23474c0..eaa138b 100644 (file)
                }
        }
 
-       function detectParserForColumn( table, rows, cellIndex ) {
+       function detectParserForColumn( table, rows, column ) {
                var l = parsers.length,
+                       cellIndex,
                        nodeValue,
                        // Start with 1 because 0 is the fallback parser
                        i = 1,
+                       lastRowIndex = -1,
                        rowIndex = 0,
                        concurrent = 0,
+                       empty = 0,
                        needed = ( rows.length > 4 ) ? 5 : rows.length;
 
                while ( i < l ) {
-                       if ( rows[ rowIndex ] && rows[ rowIndex ].cells[ cellIndex ] ) {
-                               nodeValue = $.trim( getElementSortKey( rows[ rowIndex ].cells[ cellIndex ] ) );
+                       if ( rows[ rowIndex ] ) {
+                               if ( rowIndex !== lastRowIndex ) {
+                                       lastRowIndex = rowIndex;
+                                       cellIndex = $( rows[ rowIndex ] ).data( 'columnToCell' )[ column ];
+                                       nodeValue = $.trim( getElementSortKey( rows[ rowIndex ].cells[ cellIndex ] ) );
+                               }
                        } else {
                                nodeValue = '';
                        }
                                        i++;
                                        rowIndex = 0;
                                        concurrent = 0;
+                                       empty = 0;
                                }
                        } else {
                                // Empty cell
+                               empty++;
                                rowIndex++;
-                               if ( rowIndex > rows.length ) {
-                                       rowIndex = 0;
+                               if ( rowIndex >= rows.length ) {
+                                       if ( concurrent >= rows.length - empty ) {
+                                               // Confirmed the parser for all filled cells
+                                               return parsers[ i ];
+                                       }
+                                       // Check next parser, reset rows
                                        i++;
+                                       rowIndex = 0;
+                                       concurrent = 0;
+                                       empty = 0;
                                }
                        }
                }
        }
 
        function buildParserCache( table, $headers ) {
-               var sortType, cells, len, i, parser,
+               var sortType, len, j, parser,
                        rows = table.tBodies[ 0 ].rows,
+                       config = $( table ).data( 'tablesorter' ).config,
                        parsers = [];
 
                if ( rows[ 0 ] ) {
-
-                       cells = rows[ 0 ].cells;
-                       len = cells.length;
-
-                       for ( i = 0; i < len; i++ ) {
+                       len = config.columns;
+                       for ( j = 0; j < len; j++ ) {
                                parser = false;
-                               sortType = $headers.eq( i ).data( 'sortType' );
+                               sortType = $headers.eq( config.columnToHeader[ j ] ).data( 'sortType' );
                                if ( sortType !== undefined ) {
                                        parser = getParserById( sortType );
                                }
 
                                if ( parser === false ) {
-                                       parser = detectParserForColumn( table, rows, i );
+                                       parser = detectParserForColumn( table, rows, j );
                                }
 
                                parsers.push( parser );
        function buildCache( table ) {
                var i, j, $row, cols,
                        totalRows = ( table.tBodies[ 0 ] && table.tBodies[ 0 ].rows.length ) || 0,
-                       totalCells = ( table.tBodies[ 0 ].rows[ 0 ] && table.tBodies[ 0 ].rows[ 0 ].cells.length ) || 0,
                        config = $( table ).data( 'tablesorter' ).config,
                        parsers = config.parsers,
+                       len = parsers.length,
+                       cellIndex,
                        cache = {
                                row: [],
                                normalized: []
                        };
 
-               for ( i = 0; i < totalRows; ++i ) {
+               for ( i = 0; i < totalRows; i++ ) {
 
                        // Add the table data to main data array
                        $row = $( table.tBodies[ 0 ].rows[ i ] );
 
                        cache.row.push( $row );
 
-                       for ( j = 0; j < totalCells; ++j ) {
-                               cols.push( parsers[ j ].format( getElementSortKey( $row[ 0 ].cells[ j ] ), table, $row[ 0 ].cells[ j ] ) );
+                       for ( j = 0; j < len; j++ ) {
+                               cellIndex = $row.data( 'columnToCell' )[ j ];
+                               cols.push( parsers[ j ].format( getElementSortKey( $row[ 0 ].cells[ cellIndex ] ) ) );
                        }
 
                        cols.push( cache.normalized.length ); // add position for rowCache
         * After this, it will look at all rows at the bottom for footer rows
         * And place these in a tfoot using similar rules.
         *
-        * @param {jQuery} $table jQuery object for a <table>
+        * @param {jQuery} $table object for a <table>
         */
        function emulateTHeadAndFoot( $table ) {
                var $thead, $tfoot, i, len,
                        maxSeen = 0,
                        colspanOffset = 0,
                        columns,
-                       i,
+                       k,
                        $cell,
                        rowspan,
                        colspan,
                        headerCount,
                        longestTR,
+                       headerIndex,
                        exploded,
                        $tableHeaders = $( [] ),
                        $tableRows = $( 'thead:eq(0) > tr', table );
 
                // as each header can span over multiple columns (using colspan=N),
                // we have to bidirectionally map headers to their columns and columns to their headers
-               $tableHeaders.each( function ( headerIndex ) {
+               config.columnToHeader = [];
+               config.headerToColumns = [];
+               config.headerList = [];
+               headerIndex = 0;
+               $tableHeaders.each( function () {
                        $cell = $( this );
                        columns = [];
 
-                       for ( i = 0; i < this.colSpan; i++ ) {
-                               config.columnToHeader[ colspanOffset + i ] = headerIndex;
-                               columns.push( colspanOffset + i );
-                       }
-
-                       config.headerToColumns[ headerIndex ] = columns;
-                       colspanOffset += this.colSpan;
-
-                       $cell.data( {
-                               headerIndex: headerIndex,
-                               order: 0,
-                               count: 0
-                       } );
-
-                       if ( $cell.hasClass( config.unsortableClass ) ) {
-                               $cell.data( 'sortDisabled', true );
-                       }
-
-                       if ( !$cell.data( 'sortDisabled' ) ) {
+                       if ( !$cell.hasClass( config.unsortableClass ) ) {
                                $cell
                                        .addClass( config.cssHeader )
                                        .prop( 'tabIndex', 0 )
                                                role: 'columnheader button',
                                                title: msg[ 1 ]
                                        } );
+
+                               for ( k = 0; k < this.colSpan; k++ ) {
+                                       config.columnToHeader[ colspanOffset + k ] = headerIndex;
+                                       columns.push( colspanOffset + k );
+                               }
+
+                               config.headerToColumns[ headerIndex ] = columns;
+
+                               $cell.data( {
+                                       headerIndex: headerIndex,
+                                       order: 0,
+                                       count: 0
+                               } );
+
+                               // add only sortable cells to headerList
+                               config.headerList[ headerIndex ] = this;
+                               headerIndex++;
                        }
 
-                       // add cell to headerList
-                       config.headerList[ headerIndex ] = this;
+                       colspanOffset += this.colSpan;
                } );
 
-               return $tableHeaders;
+               // number of columns with extended colspan, inclusive unsortable
+               // parsers[j], cache[][j], columnToHeader[j], columnToCell[j] have so many elements
+               config.columns = colspanOffset;
 
+               return $tableHeaders.not( '.' + config.unsortableClass );
        }
 
        function isValueInArray( v, a ) {
                }
        }
 
+       /**
+        * Build index to handle colspanned cells in the body.
+        * Set the cell index for each column in an array,
+        * so that colspaned cells set multiple in this array.
+        * columnToCell[collumnIndex] point at the real cell in this row.
+        *
+        * @param {jQuery} $table object for a <table>
+        */
+       function manageColspans( $table ) {
+               var i, j, k, $row,
+                       $rows = $table.find( '> tbody > tr' ),
+                       totalRows = $rows.length || 0,
+                       config = $table.data( 'tablesorter' ).config,
+                       columns = config.columns,
+                       columnToCell, cellsInRow, index;
+
+               for ( i = 0; i < totalRows; i++ ) {
+
+                       $row = $rows.eq( i );
+                       // if this is a child row, continue to the next row (as buildCache())
+                       if ( $row.hasClass( config.cssChildRow ) ) {
+                               // go to the next for loop
+                               continue;
+                       }
+
+                       columnToCell = [];
+                       cellsInRow = ( $row[ 0 ].cells.length ) || 0;  // all cells in this row
+                       index = 0; // real cell index in this row
+                       for ( j = 0; j < columns; index++ ) {
+                               if ( index === cellsInRow ) {
+                                       // Row with cells less than columns: add empty cell
+                                       $row.append( '<td>' );
+                                       cellsInRow++;
+                               }
+                               for ( k = 0; k < $row[ 0 ].cells[ index ].colSpan; k++ ) {
+                                       columnToCell[ j++ ] = index;
+                               }
+                       }
+                       // Store it in $row
+                       $row.data( 'columnToCell', columnToCell );
+               }
+       }
+
        function buildCollationTable() {
                ts.collationTable = mw.config.get( 'tableSorterCollation' );
                ts.collationRegex = null;
                                cssChildRow: 'expand-child',
                                sortMultiSortKey: 'shiftKey',
                                unsortableClass: 'unsortable',
-                               parsers: {},
+                               parsers: [],
                                cancelSelection: true,
                                sortList: [],
                                headerList: [],
                                headerToColumns: [],
-                               columnToHeader: []
+                               columnToHeader: [],
+                               columns: 0
                        },
 
                        dateRegex: [],
                                                }
 
                                                explodeRowspans( $table );
+                                               manageColspans( $table );
 
                                                // Try to auto detect column type, and store in tables config
                                                config.parsers = buildParserCache( table, $headers );
 
                                        // Apply event handling to headers
                                        // this is too big, perhaps break it out?
-                                       $headers.not( '.' + config.unsortableClass ).on( 'keypress click', function ( e ) {
+                                       $headers.on( 'keypress click', function ( e ) {
                                                var cell, $cell, columns, newSortList, i,
                                                        totalRows,
                                                        j, s, o;
                                                cache = buildCache( table );
 
                                                totalRows = ( $table[ 0 ].tBodies[ 0 ] && $table[ 0 ].tBodies[ 0 ].rows.length ) || 0;
-                                               if ( !table.sortDisabled && totalRows > 0 ) {
+                                               if ( totalRows > 0 ) {
                                                        cell = this;
                                                        $cell = $( cell );
 
                                                                        // Reverse the sorting direction for all tables.
                                                                        for ( j = 0; j < config.sortList.length; j++ ) {
                                                                                s = config.sortList[ j ];
-                                                                               o = config.headerList[ s[ 0 ] ];
+                                                                               o = config.headerList[ config.columnToHeader[ s[ 0 ] ] ];
                                                                                if ( isValueInArray( s[ 0 ], newSortList ) ) {
                                                                                        $( o ).data( 'count', s[ 1 ] + 1 );
                                                                                        s[ 1 ] = $( o ).data( 'count' ) % 2;
 
                                        // sort initially
                                        if ( config.sortList.length > 0 ) {
-                                               setupForFirstSort();
                                                config.sortList = convertSortList( config.sortList );
                                                $table.data( 'tablesorter' ).sort();
                                        }
                                buildCollationTable();
 
                                return getParserById( id );
+                       },
+
+                       getParsers: function () {  // for table diagnosis
+                               return parsers;
                        }
                };
 
index 759322a..d4d0ce1 100644 (file)
@@ -1,5 +1,19 @@
 ( function ( $, mw ) {
-       var header,
+       var header = [ 'Planet', 'Radius (km)' ],
+
+               // Data set "planets"
+               mercury = [ 'Mercury', '2439.7' ],
+               venus = [ 'Venus', '6051.8' ],
+               earth = [ 'Earth', '6371.0' ],
+               mars = [ 'Mars', '3390.0' ],
+               jupiter = [ 'Jupiter', '69911' ],
+               saturn = [ 'Saturn', '58232' ],
+               planets = [ mercury, venus, earth, mars, jupiter, saturn ],
+               planetsAscName = [ earth, jupiter, mars, mercury, saturn, venus ],
+               planetsAscRadius = [ mercury, mars, venus, earth, saturn, jupiter ],
+               planetsRowspan,
+               planetsRowspanII,
+               planetsAscNameLegacy,
 
                // Data set "simple"
                a1 = [ 'A', '1' ],
@@ -13,6 +27,7 @@
                simpleDescasc = [ b1, b2, b3, a1, a2, a3 ],
 
                // Data set "colspan"
+               header4 = [ 'column1a', 'column1b', 'column1c', 'column2' ],
                aaa1 = [ 'A', 'A', 'A', '1' ],
                aab5 = [ 'A', 'A', 'B', '5' ],
                abc3 = [ 'A', 'B', 'C', '3' ],
                caa4 = [ 'C', 'A', 'A', '4' ],
                colspanInitial = [ aab5, aaa1, abc3, bbc2, caa4 ],
 
-               // Data set "planets"
-               mercury = [ 'Mercury', '2439.7' ],
-               venus = [ 'Venus', '6051.8' ],
-               earth = [ 'Earth', '6371.0' ],
-               mars = [ 'Mars', '3390.0' ],
-               jupiter = [ 'Jupiter', '69911' ],
-               saturn = [ 'Saturn', '58232' ],
-               planets = [ mercury, venus, earth, mars, jupiter, saturn ],
-               planetsAscName = [ earth, jupiter, mars, mercury, saturn, venus ],
-               planetsAscRadius = [ mercury, mars, venus, earth, saturn, jupiter ],
-               planetsRowspan,
-               planetsRowspanII,
-               planetsAscNameLegacy,
-
                // Data set "ipv4"
                ipv4 = [
                        // Some randomly generated fake IPs
        }
 
        // Sample data set using planets named and their radius
-       header = [ 'Planet', 'Radius (km)' ];
 
        tableTest(
                'Basic planet table: sorting initially - ascending by name',
                        $table.find( '.headerSort:eq(1)' ).click().click();
                }
        );
-
-       header = [ 'column1', 'column2' ];
-
        tableTest(
                'Sorting multiple columns by passing sort list',
                header,
        } );
 
        // Sorting with colspans
-       header = [ 'column1a', 'column1b', 'column1c', 'column2' ];
 
        tableTest( 'Sorting with colspanned headers: spanned column',
-               header,
+               header4,
                colspanInitial,
                [ aaa1, aab5, abc3, bbc2, caa4 ],
                function ( $table ) {
                }
        );
        tableTest( 'Sorting with colspanned headers: sort spanned column twice',
-               header,
+               header4,
                colspanInitial,
                [ caa4, bbc2, abc3, aab5, aaa1 ],
                function ( $table ) {
                }
        );
        tableTest( 'Sorting with colspanned headers: subsequent column',
-               header,
+               header4,
                colspanInitial,
                [ aaa1, bbc2, abc3, caa4, aab5 ],
                function ( $table ) {
                }
        );
        tableTest( 'Sorting with colspanned headers: sort subsequent column twice',
-               header,
+               header4,
                colspanInitial,
                [ aab5, caa4, abc3, bbc2, aaa1 ],
                function ( $table ) {
                }
        );
 
-       tableTest(
-               'Basic planet table: one unsortable column',
-               header,
-               planets,
-               planets,
-               function ( $table ) {
-                       $table.find( 'tr:eq(0) > th:eq(0)' ).addClass( 'unsortable' );
+       QUnit.test( 'Basic planet table: one unsortable column', 3, function ( assert ) {
+               var $table = tableCreate( header, planets ),
+                       $cell;
+               $table.find( 'tr:eq(0) > th:eq(0)' ).addClass( 'unsortable' );
 
-                       $table.tablesorter();
-                       $table.find( 'tr:eq(0) > th:eq(0)' ).click();
-               }
-       );
+               $table.tablesorter();
+               $table.find( 'tr:eq(0) > th:eq(0)' ).click();
+
+               assert.deepEqual(
+                       tableExtract( $table ),
+                       planets,
+                       'table not sorted'
+               );
+
+               $cell = $table.find( 'tr:eq(0) > th:eq(0)' );
+               $table.find( 'tr:eq(0) > th:eq(1)' ).click();
+
+               assert.equal(
+                       $cell.hasClass( 'headerSortUp' ) || $cell.hasClass( 'headerSortDown' ),
+                       false,
+                       'after sort: no class headerSortUp or headerSortDown'
+               );
+
+               assert.equal(
+                       $cell.attr( 'title' ),
+                       undefined,
+                       'after sort: no title tag added'
+               );
+
+       } );
 
        // Regression tests!
        tableTest(
        tableTestHTML(
                'Rowspan exploding with colspanned cells (2)',
                '<table class="sortable">' +
-                       '<thead><tr><th id="sortme">n</th><th>foo</th><th>bar</th><th>baz</th><th>quux</th></tr></thead>' +
+                       '<thead><tr><th>n</th><th>foo</th><th>bar</th><th>baz</th><th id="sortme">n2</th></tr></thead>' +
                        '<tbody>' +
-                       '<tr><td>1</td><td>foo</td><td>bar</td><td rowspan="2">baz</td><td>quux</td></tr>' +
-                       '<tr><td>2</td><td colspan="2">foobar</td><td>quux</td></tr>' +
+                       '<tr><td>1</td><td>foo</td><td>bar</td><td rowspan="2">baz</td><td>2</td></tr>' +
+                       '<tr><td>2</td><td colspan="2">foobar</td><td>1</td></tr>' +
                        '</tbody></table>',
                [
-                       [ '1', 'foo', 'bar', 'baz', 'quux' ],
-                       [ '2', 'foobar', 'baz', 'quux' ]
+                       [ '2', 'foobar', 'baz', '1' ],
+                       [ '1', 'foo', 'bar', 'baz', '2' ]
                ]
        );
 
                ]
        );
 
+       QUnit.test( 'bug 105731 - incomplete rows in table body', 3, function ( assert ) {
+               var $table, parsers;
+               $table = $(
+                       '<table class="sortable">' +
+                               '<tr><th>A</th><th>B</th></tr>' +
+                               '<tr><td>3</td></tr>' +
+                               '<tr><td>1</td><td>2</td></tr>' +
+                               '</table>'
+               );
+               $table.tablesorter();
+               $table.find( '.headerSort:eq(0)' ).click();
+               // now the first row have 2 columns
+               $table.find( '.headerSort:eq(1)' ).click();
+
+               parsers = $table.data( 'tablesorter' ).config.parsers;
+
+               assert.equal(
+                       parsers.length,
+                       2,
+                       'detectParserForColumn() detect 2 parsers'
+               );
+
+               assert.equal(
+                       parsers[ 1 ].id,
+                       'number',
+                       'detectParserForColumn() detect parser.id "number" for second column'
+               );
+
+               assert.equal(
+                       parsers[ 1 ].format( $table.find( 'tbody > tr > td:eq(1)' ).text() ),
+                       0,
+                       'empty cell is sorted as number 0'
+               );
+
+       } );
 }( jQuery, mediaWiki ) );