From c88054be22f73f5fb91a84fbb67908f006f913da Mon Sep 17 00:00:00 2001 From: Even Stensberg Date: Fri, 28 Aug 2026 13:27:59 +0200 Subject: [PATCH] feat: sortable columns Sorting lived in the modules page and was written against app.stats.modules, so it was the only table that could sort, and only by size. It now works on the rendered rows: a page marks a column .sortable-th and hands it a data-sort value wherever a cell does not sort the way it reads, which covers the modules of a chunk, the assets, the chunks and the module list alike. Which way a column opens is declared rather than guessed from its values, or the ids of the chunks page would count down from the last one. Tables that redraw their rows, as the module filter does, put the sort back afterwards. Co-Authored-By: Claude Opus 5 --- README.md | 1 + app/pages/assets/assets.pug | 8 ++-- app/pages/assets/page.js | 2 + app/pages/chunk/chunk.pug | 14 +++--- app/pages/chunk/page.js | 2 + app/pages/chunks/chunks.pug | 12 ++--- app/pages/chunks/page.js | 2 + app/pages/modules/page.js | 13 ++--- app/pages/modules/table.pug | 14 +++--- app/sortableTable.js | 96 +++++++++++++++++++++++++++++++++++++ app/style.css | 15 ++++++ test/sortableTable.test.js | 46 ++++++++++++++++++ 12 files changed, 192 insertions(+), 33 deletions(-) create mode 100644 app/sortableTable.js create mode 100644 test/sortableTable.test.js diff --git a/README.md b/README.md index 4a98727..c12bfd7 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ This project is a lightweight front-end viewer for webpack output generated with - Module dependency graph and chunk relationships - Asset and bundle size breakdowns - Filter the module list and graph by name or regexp, or hide `node_modules` +- Sort any table of modules, chunks or assets by size, name or id - Warning and error inspection - Hints for common optimization issues, including circular dependencies - Upload a generated stats file directly in the app diff --git a/app/pages/assets/assets.pug b/app/pages/assets/assets.pug index bd9565f..5171ef9 100644 --- a/app/pages/assets/assets.pug +++ b/app/pages/assets/assets.pug @@ -1,16 +1,16 @@ table.table.table-condensed thead tr - th assets - th size + th.sortable-th assets + th.sortable-th(data-sort-first="desc") size th chunks th names th flags tbody each asset in stats.assets tr - td: pre: code= asset.name - td= require("../../formatSize")(asset.size) + td(data-sort=asset.name): pre: code= asset.name + td(data-sort=asset.size)= require("../../formatSize")(asset.size) td each chunk in asset.chunks a.btn.btn-info(href=`#chunk/${encodeURIComponent(chunk)}`)= chunk diff --git a/app/pages/assets/page.js b/app/pages/assets/page.js index e5b4e68..a75f325 100644 --- a/app/pages/assets/page.js +++ b/app/pages/assets/page.js @@ -1,7 +1,9 @@ var app = require("../../app"); +var sortableTable = require("../../sortableTable"); module.exports = function() { document.title = "assets"; + sortableTable.enable(); $(".page").html( require("./assets.pug")({ stats: app.stats diff --git a/app/pages/chunk/chunk.pug b/app/pages/chunk/chunk.pug index a73bc29..66be30d 100644 --- a/app/pages/chunk/chunk.pug +++ b/app/pages/chunk/chunk.pug @@ -70,24 +70,24 @@ if chunk.modules && chunk.modules.length > 0 .row: .col-md-12: .well h4 modules - table.table.table-condensed + table.table.table-condensed(data-sort-key="chunk-modules") thead tr - th id - th name - th size + th.sortable-th id + th.sortable-th name + th.sortable-th(data-sort-first="desc") size th chunks th flags tbody each module in chunk.modules tr - td + td(data-sort=module.id) if typeof module.uid === "number" a.btn.btn-success(href=`#module/${module.uid}`)= module.id else span.btn.btn-success= module.id - td: pre: code= module.name.split("!").join("\n") - td= require("../../formatSize")(module.size) + td(data-sort=module.name): pre: code= module.name.split("!").join("\n") + td(data-sort=module.size)= require("../../formatSize")(module.size) td each chunk in module.chunks a.btn.btn-info(href=`#chunk/${encodeURIComponent(chunk)}`)= chunk diff --git a/app/pages/chunk/page.js b/app/pages/chunk/page.js index 2ef5b3c..106cd45 100644 --- a/app/pages/chunk/page.js +++ b/app/pages/chunk/page.js @@ -1,9 +1,11 @@ var app = require("../../app"); var modulesGraph = require("../../graphs/modules"); +var sortableTable = require("../../sortableTable"); module.exports = function (id) { id = isNaN(parseInt(id, 10)) ? decodeURIComponent(id) : parseInt(id, 10); document.title = "chunk " + id; + sortableTable.enable(); $(".page").html( require("./chunk.pug")({ stats: app.stats, diff --git a/app/pages/chunks/chunks.pug b/app/pages/chunks/chunks.pug index d1c0702..efcb5d6 100644 --- a/app/pages/chunks/chunks.pug +++ b/app/pages/chunks/chunks.pug @@ -1,22 +1,22 @@ table.table.table-condensed thead tr - th id + th.sortable-th id th names - th modules - th size + th.sortable-th(data-sort-first="desc") modules + th.sortable-th(data-sort-first="desc") size th parents th flags tbody each chunk in stats.chunks tr - td: a.btn.btn-info(href=`#chunk/${encodeURIComponent(chunk.id)}`)= chunk.id + td(data-sort=chunk.id): a.btn.btn-info(href=`#chunk/${encodeURIComponent(chunk.id)}`)= chunk.id td each name in chunk.names code= name = " " - td= chunk.modules ? chunk.modules.length : "N/A" - td= require("../../formatSize")(chunk.size) + td(data-sort=chunk.modules ? chunk.modules.length : "")= chunk.modules ? chunk.modules.length : "N/A" + td(data-sort=chunk.size)= require("../../formatSize")(chunk.size) td each parent in chunk.parents a.btn.btn-info(href=`#chunk/${encodeURIComponent(parent)}`)= parent diff --git a/app/pages/chunks/page.js b/app/pages/chunks/page.js index c55f07b..af31563 100644 --- a/app/pages/chunks/page.js +++ b/app/pages/chunks/page.js @@ -1,8 +1,10 @@ var app = require("../../app"); var chunksGraph = require("../../graphs/chunks"); +var sortableTable = require("../../sortableTable"); module.exports = function() { document.title = "chunks"; + sortableTable.enable(); $(".page").html( require("./chunks.pug")({ stats: app.stats diff --git a/app/pages/modules/page.js b/app/pages/modules/page.js index fdc7f8e..2f3bd1b 100644 --- a/app/pages/modules/page.js +++ b/app/pages/modules/page.js @@ -2,6 +2,7 @@ var app = require("../../app"); var modulesGraph = require("../../graphs/modules"); var moduleFilter = require("../../moduleFilter"); var formatSize = require("../../formatSize"); +var sortableTable = require("../../sortableTable"); function renderTable() { $(".modules-table").html( @@ -9,6 +10,8 @@ function renderTable() { modules: app.stats.modules.filter(moduleFilter.isVisible) }) ); + // Filtering redraws the rows, which would otherwise drop the sort. + sortableTable.restore($(".modules-table table")[0]); } function renderSummary() { @@ -34,6 +37,7 @@ function renderSummary() { module.exports = function() { document.title = "modules"; + sortableTable.enable(); $(".page").html( require("./modules.pug")({ query: moduleFilter.query, @@ -43,14 +47,6 @@ module.exports = function() { renderTable(); renderSummary(); - var sortDir; - $(document).on("click", ".size-th", function() { - sortDir = sortDir === "desc" ? "asc" : "desc"; - app.stats.modules.sort(function(a, b) { - return sortDir === "asc" ? b.size - a.size : a.size - b.size; - }); - renderTable(); - }); // The graph follows the same filter through moduleFilter, so only the table // and the summary are redrawn here. $(document).on("input", ".module-filter-query", function() { @@ -67,7 +63,6 @@ module.exports = function() { modulesGraph.show(); modulesGraph.setNormal(); return function() { - $(document).off("click", ".size-th"); $(document).off("input", ".module-filter-query"); $(document).off("change", ".module-filter-third-party"); modulesGraph.hide(); diff --git a/app/pages/modules/table.pug b/app/pages/modules/table.pug index ee09cc2..f5ffced 100644 --- a/app/pages/modules/table.pug +++ b/app/pages/modules/table.pug @@ -1,21 +1,21 @@ -table.table.table-condensed +table.table.table-condensed(data-sort-key="modules") thead tr - th id - th name - th.sortable-th.size-th size + th.sortable-th id + th.sortable-th name + th.sortable-th(data-sort-first="desc") size th chunks th flags tbody each module in modules tr - td + td(data-sort=module.id) if typeof module.uid === "number" a.btn.btn-success(href=`#module/${module.uid}`)= module.id else span.btn.btn-success= module.id - td: pre: code= module.name.split("!").join("\n") - td= require("../../formatSize")(module.size) + td(data-sort=module.name): pre: code= module.name.split("!").join("\n") + td(data-sort=module.size)= require("../../formatSize")(module.size) td each chunk in module.chunks a.btn.btn-info(href=`#chunk/${encodeURIComponent(chunk)}`)= chunk diff --git a/app/sortableTable.js b/app/sortableTable.js new file mode 100644 index 0000000..d0f9f80 --- /dev/null +++ b/app/sortableTable.js @@ -0,0 +1,96 @@ +// Click-to-sort for the tables of any page (webpack/analyse#28). +// +// Sorting used to live in the modules page and was written against +// app.stats.modules, which is why that was the only table that could sort, and +// only by size. This sorts the rendered rows instead, so a page only has to +// mark a column with .sortable-th, whatever shape the data behind it has. +// Cells that do not sort the way they read - a size printed as "4 KiB", an id +// that is a number inside a link - carry what to sort on in data-sort. + +// Remembered per table for the pages that redraw their rows, keyed by the +// data-sort-key of the table. +var lastSort = {}; + +// Called by every page that has a sortable table. The handler is delegated +// from the document and installed once, so it survives the pages redrawing +// their tables and there is nothing to tear down. +var enabled = false; + +exports.enable = function enable() { + if (enabled) return; + enabled = true; + $(document).on("click", "th.sortable-th", function() { + var column = $(this).index(); + var table = $(this).closest("table")[0]; + if (!table) return; + sort(table, column, nextDirection(this)); + }); +}; + +// A column is wanted a particular way round the first time it is clicked: +// sizes and counts biggest first, ids and names from the start. The column +// says which through data-sort-first, since guessing it from the values would +// turn an id into a countdown. +function nextDirection(th) { + if ($(th).hasClass("sorted-desc")) return "asc"; + if ($(th).hasClass("sorted-asc")) return "desc"; + return th.getAttribute("data-sort-first") === "desc" ? "desc" : "asc"; +} + +function sort(table, column, direction) { + var $table = $(table); + var body = $table.find("tbody")[0] || table; + var factor = direction === "asc" ? 1 : -1; + // Array.prototype.sort is stable, so rows that compare equal stay in the + // order the page rendered them in. + var rows = $table.find("tbody > tr").get(); + rows.sort(function(a, b) { + return factor * compare(valueOf(a, column), valueOf(b, column)); + }); + rows.forEach(function(row) { + body.appendChild(row); + }); + $table.find("th").removeClass("sorted-asc sorted-desc"); + $table + .find("th") + .eq(column) + .addClass("sorted-" + direction); + var key = table.getAttribute("data-sort-key"); + if (key) lastSort[key] = { column: column, direction: direction }; +} + +// Puts a redrawn table back in the order it was sorted in. Without it, typing +// in the module filter would silently undo the sort. +exports.restore = function restore(table) { + if (!table) return; + var key = table.getAttribute("data-sort-key"); + var last = key && lastSort[key]; + if (last) sort(table, last.column, last.direction); +}; + +function valueOf(row, column) { + var cell = row.cells[column]; + if (!cell) return ""; + var raw = cell.getAttribute("data-sort"); + return exports.valueFrom(raw === null ? cell.textContent : raw); +} + +// What a cell sorts as: a number when it reads as one, its text otherwise. +exports.valueFrom = function valueFrom(text) { + text = (text || "").trim(); + if (text === "") return ""; + var number = Number(text); + return isNaN(number) ? text.toLowerCase() : number; +}; + +exports.compare = compare; + +function compare(a, b) { + var aIsNumber = typeof a === "number"; + var bIsNumber = typeof b === "number"; + if (aIsNumber && bIsNumber) return a - b; + // Numbers before text, so a cell that holds neither does not land in the + // middle of a column of numbers. + if (aIsNumber !== bIsNumber) return aIsNumber ? -1 : 1; + return a < b ? -1 : a > b ? 1 : 0; +} diff --git a/app/style.css b/app/style.css index 08e0970..0401b8a 100644 --- a/app/style.css +++ b/app/style.css @@ -21,8 +21,23 @@ table pre { margin-bottom: 0px; } +/* A sortable column says so: a faint pair of arrows until it is sorted on, + then the direction it is sorted in. */ .sortable-th { cursor: pointer; + white-space: nowrap; +} +.sortable-th:after { + content: " \2195"; + opacity: 0.3; +} +.sortable-th.sorted-asc:after { + content: " \2191"; + opacity: 1; +} +.sortable-th.sorted-desc:after { + content: " \2193"; + opacity: 1; } /* Legend under the module and chunk graphs (webpack/analyse#29). Sits between diff --git a/test/sortableTable.test.js b/test/sortableTable.test.js new file mode 100644 index 0000000..337a9eb --- /dev/null +++ b/test/sortableTable.test.js @@ -0,0 +1,46 @@ +// Checks the ordering rules behind the sortable table columns. The clicking +// and the row shuffling need a browser; what a cell is worth and how two of +// them compare does not, and that is where the surprises live. +var test = require("node:test"); +var assert = require("node:assert"); + +var sortableTable = require("../app/sortableTable"); +var valueFrom = sortableTable.valueFrom; +var compare = sortableTable.compare; + +test("reads a cell as a number when it is one", function() { + assert.strictEqual(valueFrom("1846"), 1846); + assert.strictEqual(valueFrom(" 12 "), 12); + assert.strictEqual(valueFrom("0"), 0); +}); + +test("reads anything else as text, case aside", function() { + assert.strictEqual(valueFrom("./SRC/App.js"), "./src/app.js"); + // The size column is printed, not measured, here: it sorts on the + // data-sort number the template writes, never on "4 KiB". + assert.strictEqual(valueFrom("4 KiB"), "4 kib"); + assert.strictEqual(valueFrom(""), ""); + assert.strictEqual(valueFrom(null), ""); +}); + +test("orders numbers by size, not by their digits", function() { + var sizes = [41, 1846, 205, 28672].sort(compare); + assert.deepStrictEqual(sizes, [41, 205, 1846, 28672]); +}); + +test("orders text alphabetically", function() { + var names = ["./b.js", "./a.js", "./c.js"].sort(compare); + assert.deepStrictEqual(names, ["./a.js", "./b.js", "./c.js"]); +}); + +test("keeps cells that hold neither out of the numbers", function() { + // A chunk with no module list prints "N/A" and sorts with an empty value; + // it belongs at one end of the column rather than in the middle of it. + var mixed = [12, "", 3, "n/a"].sort(compare); + assert.deepStrictEqual(mixed, [3, 12, "", "n/a"]); +}); + +test("compares equal values as equal, so a sort stays stable", function() { + assert.strictEqual(compare(7, 7), 0); + assert.strictEqual(compare("a", "a"), 0); +});