From 4ad623506a08d2dc20889a1f88b38d8a1ed2fed3 Mon Sep 17 00:00:00 2001 From: 81reap Date: Mon, 28 Sep 2026 22:56:53 -0400 Subject: [PATCH] refactor(frontend) :: convert the browser sources to TypeScript The browser modules become .ts and their JSDoc annotations become types. The emitted bundles are unchanged. --- frontend/package.json | 3 + frontend/src/{apexcharts.js => apexcharts.ts} | 73 ++++++------ .../src/{chart_series.js => chart_series.ts} | 65 ++++++----- frontend/src/{init.js => init.ts} | 2 +- frontend/src/{sqlpage.js => sqlpage.ts} | 107 ++++++++---------- frontend/src/{tomselect.js => tomselect.ts} | 19 ++-- scripts/build-frontend.mjs | 2 +- tests/js/chart_series.spec.ts | 2 +- tsconfig.json | 3 +- 9 files changed, 133 insertions(+), 143 deletions(-) create mode 100644 frontend/package.json rename frontend/src/{apexcharts.js => apexcharts.ts} (85%) rename frontend/src/{chart_series.js => chart_series.ts} (52%) rename frontend/src/{init.js => init.ts} (78%) rename frontend/src/{sqlpage.js => sqlpage.ts} (84%) rename frontend/src/{tomselect.js => tomselect.ts} (84%) diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 00000000..3dbc1ca5 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/frontend/src/apexcharts.js b/frontend/src/apexcharts.ts similarity index 85% rename from frontend/src/apexcharts.js rename to frontend/src/apexcharts.ts index 4721dc22..fb09d984 100644 --- a/frontend/src/apexcharts.js +++ b/frontend/src/apexcharts.ts @@ -1,16 +1,18 @@ +import type { ApexOptions } from "apexcharts"; import ApexCharts from "apexcharts"; -import { align_series_for, xaxis_type_for } from "./chart_series.js"; -import { add_init_fn } from "./init.js"; - -/** - * @typedef {import("./chart_series.js").ChartSeries} ChartSeries - * @typedef {import("./chart_series.js").Series} Series - */ +import { + align_series_for, + type ChartSeries, + type Series, + xaxis_type_for, +} from "./chart_series.ts"; +import { add_init_fn } from "./init.ts"; const sqlpage_chart = (() => { function sqlpage_chart() { - /** @type {NodeListOf} */ - const charts = document.querySelectorAll("[data-pre-init=chart]"); + const charts = document.querySelectorAll( + "[data-pre-init=chart]", + ); for (const c of charts) { try { build_sqlpage_chart(c); @@ -50,24 +52,21 @@ const sqlpage_chart = (() => { const referenceColor = colorNames[isDarkTheme ? "gray-lt" : "gray"]; - /** @typedef { {[property:string]: string|number|null} } ReferenceLine */ + type ReferenceLine = { [property: string]: string | number | null }; - /** @param {unknown} name @returns {string|undefined} */ - const named_color = (name) => + const named_color = (name: unknown): string | undefined => typeof name === "string" ? colorNames[name] : undefined; - /** @param {string|number|null} name */ - const reference_color = (name) => named_color(name) || referenceColor; + const reference_color = (name: string | number | null) => + named_color(name) || referenceColor; - /** - * @param {ReferenceLine[]} rows - the rows that carry an xline or a yline - * @param {"x"|"y"} column - the column the reference is written in - * @param {"x"|"y"} axis - the apexcharts axis that column is drawn on - * @param {(value: any) => any} to_axis_value - puts a SQL value on the axis - * @returns {object[]} apexcharts axis annotations - */ - function reference_lines(rows, column, axis, to_axis_value) { - const on_axis = (value) => { + function reference_lines( + rows: ReferenceLine[], + column: "x" | "y", + axis: "x" | "y", + to_axis_value: (value: string | number) => unknown, + ): object[] { + const on_axis = (value: string | number | null) => { if (value == null) return null; const placed = to_axis_value(value); return Number.isNaN(placed) ? null : placed; @@ -94,13 +93,10 @@ const sqlpage_chart = (() => { }); } - /** @param {HTMLElement} c */ - function build_sqlpage_chart(c) { + function build_sqlpage_chart(c: HTMLElement) { const [data_element] = c.getElementsByTagName("data"); const data = JSON.parse(data_element.textContent); - const chartContainer = /** @type {HTMLElement} */ ( - c.querySelector(".chart") - ); + const chartContainer = c.querySelector(".chart") as HTMLElement; chartContainer.innerHTML = ""; const is_timeseries = !!data.time; const chart_type = @@ -109,11 +105,12 @@ const sqlpage_chart = (() => { !!data.stacked && STACKABLE_CHART_TYPES.includes(chart_type); const points = data.points.filter(Array.isArray); const reference_rows = data.points.filter((row) => !Array.isArray(row)); - /** @type { Series } */ - const series_map = new Map(); + const series_map: Series = new Map(); for (const [name, old_x, old_y, color, z] of points) { - /** @type {ChartSeries} */ - const point_series = series_map.get(name) ?? { name, data: [] }; + const point_series: ChartSeries = series_map.get(name) ?? { + name, + data: [], + }; series_map.set(name, point_series); let x = old_x; let y = old_y; @@ -145,7 +142,7 @@ const sqlpage_chart = (() => { !!data.horizontal, ); - let labels; + let labels: (number | string | Date)[] | undefined; if (chart_type === "pie") { labels = points.map(([name, x, _y]) => x || name); series = points.map(([_name, _x, y]) => Number.parseFloat(y)); @@ -297,14 +294,12 @@ const sqlpage_chart = (() => { colors, series, }; - if (labels) options.labels = labels; + if (labels) (options as { labels?: unknown }).labels = labels; // Numeric axes count intervals; category and time axes use tickAmount as a // target for label density. - if (data.xticks) options.xaxis.tickAmount = data.xticks; - const chart = new ApexCharts( - chartContainer, - /** @type {import("apexcharts").ApexOptions} */ (options), - ); + if (data.xticks) + (options.xaxis as { tickAmount?: number }).tickAmount = data.xticks; + const chart = new ApexCharts(chartContainer, options as ApexOptions); chart.render().catch(console.error); if (window.charts) window.charts.push(chart); else window.charts = [chart]; diff --git a/frontend/src/chart_series.js b/frontend/src/chart_series.ts similarity index 52% rename from frontend/src/chart_series.js rename to frontend/src/chart_series.ts index d3134101..34ffb4d1 100644 --- a/frontend/src/chart_series.js +++ b/frontend/src/chart_series.ts @@ -1,11 +1,16 @@ -/** @typedef {number|string|Date} XValue */ -/** @typedef { {x:XValue, y:number|string|number[]|null, z?:number, fillColor?:string} } ChartPoint */ -/** @typedef { {name:string, data:ChartPoint[]} } ChartSeries */ -/** @typedef { Map } Series */ +export type XValue = number | string | Date; +export type ChartPoint = { + x: XValue; + y: number | string | number[] | null; + z?: number; + fillColor?: string; +}; +export type ChartSeries = { name: string; data: ChartPoint[] }; +export type Series = Map; const NUMERIC_X_CHART_TYPES = ["line", "area", "bar", "scatter", "bubble"]; -const Y_WHEN_A_SERIES_SKIPS_A_LABEL = { +const Y_WHEN_A_SERIES_SKIPS_A_LABEL: Record = { bar: 0, line: null, area: null, @@ -14,18 +19,18 @@ const Y_WHEN_A_SERIES_SKIPS_A_LABEL = { heatmap: null, }; -/** @param {XValue} x @returns {number|string} equal x values share a key */ -const x_key = (x) => (x instanceof Date ? x.getTime() : x); +/** equal x values share a key */ +const x_key = (x: XValue): number | string => + x instanceof Date ? x.getTime() : x; -/** @param {ChartSeries[]} series */ -const x_is_text = (series) => typeof series[0]?.data?.[0]?.x === "string"; +const x_is_text = (series: ChartSeries[]) => + typeof series[0]?.data?.[0]?.x === "string"; -/** @param {ChartSeries[]} series @param {string} chart_type */ export function xaxis_type_for( - series, - chart_type, - is_timeseries, - is_horizontal, + series: ChartSeries[], + chart_type: string, + is_timeseries: boolean, + is_horizontal: boolean, ) { if (is_timeseries) return "datetime"; if (x_is_text(series)) return "category"; @@ -38,18 +43,17 @@ export function xaxis_type_for( } /** - * @param {ChartSeries[]} series - * @returns {XValue[]} every x the series hold, in their own order where they - * agree and in ascending order where they diverge + * @returns every x the series hold, in their own order where they agree and in + * ascending order where they diverge */ -export function merged_x_values(series) { +export function merged_x_values(series: ChartSeries[]): XValue[] { const unread = series.map(({ data }) => data.map(({ x }) => x)); const merged = new Map(); while (unread.some((xs) => xs.length > 0)) { const with_lowest_x = unread .filter((xs) => xs.length > 0) .reduce((a, b) => (b[0] < a[0] ? b : a)); - const x = /** @type {XValue} */ (with_lowest_x.shift()); + const x = with_lowest_x.shift() as XValue; merged.set(x_key(x), x); } return [...merged.values()]; @@ -60,12 +64,13 @@ export function merged_x_values(series) { * series that skips an x lands on the wrong one. Give every series the same * amount of x values. * - * @param {ChartSeries[]} series - * @param {number|null} y_when_missing what a series with no value at an x is - * worth there: zero to add nothing to a stack, null to leave a gap. - * @returns {ChartSeries[]} + * @param y_when_missing what a series with no value at an x is worth there: + * zero to add nothing to a stack, null to leave a gap. */ -export function align_series(series, y_when_missing) { +export function align_series( + series: ChartSeries[], + y_when_missing: number | null, +): ChartSeries[] { const all_x = merged_x_values(series); return series.map(({ name, data }) => { const by_x = new Map(data.map((point) => [x_key(point.x), point])); @@ -79,13 +84,11 @@ export function align_series(series, y_when_missing) { }); } -/** - * @param {ChartSeries[]} series - * @param {string} chart_type - * @param {boolean} is_stacked - * @returns {ChartSeries[]} - */ -export function align_series_for(series, chart_type, is_stacked) { +export function align_series_for( + series: ChartSeries[], + chart_type: string, + is_stacked: boolean, +): ChartSeries[] { if (is_stacked) return align_series(series, 0); if (x_is_text(series) && chart_type in Y_WHEN_A_SERIES_SKIPS_A_LABEL) return align_series(series, Y_WHEN_A_SERIES_SKIPS_A_LABEL[chart_type]); diff --git a/frontend/src/init.js b/frontend/src/init.ts similarity index 78% rename from frontend/src/init.js rename to frontend/src/init.ts index d31f5fc2..4c345221 100644 --- a/frontend/src/init.js +++ b/frontend/src/init.ts @@ -1,4 +1,4 @@ -export function add_init_fn(f) { +export function add_init_fn(f: () => void) { document.addEventListener("DOMContentLoaded", f); document.addEventListener("fragment-loaded", f); if (document.readyState !== "loading") setTimeout(f, 0); diff --git a/frontend/src/sqlpage.js b/frontend/src/sqlpage.ts similarity index 84% rename from frontend/src/sqlpage.js rename to frontend/src/sqlpage.ts index 1796828e..752ea2ac 100644 --- a/frontend/src/sqlpage.js +++ b/frontend/src/sqlpage.ts @@ -1,5 +1,5 @@ import { bootstrap as bundled_bootstrap } from "@tabler/core"; -import { add_init_fn } from "./init.js"; +import { add_init_fn } from "./init.ts"; // A page may load its own Bootstrap; prefer it over the bundled copy. const page_bootstrap = () => window.bootstrap ?? bundled_bootstrap; @@ -7,15 +7,14 @@ const page_bootstrap = () => window.bootstrap ?? bundled_bootstrap; /** * Bootstrap declares getOrCreateInstance on the base class, which returns a * BaseComponent and so loses show(). - * @typedef {InstanceType} ToastWidget - * @typedef {InstanceType} ModalWidget */ +type ToastWidget = InstanceType; +type ModalWidget = InstanceType; -const nonce = /** @type {HTMLScriptElement} */ (document.currentScript).nonce; +const nonce = (document.currentScript as HTMLScriptElement).nonce; function sqlpage_card() { - /** @type {NodeListOf} */ - const cards = document.querySelectorAll("[data-pre-init=card]"); + const cards = document.querySelectorAll("[data-pre-init=card]"); for (const c of cards) { c.removeAttribute("data-pre-init"); if (!c.dataset.embed) continue; @@ -40,14 +39,13 @@ function sqlpage_card() { } } -/** @param {HTMLElement} root_el */ -function setup_table(root_el) { - /** @type {HTMLInputElement | null} */ - const search_input = root_el.querySelector("input.search"); +function setup_table(root_el: HTMLElement) { + const search_input = root_el.querySelector("input.search"); const table_el = root_el.querySelector("table"); if (!table_el) return; - /** @type {NodeListOf} */ - const sort_button_els = table_el.querySelectorAll("button.sort[data-sort]"); + 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; @@ -64,10 +62,11 @@ function setup_table(root_el) { } /** - * @param {HTMLInputElement} search_input - * @param {TableRow[]} items */ -function setup_table_search_behavior(search_input, items) { +function setup_table_search_behavior( + search_input: HTMLInputElement, + items: TableRow[], +) { function onSearch() { const lower_search = search_input.value .toLowerCase() @@ -85,10 +84,8 @@ function setup_table_search_behavior(search_input, items) { onSearch(); } -/**@param {HTMLElement} table_el */ -function apply_number_formatting(table_el) { - /** @type {NodeListOf} */ - const header_els = table_el.querySelectorAll("thead > tr > th"); +function apply_number_formatting(table_el: HTMLElement) { + 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); @@ -119,19 +116,18 @@ function apply_number_formatting(table_el) { } } -/** @typedef { {el: HTMLElement, sort_keys: {num: number, str: string}[]} } TableRow */ +type TableRow = { el: HTMLElement; sort_keys: { num: number; str: string }[] }; /** Prepare the table rows for sorting. - * @param {HTMLElement} table_el - * @param {HTMLElement[]} sort_buttons - * @returns {TableRow[]} */ -function table_parse_data(table_el, sort_buttons) { +function table_parse_data( + table_el: HTMLElement, + sort_buttons: HTMLElement[], +): TableRow[] { const is_num = [...sort_buttons].map( (btn_el) => btn_el.parentElement?.dataset.column_type === "number", ); - /** @type {NodeListOf} */ - const row_els = table_el.querySelectorAll("tbody tr"); + const row_els = table_el.querySelectorAll("tbody tr"); return [...row_els].map((tr_el) => { const cells = tr_el.getElementsByTagName("td"); return { @@ -147,11 +143,12 @@ 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 {TableRow[]} items - * @param {HTMLElement} item_parent */ -function setup_sort_behavior(sort_buttons, items, item_parent) { +function setup_sort_behavior( + sort_buttons: HTMLElement[], + items: TableRow[], + item_parent: HTMLElement, +) { sort_buttons.forEach((button, button_index) => { button.addEventListener("click", function sort_items() { const sort_desc = button.classList.contains("asc"); @@ -176,8 +173,9 @@ function setup_sort_behavior(sort_buttons, items, item_parent) { } function sqlpage_table() { - /** @type {NodeListOf} */ - const tables = document.querySelectorAll("[data-pre-init=table]"); + const tables = document.querySelectorAll( + "[data-pre-init=table]", + ); for (const r of tables) { r.removeAttribute("data-pre-init"); try { @@ -217,10 +215,10 @@ function sqlpage_map() { onLeafletLoad(); } /** - * @param {string|undefined} coords - * @returns {[number, number] | undefined} */ - function parseCoords(coords) { + function parseCoords( + coords: string | undefined, + ): [number, number] | undefined { if (!coords) return undefined; const parsed = coords.split(",", 2).map((c) => Number.parseFloat(c)); if (parsed.length !== 2 || !parsed.every(Number.isFinite)) { @@ -233,8 +231,7 @@ function sqlpage_map() { } function onLeafletLoad() { is_leaflet_loaded = true; - /** @type {NodeListOf} */ - const maps = document.querySelectorAll("[data-pre-init=map]"); + const maps = document.querySelectorAll("[data-pre-init=map]"); for (const m of maps) { const tile_source = m.dataset.tile_source; const maxZoom = Number(m.dataset.max_zoom); @@ -318,8 +315,7 @@ function sqlpage_map() { } function sqlpage_form() { - /** @type {NodeListOf} */ - const file_inputs = document.querySelectorAll( + const file_inputs = document.querySelectorAll( "input[type=file][data-max-size]", ); for (const input of file_inputs) { @@ -338,8 +334,9 @@ function sqlpage_form() { }); } - /** @type {NodeListOf} */ - const auto_submit_forms = document.querySelectorAll("form[data-auto-submit]"); + const auto_submit_forms = document.querySelectorAll( + "form[data-auto-submit]", + ); for (const form of auto_submit_forms) { form.addEventListener("change", () => form.submit()); } @@ -352,8 +349,7 @@ function get_tabler_color(name) { } function load_scripts() { - /** @type {NodeListOf} */ - const addjs = document.querySelectorAll("[data-sqlpage-js]"); + const addjs = document.querySelectorAll("[data-sqlpage-js]"); const existing_scripts = new Set( [...document.querySelectorAll("script")].map((s) => s.src), ); @@ -383,7 +379,7 @@ function open_toasts_for_hash(toasts) { if (!hash) return; for (const toast of toasts) { if (normalize_hash(toast.dataset.toastTrigger) === hash) { - /** @type {ToastWidget} */ (Toast.getOrCreateInstance(toast)).show(); + (Toast.getOrCreateInstance(toast) as ToastWidget).show(); } } } @@ -406,17 +402,16 @@ function restore_focus_after_toast(toast, container) { function sqlpage_toast() { const Toast = page_bootstrap().Toast; - const initialized_toasts = []; - /** @type {NodeListOf} */ - const toasts = document.querySelectorAll('[data-pre-init="toast"]'); + const initialized_toasts: HTMLElement[] = []; + 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 = /** @type {HTMLElement | null} */ ( - document.querySelector( - `.toast-container[data-sqlpage-toast-position="${position}"]:not([data-pre-init])`, - ) + let container: HTMLElement | null = document.querySelector( + `.toast-container[data-sqlpage-toast-position="${position}"]:not([data-pre-init])`, ); if (!container) { container = source_container; @@ -428,9 +423,7 @@ function sqlpage_toast() { } toast.removeAttribute("data-pre-init"); - const instance = /** @type {ToastWidget} */ ( - Toast.getOrCreateInstance(toast) - ); + const instance = Toast.getOrCreateInstance(toast) as ToastWidget; initialized_toasts.push(toast); toast.addEventListener("hidden.bs.toast", () => { restore_focus_after_toast(toast, container); @@ -505,9 +498,9 @@ function open_modal_for_hash() { if (!hash) return; const modal = document.getElementById(hash); if (!modal?.classList.contains("modal")) return; - const bootstrap_modal = /** @type {ModalWidget} */ ( - page_bootstrap().Modal.getOrCreateInstance(modal) - ); + const bootstrap_modal = page_bootstrap().Modal.getOrCreateInstance( + modal, + ) as ModalWidget; bootstrap_modal.show(); modal.addEventListener( "hidden.bs.modal", diff --git a/frontend/src/tomselect.js b/frontend/src/tomselect.ts similarity index 84% rename from frontend/src/tomselect.js rename to frontend/src/tomselect.ts index 32d81e0f..a7ceaacf 100644 --- a/frontend/src/tomselect.js +++ b/frontend/src/tomselect.ts @@ -1,9 +1,10 @@ import TomSelect from "tom-select/popular"; -import { add_init_fn } from "./init.js"; +import { add_init_fn } from "./init.ts"; function sqlpage_select_dropdown() { - /** @type {NodeListOf} */ - const selects = document.querySelectorAll("[data-pre-init=select-dropdown]"); + const selects = document.querySelectorAll( + "[data-pre-init=select-dropdown]", + ); for (const s of selects) { try { sqlpage_select_dropdown_individual(s); @@ -13,11 +14,7 @@ function sqlpage_select_dropdown() { } } -/** - * Initialize a select dropdown for a single element - * @param {HTMLSelectElement} s - The select element to initialize - */ -function sqlpage_select_dropdown_individual(s) { +function sqlpage_select_dropdown_individual(s: HTMLSelectElement) { s.removeAttribute("data-pre-init"); // See: https://github.com/orchidjs/tom-select/issues/716 // By default, TomSelect will not retain the focus if s is already focused @@ -48,10 +45,10 @@ function sqlpage_select_dropdown_individual(s) { }); } -function sqlpage_load_options_source(options_source) { +function sqlpage_load_options_source(options_source: string | undefined) { if (!options_source) return; - return async (query, callback) => { - const err = (label) => callback([{ label, value: "" }]); + return async (query: string, callback: (options: unknown[]) => void) => { + const err = (label: string) => callback([{ label, value: "" }]); const options_url = new URL(options_source, document.baseURI); options_url.searchParams.set("search", query); const resp = await fetch(options_url); diff --git a/scripts/build-frontend.mjs b/scripts/build-frontend.mjs index 83d3d97e..67429109 100644 --- a/scripts/build-frontend.mjs +++ b/scripts/build-frontend.mjs @@ -33,7 +33,7 @@ async function serve(name, content) { async function script(entry) { const build = await rolldown({ - input: { [entry]: `frontend/src/${entry}.js` }, + input: { [entry]: `frontend/src/${entry}` }, onwarn: refuse, }); const { output } = await build.generate({ format: "iife", minify: true }); diff --git a/tests/js/chart_series.spec.ts b/tests/js/chart_series.spec.ts index 22dbd9ac..3c52b616 100644 --- a/tests/js/chart_series.spec.ts +++ b/tests/js/chart_series.spec.ts @@ -5,7 +5,7 @@ import { align_series_for, merged_x_values, xaxis_type_for, -} from "../../frontend/src/chart_series.js"; +} from "../../frontend/src/chart_series.ts"; const ADDS_NOTHING_TO_THE_STACK = 0; const LEAVES_A_GAP = null; diff --git a/tsconfig.json b/tsconfig.json index f954acea..045c63f9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,9 +4,8 @@ "lib": ["es2022", "dom", "dom.iterable"], "module": "preserve", "moduleResolution": "bundler", - "allowJs": true, - "checkJs": true, "noEmit": true, + "allowImportingTsExtensions": true, "strict": true, "noImplicitAny": false, "types": []