diff --git a/package-lock.json b/package-lock.json index 5c0c7fc3..7d97b7b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,9 @@ "tests/end-to-end" ], "devDependencies": { - "@biomejs/biome": "^2.5.4" + "@biomejs/biome": "^2.5.4", + "@types/node": "^26.2.0", + "typescript": "^7.0.2" } }, "node_modules/@biomejs/biome": { @@ -191,13 +193,353 @@ } }, "node_modules/@types/node": { - "version": "24.13.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", - "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "version": "26.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", + "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.18.0" + "undici-types": "~8.3.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" } }, "node_modules/end-to-end": { @@ -219,10 +561,45 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", "dev": true, "license": "MIT" }, @@ -230,8 +607,7 @@ "version": "1.0.0", "license": "ISC", "devDependencies": { - "@playwright/test": "^1.61.1", - "@types/node": "^24.13.3" + "@playwright/test": "^1.61.1" } }, "tests/end-to-end/node_modules/@playwright/test": { diff --git a/package.json b/package.json index 322c0864..79f9d613 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,8 @@ "name": "sqlpage", "version": "1.0.0", "scripts": { - "test": "biome check . && node --test \"tests/js/**/*.spec.ts\"", + "test": "biome check . && npm run typecheck && node --test \"tests/js/**/*.spec.ts\"", + "typecheck": "tsc && tsc -p tests/js && tsc -p tests/end-to-end", "format": "biome format --write .", "fix": "biome check --fix --unsafe ." }, @@ -12,7 +13,9 @@ }, "license": "MIT", "devDependencies": { - "@biomejs/biome": "^2.5.4" + "@biomejs/biome": "^2.5.4", + "@types/node": "^26.2.0", + "typescript": "^7.0.2" }, "workspaces": [ "tests/end-to-end" diff --git a/sqlpage/apexcharts.js b/sqlpage/apexcharts.js index cd1d1d58..98fa1ce3 100644 --- a/sqlpage/apexcharts.js +++ b/sqlpage/apexcharts.js @@ -2,7 +2,9 @@ sqlpage_chart = (() => { function sqlpage_chart() { - for (const c of document.querySelectorAll("[data-pre-init=chart]")) { + /** @type {NodeListOf} */ + const charts = document.querySelectorAll("[data-pre-init=chart]"); + for (const c of charts) { try { build_sqlpage_chart(c); } catch (e) { @@ -69,7 +71,7 @@ sqlpage_chart = (() => { const with_lowest_x = unread .filter((xs) => xs.length > 0) .reduce((a, b) => (b[0] < a[0] ? b : a)); - const x = with_lowest_x.shift(); + const x = /** @type {XValue} */ (with_lowest_x.shift()); merged.set(x_key(x), x); } return [...merged.values()]; @@ -120,7 +122,9 @@ sqlpage_chart = (() => { function build_sqlpage_chart(c) { const [data_element] = c.getElementsByTagName("data"); const data = JSON.parse(data_element.textContent); - const chartContainer = c.querySelector(".chart"); + const chartContainer = /** @type {HTMLElement} */ ( + c.querySelector(".chart") + ); chartContainer.innerHTML = ""; const is_timeseries = !!data.time; const chart_type = diff --git a/sqlpage/globals.d.ts b/sqlpage/globals.d.ts new file mode 100644 index 00000000..25eba3ca --- /dev/null +++ b/sqlpage/globals.d.ts @@ -0,0 +1,38 @@ +// Names the browser bundle relies on at runtime rather than through an import: +// libraries build.rs inlines ahead of our own code, libraries loaded on demand, +// and the objects SQLPage's scripts hang off the window for each other. + +/** + * A library this project ships no type definitions for. Saying `unknown` + * instead would only move the guesswork to a cast at every call site. + */ +// biome-ignore lint/suspicious/noExplicitAny: that is what an untyped library is +type Untyped = any; + +/** Leaflet, loaded from a CDN by sqlpage_map when a page holds a map. */ +declare const L: Untyped; + +/** ApexCharts, inlined ahead of apexcharts.js by build.rs. */ +declare const ApexCharts: Untyped; + +/** Tom Select, inlined ahead of tomselect.js by build.rs. */ +declare const TomSelect: Untyped; + +/** apexcharts.js publishes its initialiser under this name. */ +declare var sqlpage_chart: () => void; + +/** + * Tabler's bundled Bootstrap, inlined ahead of sqlpage.js by build.rs. Its + * widgets are untyped: naming a few of them here would only claim more than + * this file knows. + */ +interface TablerBootstrap { + [widget: string]: Untyped; +} + +interface Window { + /** Every chart rendered on the page, in the order they were built. */ + charts?: unknown[]; + tabler: { bootstrap: TablerBootstrap }; + bootstrap?: TablerBootstrap; +} diff --git a/sqlpage/sqlpage.js b/sqlpage/sqlpage.js index 6f2b0d43..e32667b8 100644 --- a/sqlpage/sqlpage.js +++ b/sqlpage/sqlpage.js @@ -1,20 +1,21 @@ /* !include https://cdn.jsdelivr.net/npm/@tabler/core@1.4.0/dist/js/tabler.min.js */ -const nonce = document.currentScript.nonce; +const nonce = /** @type {HTMLScriptElement} */ (document.currentScript).nonce; function sqlpage_card() { - for (const c of document.querySelectorAll("[data-pre-init=card]")) { + /** @type {NodeListOf} */ + const cards = document.querySelectorAll("[data-pre-init=card]"); + for (const c of cards) { c.removeAttribute("data-pre-init"); + if (!c.dataset.embed) continue; const url = new URL(c.dataset.embed, window.location.href); url.searchParams.set("_sqlpage_embed", "1"); fetch(url) .then((res) => res.text()) .then((html) => { const body = c.querySelector(".card-content"); - body.innerHTML = html; + if (body) body.innerHTML = html; const spinner = c.querySelector(".card-loading-placeholder"); - if (spinner) { - spinner.parentNode.removeChild(spinner); - } + spinner?.remove(); const fragLoadedEvt = new CustomEvent("fragment-loaded", { bubbles: true, }); @@ -28,14 +29,18 @@ function setup_table(root_el) { /** @type {HTMLInputElement | null} */ const search_input = root_el.querySelector("input.search"); const table_el = root_el.querySelector("table"); - const sort_buttons = [...table_el.querySelectorAll("button.sort[data-sort]")]; + if (!table_el) return; + /** @type {NodeListOf} */ + const sort_button_els = table_el.querySelectorAll("button.sort[data-sort]"); + const sort_buttons = [...sort_button_els]; const item_parent = table_el.querySelector("tbody"); const has_sort = sort_buttons.length > 0; if (search_input || has_sort) { const items = table_parse_data(table_el, sort_buttons); if (search_input) setup_table_search_behavior(search_input, items); - if (has_sort) setup_sort_behavior(sort_buttons, items, item_parent); + if (has_sort && item_parent) + setup_sort_behavior(sort_buttons, items, item_parent); } // Change number format AFTER parsing and storing the sort keys @@ -44,7 +49,7 @@ function setup_table(root_el) { /** * @param {HTMLInputElement} search_input - * @param {Array<{el: HTMLElement, sort_keys: Array<{num: number, str: string}>}>} items + * @param {TableRow[]} items */ function setup_table_search_behavior(search_input, items) { function onSearch() { @@ -66,12 +71,15 @@ function setup_table_search_behavior(search_input, items) { /**@param {HTMLElement} table_el */ function apply_number_formatting(table_el) { + /** @type {NodeListOf} */ const header_els = table_el.querySelectorAll("thead > tr > th"); const col_types = [...header_els].map((el) => el.dataset.column_type); const col_rawnums = [...header_els].map((el) => !!el.dataset.raw_number); const col_money = [...header_els].map((el) => !!el.dataset.money); const number_format_locale = table_el.dataset.number_format_locale; - const number_format_digits = table_el.dataset.number_format_digits; + const number_format_digits = table_el.dataset.number_format_digits + ? Number(table_el.dataset.number_format_digits) + : undefined; const currency = table_el.dataset.currency; for (const tr_el of table_el.querySelectorAll("tbody tr, tfoot tr")) { @@ -95,20 +103,25 @@ function apply_number_formatting(table_el) { } } +/** @typedef { {el: HTMLElement, sort_keys: {num: number, str: string}[]} } TableRow */ + /** Prepare the table rows for sorting. * @param {HTMLElement} table_el * @param {HTMLElement[]} sort_buttons + * @returns {TableRow[]} */ function table_parse_data(table_el, sort_buttons) { const is_num = [...sort_buttons].map( - (btn_el) => btn_el.parentElement.dataset.column_type === "number", + (btn_el) => btn_el.parentElement?.dataset.column_type === "number", ); - return [...table_el.querySelectorAll("tbody tr")].map((tr_el) => { + /** @type {NodeListOf} */ + const row_els = table_el.querySelectorAll("tbody tr"); + return [...row_els].map((tr_el) => { const cells = tr_el.getElementsByTagName("td"); return { el: tr_el, sort_keys: sort_buttons.map((_btn_el, idx) => { - const str = cells[idx]?.textContent; + const str = cells[idx]?.textContent ?? ""; const num = is_num[idx] ? Number.parseFloat(str) : Number.NaN; return { num, str }; }), @@ -119,7 +132,7 @@ function table_parse_data(table_el, sort_buttons) { /** * Adds event listeners to the sort buttons to sort the table rows. * @param {HTMLElement[]} sort_buttons - * @param {HTMLElement[]} items + * @param {TableRow[]} items * @param {HTMLElement} item_parent */ function setup_sort_behavior(sort_buttons, items, item_parent) { @@ -147,7 +160,9 @@ function setup_sort_behavior(sort_buttons, items, item_parent) { } function sqlpage_table() { - for (const r of document.querySelectorAll("[data-pre-init=table]")) { + /** @type {NodeListOf} */ + const tables = document.querySelectorAll("[data-pre-init=table]"); + for (const r of tables) { r.removeAttribute("data-pre-init"); setup_table(r); } @@ -198,13 +213,14 @@ function sqlpage_map() { } function onLeafletLoad() { is_leaflet_loaded = true; + /** @type {NodeListOf} */ const maps = document.querySelectorAll("[data-pre-init=map]"); for (const m of maps) { const tile_source = m.dataset.tile_source; - const maxZoom = +m.dataset.max_zoom; + const maxZoom = Number(m.dataset.max_zoom); const attribution = m.dataset.attribution; const map = L.map(m, { attributionControl: !!attribution }); - const zoom = m.dataset.zoom; + const zoom = Number(m.dataset.zoom); const center = parseCoords(m.dataset.center); if (tile_source) L.tileLayer(tile_source, { attribution, maxZoom }).addTo(map); @@ -213,14 +229,14 @@ function sqlpage_map() { setTimeout(addMarker, 0, marker_elem, map); } setTimeout(() => { - if (center) map.setView(center, +zoom); + if (center) map.setView(center, zoom); else { const markerBounds = (m) => m.getLatLng ? m.getLatLng() : m.getBounds(); const bounds = map._sqlpage_markers.map(markerBounds); if (bounds.length > 0) map.fitBounds(bounds); - else map.setView([51.505, 10], +zoom); - if (zoom != null) map.setZoom(+zoom); + else map.setView([51.505, 10], zoom); + if (!Number.isNaN(zoom)) map.setZoom(zoom); } }, 100); m.removeAttribute("data-pre-init"); @@ -282,15 +298,16 @@ function sqlpage_map() { } function sqlpage_form() { + /** @type {NodeListOf} */ const file_inputs = document.querySelectorAll( "input[type=file][data-max-size]", ); for (const input of file_inputs) { - const max_size = +input.dataset.maxSize; - input.addEventListener("change", function () { + const max_size = Number(input.dataset.maxSize); + input.addEventListener("change", () => { input.classList.remove("is-invalid"); input.setCustomValidity(""); - for (const { size } of this.files) { + for (const { size } of input.files ?? []) { if (size > max_size) { input.classList.add("is-invalid"); return input.setCustomValidity( @@ -301,6 +318,7 @@ function sqlpage_form() { }); } + /** @type {NodeListOf} */ const auto_submit_forms = document.querySelectorAll("form[data-auto-submit]"); for (const form of auto_submit_forms) { form.addEventListener("change", () => form.submit()); @@ -314,11 +332,13 @@ function get_tabler_color(name) { } function load_scripts() { + /** @type {NodeListOf} */ const addjs = document.querySelectorAll("[data-sqlpage-js]"); const existing_scripts = new Set( [...document.querySelectorAll("script")].map((s) => s.src), ); for (const el of addjs) { + if (!el.dataset.sqlpageJs) continue; const js = new URL(el.dataset.sqlpageJs, window.location.href).href; if (existing_scripts.has(js)) continue; existing_scripts.add(js); @@ -375,11 +395,16 @@ function sqlpage_toast() { if (!Toast) return; const initialized_toasts = []; - for (const toast of document.querySelectorAll('[data-pre-init="toast"]')) { + /** @type {NodeListOf} */ + const toasts = document.querySelectorAll('[data-pre-init="toast"]'); + for (const toast of toasts) { const source_container = toast.parentElement; + if (!source_container) continue; const position = source_container.dataset.sqlpageToastPosition; - let container = document.querySelector( - `.toast-container[data-sqlpage-toast-position="${position}"]:not([data-pre-init])`, + let container = /** @type {HTMLElement | null} */ ( + document.querySelector( + `.toast-container[data-sqlpage-toast-position="${position}"]:not([data-pre-init])`, + ) ); if (!container) { container = source_container; diff --git a/sqlpage/tomselect.js b/sqlpage/tomselect.js index 1e0f40e1..9460bf3e 100644 --- a/sqlpage/tomselect.js +++ b/sqlpage/tomselect.js @@ -1,9 +1,9 @@ /* !include https://cdn.jsdelivr.net/npm/tom-select@2.6.1/dist/js/tom-select.popular.min.js */ function sqlpage_select_dropdown() { - for (const s of document.querySelectorAll( - "[data-pre-init=select-dropdown]", - )) { + /** @type {NodeListOf} */ + const selects = document.querySelectorAll("[data-pre-init=select-dropdown]"); + for (const s of selects) { try { sqlpage_select_dropdown_individual(s); } catch (e) { diff --git a/tests/end-to-end/globals.d.ts b/tests/end-to-end/globals.d.ts new file mode 100644 index 00000000..47ba8d7f --- /dev/null +++ b/tests/end-to-end/globals.d.ts @@ -0,0 +1,21 @@ +// What the browser tests reach for on the page: widgets that the scripts under +// test attach to elements at runtime, and libraries the page loads itself. + +interface TomSelectInstance { + getValue(): string | string[]; + setTextboxValue(value: string): void; + focus(): void; + options: Record; +} + +interface HTMLElement { + /** Attached by sqlpage_select_dropdown to every select it takes over. */ + tomselect?: TomSelectInstance; +} + +interface Window { + /** Tabler's bundled Bootstrap, inlined ahead of sqlpage.js by build.rs. */ + tabler?: { + bootstrap: { Toast: { getInstance(element: Element): unknown } }; + }; +} diff --git a/tests/end-to-end/official-site.spec.ts b/tests/end-to-end/official-site.spec.ts index c81117c3..7c9325b4 100644 --- a/tests/end-to-end/official-site.spec.ts +++ b/tests/end-to-end/official-site.spec.ts @@ -5,10 +5,8 @@ const BASE = process.env.SQLPAGE_TEST_BASE ?? "http://localhost:8080/"; test("Open documentation", async ({ page }) => { await page.goto(BASE); - // Expect a title "to contain" a substring. await expect(page).toHaveTitle(/SQLPage.*/); - // open the submenu await page.getByText("Documentation", { exact: true }).first().click(); const components = ["form", "map", "chart", "button"]; for (const component of components) { @@ -340,34 +338,46 @@ test("table filtering", async ({ page }) => { ).not.toBeVisible(); }); -test("table sorting", async ({ page }) => { +const sortableTable = async (page: Page) => { await page.goto(`${BASE}/documentation.sql?component=table`); - const tableSection = page.locator(".table-responsive", { + return page.locator(".table-responsive", { has: page.getByRole("cell", { name: "31456" }), }); +}; - // Test numeric sorting on id column - await tableSection.getByRole("button", { name: "id" }).click(); - let ids = await tableSection.locator("td.id").allInnerTexts(); - let numericIds = ids.map((id) => Number.parseInt(id, 10)); - const sortedIds = [...numericIds].sort((a, b) => a - b); - expect(numericIds).toEqual(sortedIds); - - // Test reverse sorting - await tableSection.getByRole("button", { name: "id" }).click(); - ids = await tableSection.locator("td.id").allInnerTexts(); - numericIds = ids.map((id) => Number.parseInt(id, 10)); - const reverseSortedIds = [...numericIds].sort((a, b) => b - a); - expect(numericIds).toEqual(reverseSortedIds); - - // Test amount in stock column sorting - await tableSection.getByRole("button", { name: "Amount in stock" }).click(); - const amounts = await tableSection.locator("td.Amount").allInnerTexts(); - const numericAmounts = amounts.map((amount) => - Number.parseInt(amount.replace(/[^0-9]/g, ""), 10), - ); - const sortedAmounts = [...numericAmounts].sort((a, b) => a - b); - expect(numericAmounts).toEqual(sortedAmounts); +const numbersInColumn = async (table: Locator, cells: string) => { + const texts = await table.locator(cells).allInnerTexts(); + expect(texts.length).toBeGreaterThan(1); + return texts.map((text) => Number.parseInt(text.replace(/[^0-9]/g, ""), 10)); +}; + +const ascending = (values: number[]) => [...values].sort((a, b) => a - b); + +test("table sorts a column when its header is clicked", async ({ page }) => { + const table = await sortableTable(page); + await table.getByRole("button", { name: "id" }).click(); + + const ids = await numbersInColumn(table, "td._col_id"); + expect(ids).toEqual(ascending(ids)); +}); + +test("table reverses the sort when the header is clicked again", async ({ + page, +}) => { + const table = await sortableTable(page); + await table.getByRole("button", { name: "id" }).click(); + await table.getByRole("button", { name: "id" }).click(); + + const ids = await numbersInColumn(table, "td._col_id"); + expect(ids).toEqual(ascending(ids).reverse()); +}); + +test("table sorts a column of formatted numbers by value", async ({ page }) => { + const table = await sortableTable(page); + await table.getByRole("button", { name: "Amount in stock" }).click(); + + const amounts = await numbersInColumn(table, "td._col_Amount_in_stock"); + expect(amounts).toEqual(ascending(amounts)); }); async function checkNoConsoleErrors(page: Page, component: string) { @@ -401,10 +411,10 @@ test("no console errors on card page", async ({ page }) => { }); test("CSP issues unique nonces per request", async ({ page }) => { - const csp1 = await (await page.goto(BASE)).headerValue( + const csp1 = await (await page.goto(BASE))?.headerValue( "content-security-policy", ); - const csp2 = await (await page.reload()).headerValue( + const csp2 = await (await page.reload())?.headerValue( "content-security-policy", ); @@ -414,24 +424,19 @@ test("CSP issues unique nonces per request", async ({ page }) => { test("form component documentation", async ({ page }) => { await page.goto(`${BASE}/component.sql?component=form`); - // Find the form that contains radio buttons for component selection const componentForm = page.locator("form", { has: page.getByRole("radio", { name: "Chart" }), }); - // the form should be visible await expect(componentForm).toBeVisible(); - // Check that "form" is the first and default selected option const mapRadio = componentForm.getByRole("radio", { name: "Map" }); await expect(mapRadio).toHaveValue("map"); await expect(mapRadio).toBeChecked(); - // Select "Chart" option and submit await componentForm.getByLabel("Chart").click({ force: true }); await componentForm.getByRole("button", { name: "Submit" }).click(); - // Verify we're on the chart documentation page await expect( page.getByRole("heading", { name: /chart/i, level: 1 }), ).toBeVisible(); @@ -455,14 +460,14 @@ test("form select combines initial options with remote search results", async ({ )?.tomselect, ); - const initialState = await select.evaluate((element) => { + const initialState = await select.evaluate((element: HTMLSelectElement) => { const tomselect = element.tomselect; return { - value: tomselect.getValue(), + value: tomselect?.getValue(), labels: Object.fromEntries( - Object.entries(tomselect.options).map(([value, option]) => [ + Object.entries(tomselect?.options ?? {}).map(([value, option]) => [ value, - option.label, + option?.label, ]), ), }; @@ -472,7 +477,9 @@ test("form select combines initial options with remote search results", async ({ labels: { form: "Form" }, }); - await select.evaluate((element) => element.tomselect.focus()); + await select.evaluate((element: HTMLSelectElement) => + element.tomselect?.focus(), + ); await page.keyboard.type("form"); await page.waitForResponse((response) => response @@ -483,9 +490,9 @@ test("form select combines initial options with remote search results", async ({ ); await expect .poll(async () => - select.evaluate((element) => ({ - value: element.tomselect.getValue(), - formLabel: element.tomselect.options.form?.label, + select.evaluate((element: HTMLSelectElement) => ({ + value: element.tomselect?.getValue(), + formLabel: element.tomselect?.options.form?.label, })), ) .toEqual({ @@ -493,7 +500,9 @@ test("form select combines initial options with remote search results", async ({ formLabel: "form", }); - await select.evaluate((element) => element.tomselect.setTextboxValue("")); + await select.evaluate((element: HTMLSelectElement) => + element.tomselect?.setTextboxValue(""), + ); await page.keyboard.type("map"); await page.waitForResponse((response) => response @@ -504,10 +513,10 @@ test("form select combines initial options with remote search results", async ({ ); await expect .poll(async () => - select.evaluate((element) => ({ - value: element.tomselect.getValue(), - formLabel: element.tomselect.options.form?.label, - mapLabel: element.tomselect.options.map?.label, + select.evaluate((element: HTMLSelectElement) => ({ + value: element.tomselect?.getValue(), + formLabel: element.tomselect?.options.form?.label, + mapLabel: element.tomselect?.options.map?.label, })), ) .toEqual({ @@ -519,14 +528,12 @@ test("form select combines initial options with remote search results", async ({ test("modal", async ({ page }) => { await page.goto(`${BASE}/documentation.sql?component=modal#component`); - // get the button that opens the modal const openButton = page.getByRole("button", { name: "Open a simple modal" }); await openButton.click(); const modal = page.getByRole("dialog", { name: "A modal box" }); await expect(modal).toBeVisible(); - // close the modal await page.keyboard.press("Escape"); await expect(modal).not.toBeVisible(); diff --git a/tests/end-to-end/package.json b/tests/end-to-end/package.json index 8d884e2b..4f8f47dd 100644 --- a/tests/end-to-end/package.json +++ b/tests/end-to-end/package.json @@ -11,7 +11,6 @@ "author": "", "license": "ISC", "devDependencies": { - "@playwright/test": "^1.61.1", - "@types/node": "^24.13.3" + "@playwright/test": "^1.61.1" } } diff --git a/tests/end-to-end/tsconfig.json b/tests/end-to-end/tsconfig.json new file mode 100644 index 00000000..0a126078 --- /dev/null +++ b/tests/end-to-end/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "module": "nodenext", + "moduleResolution": "nodenext", + "types": ["node"] + }, + "include": ["*.spec.ts", "*.config.ts", "globals.d.ts"] +} diff --git a/tests/js/tsconfig.json b/tests/js/tsconfig.json new file mode 100644 index 00000000..752910d9 --- /dev/null +++ b/tests/js/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "lib": ["es2022"], + "module": "nodenext", + "moduleResolution": "nodenext", + "types": ["node"] + }, + "include": ["*.spec.ts"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..936b4362 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "es2022", + "lib": ["es2022", "dom", "dom.iterable"], + "module": "preserve", + "moduleResolution": "bundler", + "allowJs": true, + "checkJs": true, + "noEmit": true, + "strict": true, + "noImplicitAny": false, + "types": [] + }, + "include": ["sqlpage/*.js", "sqlpage/globals.d.ts"] +}