From 35dcf3a66b2fad19e319dba4712d92af990d58d4 Mon Sep 17 00:00:00 2001 From: Ophir Lojkine Date: Fri, 9 Oct 2026 09:31:17 +0000 Subject: [PATCH] refactor(frontend): replace unchecked TypeScript assertions with narrowing --- frontend/src/apexcharts.ts | 602 +++++++++--------- frontend/src/globals.d.ts | 5 + frontend/src/init.ts | 21 +- frontend/src/sqlpage.ts | 87 ++- frontend/src/tomselect.ts | 3 +- tests/end-to-end/component-assertions.ts | 65 +- tests/end-to-end/fixtures/chart/test.ts | 11 +- .../fixtures/fragment-loaded/test.ts | 3 +- tests/js/component_documentation.spec.ts | 4 +- 9 files changed, 399 insertions(+), 402 deletions(-) diff --git a/frontend/src/apexcharts.ts b/frontend/src/apexcharts.ts index 70fc83c8..29df48e7 100644 --- a/frontend/src/apexcharts.ts +++ b/frontend/src/apexcharts.ts @@ -113,338 +113,332 @@ const axisTooltip = return tooltip.outerHTML; }; -const sqlpage_chart = (() => { - function sqlpage_chart(root: InitRoot) { - const charts = select_all(root, "[data-pre-init=chart]"); - for (const c of charts) { - try { - build_sqlpage_chart(c); - } catch (e) { - console.error(e); - } +function sqlpage_chart(root: InitRoot) { + const charts = select_all(root, "[data-pre-init=chart]", HTMLElement); + for (const c of charts) { + try { + build_sqlpage_chart(c); + } catch (e) { + console.error(e); } } +} - const tblrColors = [ - ["blue", "#1c7ed6", "#339af0"], - ["red", "#f03e3e", "#ff6b6b"], - ["green", "#37b24d", "#51cf66"], - ["pink", "#d6336c", "#f06595"], - ["purple", "#ae3ec9", "#cc5de8"], - ["orange", "#f76707", "#ff922b"], - ["cyan", "#1098ad", "#22b8cf"], - ["teal", "#0ca678", "#20c997"], - ["yellow", "#f59f00", "#fcc419"], - ["indigo", "#4263eb", "#5c7cfa"], - ["lime", "#74b816", "#94d82d"], - ["azure", "#339af0", "#339af0"], - ["gray", "#495057", "#adb5bd"], - ["black", "#000000", "#000000"], - ["white", "#ffffff", "#f8f9fa"], - ] as const; - const colorNames = new Map( - tblrColors.flatMap(([name, dark, light]): [string, string][] => [ - [name, dark], - [`${name}-lt`, light], - ]), - ); - const isDarkTheme = document.body?.dataset?.bsTheme === "dark"; +const tblrColors = [ + ["blue", "#1c7ed6", "#339af0"], + ["red", "#f03e3e", "#ff6b6b"], + ["green", "#37b24d", "#51cf66"], + ["pink", "#d6336c", "#f06595"], + ["purple", "#ae3ec9", "#cc5de8"], + ["orange", "#f76707", "#ff922b"], + ["cyan", "#1098ad", "#22b8cf"], + ["teal", "#0ca678", "#20c997"], + ["yellow", "#f59f00", "#fcc419"], + ["indigo", "#4263eb", "#5c7cfa"], + ["lime", "#74b816", "#94d82d"], + ["azure", "#339af0", "#339af0"], + ["gray", "#495057", "#adb5bd"], + ["black", "#000000", "#000000"], + ["white", "#ffffff", "#f8f9fa"], +] as const; +const colorNames = new Map( + tblrColors.flatMap(([name, dark, light]): [string, string][] => [ + [name, dark], + [`${name}-lt`, light], + ]), +); +const isDarkTheme = document.body?.dataset?.bsTheme === "dark"; - const STACKABLE_CHART_TYPES = ["line", "area", "bar"]; - const STROKE_WIDTHS = new Map([ - ["area", 3], - ["line", 2], - ]); - const APEXCHARTS_TYPE_ALIASES = new Map([["column", "bar"]]); +const STACKABLE_CHART_TYPES = ["line", "area", "bar"]; +const STROKE_WIDTHS = new Map([ + ["area", 3], + ["line", 2], +]); +const APEXCHARTS_TYPE_ALIASES = new Map([["column", "bar"]]); - const referenceColor = colorNames.get(isDarkTheme ? "gray-lt" : "gray"); +const referenceColor = colorNames.get(isDarkTheme ? "gray-lt" : "gray"); - const named_color = (name: unknown): string | undefined => - typeof name === "string" ? colorNames.get(name) : undefined; +const named_color = (name: unknown): string | undefined => + typeof name === "string" ? colorNames.get(name) : undefined; - const reference_color = (name: PlotValue) => - named_color(name) || referenceColor; +const reference_color = (name: PlotValue) => + named_color(name) || referenceColor; - function reference_lines( - rows: ReferenceLine[], - column: "x" | "y", - axis: "x" | "y", - to_axis_value: (value: PlotValue) => unknown, - ): object[] { - const on_axis = (value: PlotValue) => { - if (value == null) return null; - const placed = to_axis_value(value); - return Number.isNaN(placed) ? null : placed; - }; - return rows.flatMap((row) => { - const from = on_axis(row[`${column}line`]); - if (from == null) return []; - const color = reference_color(row.color); - return [ - { - [axis]: from, - [`${axis}2`]: on_axis(row[`${column}line_end`]), +function reference_lines( + rows: ReferenceLine[], + column: "x" | "y", + axis: "x" | "y", + to_axis_value: (value: PlotValue) => unknown, +): object[] { + const on_axis = (value: PlotValue) => { + if (value == null) return null; + const placed = to_axis_value(value); + return Number.isNaN(placed) ? null : placed; + }; + return rows.flatMap((row) => { + const from = on_axis(row[`${column}line`]); + if (from == null) return []; + const color = reference_color(row.color); + return [ + { + [axis]: from, + [`${axis}2`]: on_axis(row[`${column}line_end`]), + borderColor: color, + fillColor: color, + strokeDashArray: 4, + label: { + text: row.label, + orientation: column === "y" ? "horizontal" : "vertical", borderColor: color, - fillColor: color, - strokeDashArray: 4, - label: { - text: row.label, - orientation: column === "y" ? "horizontal" : "vertical", - borderColor: color, - style: { background: color, color: isDarkTheme ? "#000" : "#fff" }, - }, + style: { background: color, color: isDarkTheme ? "#000" : "#fff" }, }, - ]; - }); - } + }, + ]; + }); +} - function build_sqlpage_chart(c: HTMLElement) { - const [data_element] = c.getElementsByTagName("data"); - const chartContainer = c.querySelector(".chart"); - if (!data_element || !chartContainer) - throw new Error("Chart component is missing its data or container"); - const data = read_chart_data(data_element.textContent); - chartContainer.innerHTML = ""; - const is_timeseries = data.time; - const chart_type = - APEXCHARTS_TYPE_ALIASES.get(data.type) || data.type || "line"; - const is_pie = chart_type === "pie"; - const is_stacked = - data.stacked && STACKABLE_CHART_TYPES.includes(chart_type); - const { points } = data; - const series_map: Series = new Map(); - for (const { name, x: old_x, y: old_y, color, z, link } of points) { - const point_series: ChartSeries = series_map.get(name) ?? { - name, - data: [], - }; - series_map.set(name, point_series); - let x: XValue = old_x; - let y: PlotValue | PlotValue[] = old_y; - if (is_timeseries) { - if (typeof x === "number") x = new Date(x * 1000); - else if (chart_type === "rangeBar" && Array.isArray(y)) - y = y.map((value) => new Date(value ?? 0).getTime()); - else x = new Date(x ?? 0); - } - point_series.data.push({ - x, - y, - z, - link, - fillColor: named_color(color), - }); +function build_sqlpage_chart(c: HTMLElement) { + const [data_element] = c.getElementsByTagName("data"); + const chartContainer = c.querySelector(".chart"); + if (!data_element || !chartContainer) + throw new Error("Chart component is missing its data or container"); + const data = read_chart_data(data_element.textContent); + chartContainer.innerHTML = ""; + const is_timeseries = data.time; + const chart_type = + APEXCHARTS_TYPE_ALIASES.get(data.type) || data.type || "line"; + const is_pie = chart_type === "pie"; + const is_stacked = data.stacked && STACKABLE_CHART_TYPES.includes(chart_type); + const { points } = data; + const series_map: Series = new Map(); + for (const { name, x: old_x, y: old_y, color, z, link } of points) { + const point_series: ChartSeries = series_map.get(name) ?? { + name, + data: [], + }; + series_map.set(name, point_series); + let x: XValue = old_x; + let y: PlotValue | PlotValue[] = old_y; + if (is_timeseries) { + if (typeof x === "number") x = new Date(x * 1000); + else if (chart_type === "rangeBar" && Array.isArray(y)) + y = y.map((value) => new Date(value ?? 0).getTime()); + else x = new Date(x ?? 0); } + point_series.data.push({ + x, + y, + z, + link, + fillColor: named_color(color), + }); + } - const palette = [ - ...data.colors.map(named_color).filter((color) => color !== undefined), - ...tblrColors.map(([_, dark, light]) => (isDarkTheme ? dark : light)), - ...tblrColors.map(([_, dark, light]) => (isDarkTheme ? light : dark)), - ]; + const palette = [ + ...data.colors.map(named_color).filter((color) => color !== undefined), + ...tblrColors.map(([_, dark, light]) => (isDarkTheme ? dark : light)), + ...tblrColors.map(([_, dark, light]) => (isDarkTheme ? light : dark)), + ]; - const chart_series = [...series_map.values()]; - const aligned_series = - chart_series.length > 1 - ? align_series_for(chart_series, chart_type, is_stacked) - : chart_series; - const xaxis_type = xaxis_type_for( - chart_series, - chart_type, - is_timeseries, - data.horizontal, - ); + const chart_series = [...series_map.values()]; + const aligned_series = + chart_series.length > 1 + ? align_series_for(chart_series, chart_type, is_stacked) + : chart_series; + const xaxis_type = xaxis_type_for( + chart_series, + chart_type, + is_timeseries, + data.horizontal, + ); - const pie_labels = is_pie - ? points.map(({ name, x }) => String(x || name)) - : []; - const series = is_pie - ? points.map(({ y }) => Number.parseFloat(String(y))) - : aligned_series; - const colors = is_pie - ? points.map( - ({ color }, i) => - named_color(color) ?? - palette[i % palette.length] ?? - tblrColors[0][isDarkTheme ? 1 : 2], - ) - : palette; + const pie_labels = is_pie + ? points.map(({ name, x }) => String(x || name)) + : []; + const series = is_pie + ? points.map(({ y }) => Number.parseFloat(String(y))) + : aligned_series; + const colors = is_pie + ? points.map( + ({ color }, i) => + named_color(color) ?? + palette[i % palette.length] ?? + tblrColors[0][isDarkTheme ? 1 : 2], + ) + : palette; - const to_timestamp = (value: PlotValue) => - (typeof value === "number" - ? new Date(value * 1000) - : new Date(value ?? 0) - ).getTime(); - const dates_are_values = is_timeseries && chart_type === "rangeBar"; - const to_value = dates_are_values ? to_timestamp : Number; - const to_category = - is_timeseries && !dates_are_values - ? to_timestamp - : (value: PlotValue) => value; - const inverted = - chart_type === "rangeBar" || (chart_type === "bar" && data.horizontal); - const value_axis = inverted ? "x" : "y"; - const category_axis = inverted ? "y" : "x"; - const axis_titles: AxisTitles = { - x: data.xtitle, - y: data.ytitle, - z: data.ztitle, - }; - const has_point_links = points.some((point) => point.link); - const text_x_values = chart_series.every(({ data }) => - data.every(({ x }) => x == null || typeof x === "string"), - ); - const formatValue = (value: number | null) => { - if (value == null) return ""; - if (dates_are_values) return dateLabel(value); - return value.toLocaleString(undefined, { maximumFractionDigits: 2 }); - }; - const pointLink = is_pie - ? ({ seriesIndex }: PointIndex) => points[seriesIndex]?.link - : ({ seriesIndex, dataPointIndex }: PointIndex) => - aligned_series[seriesIndex]?.data[dataPointIndex]?.link; - const options: ApexOptions = { - annotations: { - [`${value_axis}axis`]: reference_lines( - data.reference_lines, - "y", - value_axis, - to_value, - ), - [`${category_axis}axis`]: reference_lines( - data.reference_lines, - "x", - category_axis, - to_category, - ), - }, - chart: { - // The query may name any type ApexCharts draws, not only the ones - // the component documents. - type: chart_type as ApexChart["type"], - fontFamily: "inherit", - background: "transparent", - parentHeightOffset: 0, - height: chartContainer.style.height, - stacked: is_stacked, - toolbar: { - show: data.toolbar, - }, - animations: { - enabled: false, - }, - zoom: { - enabled: false, - }, - events: { - dataPointSelection: (_event, _chart, args) => { - const link = args && pointLink(args); - if (link) window.location.assign(link); - }, - }, + const to_timestamp = (value: PlotValue) => + (typeof value === "number" + ? new Date(value * 1000) + : new Date(value ?? 0) + ).getTime(); + const dates_are_values = is_timeseries && chart_type === "rangeBar"; + const to_value = dates_are_values ? to_timestamp : Number; + const to_category = + is_timeseries && !dates_are_values + ? to_timestamp + : (value: PlotValue) => value; + const inverted = + chart_type === "rangeBar" || (chart_type === "bar" && data.horizontal); + const value_axis = inverted ? "x" : "y"; + const category_axis = inverted ? "y" : "x"; + const axis_titles: AxisTitles = { + x: data.xtitle, + y: data.ytitle, + z: data.ztitle, + }; + const has_point_links = points.some((point) => point.link); + const text_x_values = chart_series.every(({ data }) => + data.every(({ x }) => x == null || typeof x === "string"), + ); + const formatValue = (value: number | null) => { + if (value == null) return ""; + if (dates_are_values) return dateLabel(value); + return value.toLocaleString(undefined, { maximumFractionDigits: 2 }); + }; + const pointLink = is_pie + ? ({ seriesIndex }: PointIndex) => points[seriesIndex]?.link + : ({ seriesIndex, dataPointIndex }: PointIndex) => + aligned_series[seriesIndex]?.data[dataPointIndex]?.link; + const options: ApexOptions = { + annotations: { + [`${value_axis}axis`]: reference_lines( + data.reference_lines, + "y", + value_axis, + to_value, + ), + [`${category_axis}axis`]: reference_lines( + data.reference_lines, + "x", + category_axis, + to_category, + ), + }, + chart: { + // The query may name any type ApexCharts draws, not only the ones + // the component documents. + type: chart_type as ApexChart["type"], + fontFamily: "inherit", + background: "transparent", + parentHeightOffset: 0, + height: chartContainer.style.height, + stacked: is_stacked, + toolbar: { + show: data.toolbar, }, - theme: { - mode: isDarkTheme ? "dark" : "light", - palette: "palette4", + animations: { + enabled: false, }, - legend: { - show: data.show_legend, + zoom: { + enabled: false, }, - dataLabels: { - enabled: data.labels, - dropShadow: { - enabled: true, - color: "var(--tblr-primary-bg-subtle)", + events: { + dataPointSelection: (_event, _chart, args) => { + const link = args && pointLink(args); + if (link) window.location.assign(link); }, - formatter: is_pie - ? pieLabel(pie_labels) - : chart_type === "rangeBar" - ? rangeBarLabel(aligned_series.map(({ name }) => name)) - : numberLabel, }, - fill: { - type: chart_type === "area" ? "gradient" : "solid", + }, + theme: { + mode: isDarkTheme ? "dark" : "light", + palette: "palette4", + }, + legend: { + show: data.show_legend, + }, + dataLabels: { + enabled: data.labels, + dropShadow: { + enabled: true, + color: "var(--tblr-primary-bg-subtle)", }, - stroke: { - width: STROKE_WIDTHS.get(chart_type) ?? 0, - lineCap: "round", - curve: "smooth", + formatter: is_pie + ? pieLabel(pie_labels) + : chart_type === "rangeBar" + ? rangeBarLabel(aligned_series.map(({ name }) => name)) + : numberLabel, + }, + fill: { + type: chart_type === "area" ? "gradient" : "solid", + }, + stroke: { + width: STROKE_WIDTHS.get(chart_type) ?? 0, + lineCap: "round", + curve: "smooth", + }, + xaxis: { + tooltip: { + enabled: false, }, - xaxis: { - tooltip: { - enabled: false, - }, - min: data.xmin, - max: data.xmax, - title: { - text: axis_titles.x, - }, - type: xaxis_type, - labels: { - datetimeUTC: false, - }, - // Numeric axes count intervals; category and time axes use tickAmount - // as a target for label density. - tickAmount: data.xticks || undefined, + min: data.xmin, + max: data.xmax, + title: { + text: axis_titles.x, }, - yaxis: { - logarithmic: data.logarithmic, - min: data.ymin, - max: data.ymax, - stepSize: data.ystep, - tickAmount: data.yticks, - title: { - text: axis_titles.y, - }, + type: xaxis_type, + labels: { + datetimeUTC: false, }, - markers: { - size: data.marker ?? 0, - strokeWidth: 0, - hover: { - sizeOffset: 5, - }, + // Numeric axes count intervals; category and time axes use tickAmount + // as a target for label density. + tickAmount: data.xticks || undefined, + }, + yaxis: { + logarithmic: data.logarithmic, + min: data.ymin, + max: data.ymax, + stepSize: data.ystep, + tickAmount: data.yticks, + title: { + text: axis_titles.y, }, - tooltip: { - fillSeriesColor: false, - interactive: has_point_links, - custom: - chart_type === "bubble" || chart_type === "scatter" - ? axisTooltip(aligned_series, axis_titles, formatValue) + }, + markers: { + size: data.marker ?? 0, + strokeWidth: 0, + hover: { + sizeOffset: 5, + }, + }, + tooltip: { + fillSeriesColor: false, + interactive: has_point_links, + custom: + chart_type === "bubble" || chart_type === "scatter" + ? axisTooltip(aligned_series, axis_titles, formatValue) + : undefined, + x: { + formatter: + has_point_links && text_x_values + ? (value, args) => linkTooltipValue(value, args && pointLink(args)) : undefined, - x: { - formatter: - has_point_links && text_x_values - ? (value, args) => - linkTooltipValue(value, args && pointLink(args)) - : undefined, - }, - y: { - formatter: (value, args) => - linkTooltipValue( - formatValue(value), - args?.seriesIndex !== undefined ? pointLink(args) : undefined, - ), - }, }, - plotOptions: { - bar: { - horizontal: data.horizontal || chart_type === "rangeBar", - borderRadius: 5, - }, - bubble: { minBubbleRadius: 5 }, + y: { + formatter: (value, args) => + linkTooltipValue( + formatValue(value), + args?.seriesIndex !== undefined ? pointLink(args) : undefined, + ), }, - colors, - // ApexCharts draws a numeric series name but declares only a string. - series: series as ApexOptions["series"], - }; - if (is_pie) options.labels = pie_labels; - const chart = new ApexCharts(chartContainer, options) as RenderedChart; - chart.render().catch(console.error); - if (window.charts) window.charts.push(chart); - else window.charts = [chart]; - c.removeAttribute("data-pre-init"); - } - - return sqlpage_chart; -})(); + }, + plotOptions: { + bar: { + horizontal: data.horizontal || chart_type === "rangeBar", + borderRadius: 5, + }, + bubble: { minBubbleRadius: 5 }, + }, + colors, + // ApexCharts draws a numeric series name but declares only a string. + series: series as ApexOptions["series"], + }; + if (is_pie) options.labels = pie_labels; + const chart = new ApexCharts(chartContainer, options) as RenderedChart; + chart.render().catch(console.error); + if (window.charts) window.charts.push(chart); + else window.charts = [chart]; + c.removeAttribute("data-pre-init"); +} add_init_fn(sqlpage_chart); diff --git a/frontend/src/globals.d.ts b/frontend/src/globals.d.ts index 274fb041..c53ad744 100644 --- a/frontend/src/globals.d.ts +++ b/frontend/src/globals.d.ts @@ -12,3 +12,8 @@ interface HTMLElement { /** Attached by sqlpage_select_dropdown to every select it takes over. */ tomselect?: import("tom-select/popular").default; } + +interface HTMLScriptElement { + /** Roots queued while an independently bundled initializer loads. */ + sqlpage_init_roots?: Set; +} diff --git a/frontend/src/init.ts b/frontend/src/init.ts index 1f9fac1a..a5df8281 100644 --- a/frontend/src/init.ts +++ b/frontend/src/init.ts @@ -1,27 +1,26 @@ export type InitRoot = Element | Document; -// The script element carries roots across independently bundled initializers. -export type InitScript = HTMLScriptElement & { - sqlpage_init_roots?: Set; -}; - /** Select matching descendants, including the fragment root itself. */ export function select_all( root: InitRoot, selector: string, + element_type: new () => T, ): T[] { - const descendants = [...root.querySelectorAll(selector)]; - if (root instanceof Element && root.matches(selector)) { - descendants.unshift(root as T); + const descendants = [...root.querySelectorAll(selector)].filter( + (element): element is T => element instanceof element_type, + ); + if (root instanceof element_type && root.matches(selector)) { + descendants.unshift(root); } return descendants; } export function add_init_fn(f: (root: InitRoot) => void) { - const script = document.currentScript as InitScript | null; - const pending_roots = script?.sqlpage_init_roots; + const script = document.currentScript; + const pending_roots = + script instanceof HTMLScriptElement ? script.sqlpage_init_roots : undefined; const initialize = () => { - if (script) delete script.sqlpage_init_roots; + if (script instanceof HTMLScriptElement) delete script.sqlpage_init_roots; for (const root of pending_roots ?? [document]) { if (root instanceof Document || root.isConnected) f(root); } diff --git a/frontend/src/sqlpage.ts b/frontend/src/sqlpage.ts index 8570c3a3..4bacfc75 100644 --- a/frontend/src/sqlpage.ts +++ b/frontend/src/sqlpage.ts @@ -1,26 +1,17 @@ import { bootstrap as bundled_bootstrap } from "@tabler/core"; import type * as Leaflet from "leaflet"; -import { - add_init_fn, - type InitRoot, - type InitScript, - select_all, -} from "./init.ts"; +import { add_init_fn, type InitRoot, select_all } from "./init.ts"; // A page may load its own Bootstrap; prefer it over the bundled copy. const page_bootstrap = () => window.bootstrap ?? bundled_bootstrap; -/** - * Bootstrap declares getOrCreateInstance on the base class, which returns a - * BaseComponent and so loses show(). - */ -type ToastWidget = InstanceType; -type ModalWidget = InstanceType; - -const nonce = (document.currentScript as HTMLScriptElement).nonce; +const nonce = + document.currentScript instanceof HTMLScriptElement + ? document.currentScript.nonce + : ""; function sqlpage_card(root: InitRoot) { - const cards = select_all(root, "[data-pre-init=card]"); + const cards = select_all(root, "[data-pre-init=card]", HTMLElement); for (const c of cards) { c.removeAttribute("data-pre-init"); if (!c.dataset.embed) continue; @@ -180,7 +171,7 @@ function setup_sort_behavior( } function sqlpage_table(root: InitRoot) { - const tables = select_all(root, "[data-pre-init=table]"); + const tables = select_all(root, "[data-pre-init=table]", HTMLElement); for (const r of tables) { r.removeAttribute("data-pre-init"); try { @@ -205,7 +196,7 @@ let is_leaflet_loaded = false; const pending_maps = new Set(); function sqlpage_map(root: InitRoot) { - const maps = select_all(root, "[data-pre-init=map]"); + const maps = select_all(root, "[data-pre-init=map]", HTMLElement); const first_map = maps[0]; if (!is_leaflet_loaded) { for (const map of maps) pending_maps.add(map); @@ -262,7 +253,7 @@ function sqlpage_map(root: InitRoot) { } function onLeafletLoad(map_root: InitRoot) { is_leaflet_loaded = true; - const maps = select_all(map_root, "[data-pre-init=map]"); + const maps = select_all(map_root, "[data-pre-init=map]", HTMLElement); for (const m of maps) { const tile_source = m.dataset.tile_source; const maxZoom = Number(m.dataset.max_zoom); @@ -371,9 +362,10 @@ const initialized_file_inputs = new WeakSet(); const initialized_auto_submit_forms = new WeakSet(); function sqlpage_form(root: InitRoot) { - const file_inputs = select_all( + const file_inputs = select_all( root, "input[type=file][data-max-size]", + HTMLInputElement, ); for (const input of file_inputs) { if (initialized_file_inputs.has(input)) continue; @@ -393,9 +385,10 @@ function sqlpage_form(root: InitRoot) { }); } - const auto_submit_forms = select_all( + const auto_submit_forms = select_all( root, "form[data-auto-submit]", + HTMLFormElement, ); for (const form of auto_submit_forms) { if (initialized_auto_submit_forms.has(form)) continue; @@ -411,9 +404,9 @@ function get_tabler_color(name: string) { } function load_scripts(root: InitRoot) { - const addjs = select_all(root, "[data-sqlpage-js]"); + const addjs = select_all(root, "[data-sqlpage-js]", HTMLElement); const existing_scripts = new Map( - [...document.querySelectorAll("script")].map((s) => [s.src, s]), + [...document.querySelectorAll("script")].map((s) => [s.src, s]), ); for (const el of addjs) { if (!el.dataset.sqlpageJs) continue; @@ -423,7 +416,7 @@ function load_scripts(root: InitRoot) { existing.sqlpage_init_roots?.add(el); continue; } - const script: InitScript = document.createElement("script"); + const script = document.createElement("script"); script.sqlpage_init_roots = new Set([el]); existing_scripts.set(js, script); script.src = js; @@ -446,7 +439,10 @@ function open_toasts_for_hash(toasts: Iterable) { if (!hash) return; for (const toast of toasts) { if (normalize_hash(toast.dataset.toastTrigger) === hash) { - (Toast.getOrCreateInstance(toast) as ToastWidget).show(); + const instance = Toast.getOrCreateInstance(toast); + if (!(instance instanceof Toast)) + throw new Error("Invalid toast instance"); + instance.show(); } } } @@ -470,7 +466,7 @@ function sqlpage_toast(root: InitRoot) { const Toast = page_bootstrap().Toast; const initialized_toasts: HTMLElement[] = []; - const toasts = select_all(root, '[data-pre-init="toast"]'); + const toasts = select_all(root, '[data-pre-init="toast"]', HTMLElement); for (const toast of toasts) { const source_container = toast.parentElement; if (!source_container) continue; @@ -488,7 +484,8 @@ function sqlpage_toast(root: InitRoot) { } toast.removeAttribute("data-pre-init"); - const instance = Toast.getOrCreateInstance(toast) as ToastWidget; + const instance = Toast.getOrCreateInstance(toast); + if (!(instance instanceof Toast)) throw new Error("Invalid toast instance"); initialized_toasts.push(toast); toast.addEventListener("hidden.bs.toast", () => { restore_focus_after_toast(toast, container); @@ -523,7 +520,7 @@ function sqlpage_modal(root: InitRoot) { // .page instead of the viewport. The modal then scrolls with the page // content and ends up behind its own backdrop, so its buttons cannot be // clicked. Moving modals to keeps them viewport-fixed. - for (const modal of select_all(root, "body .page .modal")) { + for (const modal of select_all(root, "body .page .modal", HTMLElement)) { document.body.appendChild(modal); // The modal leaves the original root; announce its subtree separately so // every initializer, including later-loaded bundles, can still see it. @@ -546,26 +543,15 @@ window.addEventListener("hashchange", () => function init_bootstrap_components(root: InitRoot) { const bootstrap = page_bootstrap(); - for (const el of select_all( - root, - '[data-bs-toggle="tooltip"]', - )) { - bootstrap.Tooltip.getOrCreateInstance(el); - } - for (const el of select_all( - root, - '[data-bs-toggle="popover"]', - )) { - bootstrap.Popover.getOrCreateInstance(el); - } - for (const el of select_all( - root, - '[data-bs-toggle="dropdown"]', - )) { - bootstrap.Dropdown.getOrCreateInstance(el); - } - for (const el of select_all(root, '[data-bs-ride="carousel"]')) { - bootstrap.Carousel.getOrCreateInstance(el); + for (const [selector, Component] of [ + ['[data-bs-toggle="tooltip"]', bootstrap.Tooltip], + ['[data-bs-toggle="popover"]', bootstrap.Popover], + ['[data-bs-toggle="dropdown"]', bootstrap.Dropdown], + ['[data-bs-ride="carousel"]', bootstrap.Carousel], + ] as const) { + for (const el of select_all(root, selector, HTMLElement)) { + Component.getOrCreateInstance(el); + } } } @@ -576,9 +562,10 @@ function open_modal_for_hash() { if (!hash) return; const modal = document.getElementById(hash); if (!modal?.classList.contains("modal")) return; - const bootstrap_modal = page_bootstrap().Modal.getOrCreateInstance( - modal, - ) as ModalWidget; + const Modal = page_bootstrap().Modal; + const bootstrap_modal = Modal.getOrCreateInstance(modal); + if (!(bootstrap_modal instanceof Modal)) + throw new Error("Invalid modal instance"); bootstrap_modal.show(); modal.addEventListener( "hidden.bs.modal", diff --git a/frontend/src/tomselect.ts b/frontend/src/tomselect.ts index 21b01bc1..fc97a66e 100644 --- a/frontend/src/tomselect.ts +++ b/frontend/src/tomselect.ts @@ -2,9 +2,10 @@ import TomSelect from "tom-select/popular"; import { add_init_fn, type InitRoot, select_all } from "./init.ts"; function sqlpage_select_dropdown(root: InitRoot) { - const selects = select_all( + const selects = select_all( root, "[data-pre-init=select-dropdown]", + HTMLSelectElement, ); for (const s of selects) { try { diff --git a/tests/end-to-end/component-assertions.ts b/tests/end-to-end/component-assertions.ts index 4a4350fe..65bc7219 100644 --- a/tests/end-to-end/component-assertions.ts +++ b/tests/end-to-end/component-assertions.ts @@ -20,7 +20,8 @@ export async function checkToastNotifications(page: Page) { await expect(stackOne).toBeHidden(); await page.evaluate(() => { document.addEventListener("shown.bs.toast", (event) => { - const toast = event.target as HTMLElement; + const toast = event.target; + if (!(toast instanceof HTMLElement)) throw new Error("Missing toast"); toast.dataset.shownCount = String( Number(toast.dataset.shownCount ?? 0) + 1, ); @@ -91,9 +92,9 @@ export async function checkToastNotifications(page: Page) { await expect(closeButton).toBeVisible(); const closeStyle = await closeButton.evaluate((button) => { const style = getComputedStyle(button); - const toastStyle = getComputedStyle( - button.closest(".toast") as HTMLElement, - ); + const toast = button.closest(".toast"); + if (!toast) throw new Error("Missing toast"); + const toastStyle = getComputedStyle(toast); return { backgroundColor: style.backgroundColor, color: style.color, @@ -130,13 +131,15 @@ export async function checkToastNotifications(page: Page) { "href", "https://example.com/releases", ); - const linkStyle = await page - .locator("#toast-markdown a") - .evaluate((link) => ({ - color: getComputedStyle(link).color, - parentColor: getComputedStyle(link.parentElement as HTMLElement).color, - textDecorationLine: getComputedStyle(link).textDecorationLine, - })); + const linkStyle = await page.locator("#toast-markdown a").evaluate((link) => { + if (!link.parentElement) throw new Error("Missing toast link parent"); + const style = getComputedStyle(link); + return { + color: style.color, + parentColor: getComputedStyle(link.parentElement).color, + textDecorationLine: style.textDecorationLine, + }; + }); expect(linkStyle.color).toBe(linkStyle.parentColor); expect(linkStyle.textDecorationLine).toBe("underline"); await expect(page.locator("#toast-plain strong")).toHaveCount(0); @@ -144,25 +147,27 @@ export async function checkToastNotifications(page: Page) { "Plain text stays escaped", ); const whiteToast = page.locator("#toast-plain"); - const whiteToastStyle = await whiteToast.evaluate((toast) => { - const style = getComputedStyle(toast); - const closeStyle = getComputedStyle( - toast.querySelector(".btn-close") as HTMLElement, - ); - const rgba = (color: string) => { - const canvas = document.createElement("canvas"); - const context = canvas.getContext("2d"); - if (!context) throw new Error("Canvas 2D context is unavailable"); - context.fillStyle = color; - context.fillRect(0, 0, 1, 1); - return Array.from(context.getImageData(0, 0, 1, 1).data); - }; - return { - backgroundColor: rgba(style.backgroundColor), - closeColor: rgba(closeStyle.backgroundColor), - color: rgba(style.color), - }; - }); + const whiteToastStyle = await whiteToast + .locator(".btn-close") + .evaluate((close) => { + const toast = close.closest(".toast"); + if (!toast) throw new Error("Missing toast"); + const style = getComputedStyle(toast); + const closeStyle = getComputedStyle(close); + const rgba = (color: string) => { + const canvas = document.createElement("canvas"); + const context = canvas.getContext("2d"); + if (!context) throw new Error("Canvas 2D context is unavailable"); + context.fillStyle = color; + context.fillRect(0, 0, 1, 1); + return Array.from(context.getImageData(0, 0, 1, 1).data); + }; + return { + backgroundColor: rgba(style.backgroundColor), + closeColor: rgba(closeStyle.backgroundColor), + color: rgba(style.color), + }; + }); expect(whiteToastStyle.backgroundColor).toEqual([255, 255, 255, 255]); expect(whiteToastStyle.color).toEqual([31, 41, 55, 255]); expect(whiteToastStyle.closeColor).toEqual(whiteToastStyle.color); diff --git a/tests/end-to-end/fixtures/chart/test.ts b/tests/end-to-end/fixtures/chart/test.ts index 8038beb2..9b41ba0c 100644 --- a/tests/end-to-end/fixtures/chart/test.ts +++ b/tests/end-to-end/fixtures/chart/test.ts @@ -480,10 +480,13 @@ test("gives the tooltip title the color of the tooltip around it", async ({ const title = page.locator("#test-chart .apexcharts-tooltip-title"); await expect(title).toHaveText("Tue"); - const colors = await title.evaluate((el) => ({ - title: getComputedStyle(el).color, - tooltip: getComputedStyle(el.parentElement as HTMLElement).color, - })); + const colors = await title.evaluate((el) => { + if (!el.parentElement) throw new Error("Missing tooltip"); + return { + title: getComputedStyle(el).color, + tooltip: getComputedStyle(el.parentElement).color, + }; + }); expect(colors.title).toBe(colors.tooltip); }); diff --git a/tests/end-to-end/fixtures/fragment-loaded/test.ts b/tests/end-to-end/fixtures/fragment-loaded/test.ts index f56f1952..7710b6f8 100644 --- a/tests/end-to-end/fixtures/fragment-loaded/test.ts +++ b/tests/end-to-end/fixtures/fragment-loaded/test.ts @@ -8,7 +8,8 @@ const announceDocument = (page: Page) => async function trackHandlers(page: Page, selector: string) { await page.locator(selector).evaluate((element) => { - const form = element as HTMLFormElement; + if (!(element instanceof HTMLFormElement)) throw new Error("Missing form"); + const form = element; form.dataset.submissions = "0"; form.submit = () => { form.dataset.submissions = String(Number(form.dataset.submissions) + 1); diff --git a/tests/js/component_documentation.spec.ts b/tests/js/component_documentation.spec.ts index a9426cbe..c78a4683 100644 --- a/tests/js/component_documentation.spec.ts +++ b/tests/js/component_documentation.spec.ts @@ -250,8 +250,10 @@ function read_documentation(): Map { const documented = new Map(); const rows = db .prepare("select component, name, top_level from parameter") - .all() as { component: string; name: string; top_level: number }[]; + .all(); for (const { component, name, top_level } of rows) { + assert.ok(typeof component === "string" && typeof name === "string"); + assert.equal(typeof top_level, "number"); let entry = documented.get(component); if (!entry) { entry = { top: new Set(), row: new Set() };