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