|
| 1 | +/* SPDX-License-Identifier: Apache-2.0 */ |
| 2 | + |
| 3 | +/* global expect, describe, it, beforeEach, jest */ |
| 4 | + |
| 5 | +/** |
| 6 | + * Tests for the declarative admin tables, asserting on runtime behavior rather |
| 7 | + * than on the markup that configures it. |
| 8 | + * |
| 9 | + * A `tabulator-*` attribute can be spelled correctly and still have no effect: |
| 10 | + * Tabulator parses those inside HtmlTableImport, which initializes after the |
| 11 | + * core modules and after GroupRows has decided whether to subscribe, so any |
| 12 | + * table option consulted during a module's `initialize()` never sees one. |
| 13 | + * Asserting that an attribute is present therefore proves nothing about |
| 14 | + * whether the table does what it says. |
| 15 | + */ |
| 16 | + |
| 17 | +async function render(html) { |
| 18 | + document.body.innerHTML = html; |
| 19 | + let table; |
| 20 | + await jest.isolateModulesAsync(async () => { |
| 21 | + const { TabulatorFull } = await import("tabulator-tables"); |
| 22 | + await import("../../warehouse/admin/static/js/tabulator"); |
| 23 | + const mounted = document.querySelector(".tabulator") || document.querySelector("table"); |
| 24 | + table = TabulatorFull.findTable(mounted)[0]; |
| 25 | + }); |
| 26 | + await new Promise((resolve) => { |
| 27 | + table.on("tableBuilt", resolve); |
| 28 | + if (table.initialized) { |
| 29 | + resolve(); |
| 30 | + } |
| 31 | + }); |
| 32 | + return table; |
| 33 | +} |
| 34 | + |
| 35 | +const GROUPED = ` |
| 36 | + <table data-tabulator |
| 37 | + tabulator-layout="fitDataFill" |
| 38 | + tabulator-groupBy="project_name" |
| 39 | + tabulator-pagination="true" |
| 40 | + tabulator-paginationSize="25" |
| 41 | + tabulator-paginationSizeSelector="25,50,100"> |
| 42 | + <thead><tr> |
| 43 | + <th tabulator-visible="false">Project Name</th><th>Summary</th> |
| 44 | + </tr></thead> |
| 45 | + <tbody> |
| 46 | + <tr><td><a href="/admin/projects/evil-pkg/">evil-pkg</a></td><td>one</td></tr> |
| 47 | + <tr><td><a href="/admin/projects/evil-pkg/">evil-pkg</a></td><td>two</td></tr> |
| 48 | + <tr><td><a href="/admin/projects/gone-pkg/">gone-pkg</a></td><td>three</td></tr> |
| 49 | + </tbody> |
| 50 | + </table>`; |
| 51 | + |
| 52 | +describe("declarative admin tables", () => { |
| 53 | + beforeEach(() => { |
| 54 | + document.body.innerHTML = ""; |
| 55 | + }); |
| 56 | + |
| 57 | + it("groups rows by a hidden column", async () => { |
| 58 | + const table = await render(GROUPED); |
| 59 | + // Grouping is the only thing rendering the project name on the malware |
| 60 | + // reports page, since its column is hidden. |
| 61 | + expect(table.getGroups()).toHaveLength(2); |
| 62 | + expect(table.getColumns()[0].isVisible()).toBe(false); |
| 63 | + }); |
| 64 | + |
| 65 | + it("applies the layout mode named in the attribute", async () => { |
| 66 | + const table = await render(GROUPED); |
| 67 | + expect(table.modules.layout.mode).toBe("fitDataFill"); |
| 68 | + }); |
| 69 | + |
| 70 | + it("gives list and number options their real types", async () => { |
| 71 | + const table = await render(GROUPED); |
| 72 | + expect(table.options.paginationSize).toBe(25); |
| 73 | + expect(table.options.paginationSizeSelector).toEqual([25, 50, 100]); |
| 74 | + }); |
| 75 | + |
| 76 | + it("sorts on the text of a cell rather than its markup", async () => { |
| 77 | + // Cell values are markup, so a naive sort compares hrefs. |
| 78 | + const table = await render(` |
| 79 | + <table data-tabulator><thead><tr><th>Name</th></tr></thead> |
| 80 | + <tbody> |
| 81 | + <tr><td><a href="/admin/sponsors/ffffffff/">Aardvark</a></td></tr> |
| 82 | + <tr><td><a href="/admin/sponsors/00000000/">Zebra</a></td></tr> |
| 83 | + </tbody></table>`); |
| 84 | + table.setSort("name", "asc"); |
| 85 | + const names = table |
| 86 | + .getData("active") |
| 87 | + .map((row) => row.name.replace(/<[^>]*>/g, "").trim()); |
| 88 | + // Sorting the markup would lead with Zebra, whose href sorts first. |
| 89 | + expect(names).toEqual(["Aardvark", "Zebra"]); |
| 90 | + }); |
| 91 | + |
| 92 | + it.each([ |
| 93 | + ["asc", ["Gold", "Silver"]], |
| 94 | + ["desc", ["Silver", "Gold"]], |
| 95 | + ])("sorts blanks last going %s", async (dir, filled) => { |
| 96 | + // A column of optional values keeps its filled-in rows together. |
| 97 | + const table = await render(` |
| 98 | + <table data-tabulator><thead><tr><th>Level</th></tr></thead> |
| 99 | + <tbody> |
| 100 | + <tr><td>Gold</td></tr><tr><td></td></tr><tr><td>Silver</td></tr> |
| 101 | + </tbody></table>`); |
| 102 | + table.setSort("level", dir); |
| 103 | + expect(table.getData("active").map((row) => row.level)).toEqual([...filled, ""]); |
| 104 | + }); |
| 105 | + |
| 106 | + it("filters a column on its text, and skips columns opted out", async () => { |
| 107 | + const table = await render(` |
| 108 | + <table data-tabulator> |
| 109 | + <thead><tr> |
| 110 | + <th>Name</th><th tabulator-headerFilter="false">Active?</th> |
| 111 | + </tr></thead> |
| 112 | + <tbody> |
| 113 | + <tr><td><a href="/admin/sponsors/beeeee/">Aardvark</a></td> |
| 114 | + <td><i class="fa fa-check"></i></td></tr> |
| 115 | + <tr><td><a href="/admin/sponsors/aaaaaa/">Zebra</a></td> |
| 116 | + <td><i class="fa fa-times"></i></td></tr> |
| 117 | + </tbody></table>`); |
| 118 | + |
| 119 | + const [name, active] = table.getColumns(); |
| 120 | + expect(name.getDefinition().headerFilter).toBe("input"); |
| 121 | + // An icon column has no text to match, so a filter box there could only |
| 122 | + // ever empty the table. |
| 123 | + expect(active.getDefinition().headerFilter).toBe(false); |
| 124 | + |
| 125 | + // "beeeee" appears only inside the href, so a filter reading the markup |
| 126 | + // would answer with Aardvark for a string nobody can see on the page. |
| 127 | + table.setHeaderFilterValue("name", "beeeee"); |
| 128 | + expect(table.getData("active")).toEqual([]); |
| 129 | + |
| 130 | + table.setHeaderFilterValue("name", "aardvark"); |
| 131 | + const shown = table |
| 132 | + .getData("active") |
| 133 | + .map((row) => row.name.replace(/<[^>]*>/g, "").trim()); |
| 134 | + expect(shown).toEqual(["Aardvark"]); |
| 135 | + }); |
| 136 | + |
| 137 | + it("toggles a column once per click of its menu entry", async () => { |
| 138 | + const table = await render(` |
| 139 | + <table data-tabulator data-tabulator-column-menu> |
| 140 | + <thead><tr><th tabulator-visible="false">IP address</th><th>Event</th></tr></thead> |
| 141 | + <tbody><tr><td>127.0.0.1</td><td>login</td></tr></tbody></table>`); |
| 142 | + const column = table.getColumns()[0]; |
| 143 | + const [entry] = table.options.columnDefaults.headerMenu.call(table); |
| 144 | + |
| 145 | + // Tabulator listens on the menu item, so a <label> here would forward a |
| 146 | + // second click to its own checkbox and toggle the column straight back. |
| 147 | + const item = document.createElement("div"); |
| 148 | + item.appendChild(entry.label); |
| 149 | + document.body.appendChild(item); |
| 150 | + item.addEventListener("click", (event) => entry.action(event, column)); |
| 151 | + entry.label.dispatchEvent(new MouseEvent("click", { bubbles: true })); |
| 152 | + |
| 153 | + expect(column.isVisible()).toBe(true); |
| 154 | + }); |
| 155 | + it("shows a column the responsive layout folded as still wanted", async () => { |
| 156 | + const table = await render(` |
| 157 | + <table data-tabulator data-tabulator-column-menu |
| 158 | + tabulator-responsiveLayout="collapse"> |
| 159 | + <thead><tr><th>Event</th><th tabulator-responsive="2">User-Agent</th></tr></thead> |
| 160 | + <tbody><tr><td>login</td><td>curl/8.0</td></tr></tbody></table>`); |
| 161 | + const agent = table |
| 162 | + .getColumns() |
| 163 | + .find((column) => column.getDefinition().title === "User-Agent"); |
| 164 | + |
| 165 | + // What a narrow window does: fold the column into the collapsed block. |
| 166 | + table.modules.responsiveLayout.hideColumn(agent); |
| 167 | + expect(agent.isVisible()).toBe(false); |
| 168 | + |
| 169 | + const entry = table.options.columnDefaults.headerMenu |
| 170 | + .call(table) |
| 171 | + .find((item) => item.label.textContent.trim() === "User-Agent"); |
| 172 | + // Reading `isVisible()` here would report a folded column as one the |
| 173 | + // admin had hidden, and clicking it would then show it rather than hide. |
| 174 | + expect(entry.label.querySelector("input").checked).toBe(true); |
| 175 | + }); |
| 176 | + |
| 177 | + it("gives a collapsing table a handle to close the block with", async () => { |
| 178 | + const table = await render(` |
| 179 | + <table data-tabulator tabulator-responsiveLayout="collapse"> |
| 180 | + <thead><tr><th>Event</th></tr></thead> |
| 181 | + <tbody><tr><td>login</td></tr></tbody></table>`); |
| 182 | + |
| 183 | + // ResponsiveLayout wires up a toggle only when it finds this formatter, |
| 184 | + // and no attribute can ask for it, so without one the collapsed block |
| 185 | + // renders permanently open with nothing to close it. |
| 186 | + const [handle] = table.getColumns(); |
| 187 | + expect(handle.getDefinition().formatter).toBe("responsiveCollapse"); |
| 188 | + expect(table.modules.responsiveLayout.collapseHandleColumn).toBeTruthy(); |
| 189 | + // No title, so it stays out of the column visibility menu. |
| 190 | + expect(handle.getDefinition().title).toBeUndefined(); |
| 191 | + }); |
| 192 | + |
| 193 | + it("ignores an attribute naming only an Object prototype member", async () => { |
| 194 | + // HTML lowercases attribute names, so `constructor` and `__proto__` reach |
| 195 | + // the prototype chain of the option table and answer with something that |
| 196 | + // is not an option at all. |
| 197 | + document.body.innerHTML = ` |
| 198 | + <table id="first" data-tabulator tabulator-constructor="boom"> |
| 199 | + <thead><tr><th>Name</th></tr></thead> |
| 200 | + <tbody><tr><td>a</td></tr></tbody></table> |
| 201 | + <table id="second" data-tabulator> |
| 202 | + <thead><tr><th>Name</th></tr></thead> |
| 203 | + <tbody><tr><td>b</td></tr></tbody></table>`; |
| 204 | + await jest.isolateModulesAsync(async () => { |
| 205 | + await import("../../warehouse/admin/static/js/tabulator"); |
| 206 | + }); |
| 207 | + await new Promise((resolve) => setTimeout(resolve)); |
| 208 | + |
| 209 | + // Both tables built: a throw while reading the first one's attributes |
| 210 | + // would have left the second as a plain <table>. |
| 211 | + expect(document.querySelectorAll("div.tabulator")).toHaveLength(2); |
| 212 | + }); |
| 213 | + |
| 214 | + it("exports what a cell shows rather than the markup showing it", async () => { |
| 215 | + const table = await render(` |
| 216 | + <table id="sponsors" data-tabulator data-tabulator-download> |
| 217 | + <thead><tr><th>Name</th></tr></thead> |
| 218 | + <tbody><tr><td><a href="/admin/sponsors/00000000/">Aardvark</a></td></tr> |
| 219 | + </tbody></table>`); |
| 220 | + |
| 221 | + const { accessorDownload, accessorClipboard } = table.options.columnDefaults; |
| 222 | + const cell = "<a href=\"/admin/sponsors/00000000/\">Aardvark</a>"; |
| 223 | + expect(accessorDownload(cell)).toBe("Aardvark"); |
| 224 | + expect(accessorClipboard(cell)).toBe("Aardvark"); |
| 225 | + |
| 226 | + const download = jest.spyOn(table, "download").mockImplementation(() => {}); |
| 227 | + const copy = jest.spyOn(table, "copyToClipboard").mockImplementation(() => {}); |
| 228 | + const [copyButton, csvButton] = document.querySelectorAll( |
| 229 | + "div.btn-group > button", |
| 230 | + ); |
| 231 | + copyButton.click(); |
| 232 | + csvButton.click(); |
| 233 | + |
| 234 | + // Filtered and sorted as they stand, across every page, which is what the |
| 235 | + // DataTables toolbar these replace exported. |
| 236 | + expect(copy).toHaveBeenCalledWith("active"); |
| 237 | + // Without this the Clipboard module never binds the listener that |
| 238 | + // `copyToClipboard` fires against, and the button does nothing at all. |
| 239 | + expect(table.options.clipboard).toBe("copy"); |
| 240 | + expect(download).toHaveBeenCalledWith("csv", "sponsors.csv", {}, "active"); |
| 241 | + }); |
| 242 | + |
| 243 | + it("leaves the export buttons off a table that did not ask for them", async () => { |
| 244 | + await render(` |
| 245 | + <table data-tabulator><thead><tr><th>Name</th></tr></thead> |
| 246 | + <tbody><tr><td>a</td></tr></tbody></table>`); |
| 247 | + expect(document.querySelector("div.btn-group")).toBeNull(); |
| 248 | + }); |
| 249 | +}); |
0 commit comments