Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
602 changes: 298 additions & 304 deletions frontend/src/apexcharts.ts

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions frontend/src/globals.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<import("./init.ts").InitRoot>;
}
21 changes: 10 additions & 11 deletions frontend/src/init.ts
Original file line number Diff line number Diff line change
@@ -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<InitRoot>;
};

/** Select matching descendants, including the fragment root itself. */
export function select_all<T extends Element>(
root: InitRoot,
selector: string,
element_type: new () => T,
): T[] {
const descendants = [...root.querySelectorAll<T>(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);
}
Expand Down
87 changes: 37 additions & 50 deletions frontend/src/sqlpage.ts
Original file line number Diff line number Diff line change
@@ -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<typeof bundled_bootstrap.Toast>;
type ModalWidget = InstanceType<typeof bundled_bootstrap.Modal>;

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<HTMLElement>(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;
Expand Down Expand Up @@ -180,7 +171,7 @@ function setup_sort_behavior(
}

function sqlpage_table(root: InitRoot) {
const tables = select_all<HTMLElement>(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 {
Expand All @@ -205,7 +196,7 @@ let is_leaflet_loaded = false;
const pending_maps = new Set<HTMLElement>();

function sqlpage_map(root: InitRoot) {
const maps = select_all<HTMLElement>(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);
Expand Down Expand Up @@ -262,7 +253,7 @@ function sqlpage_map(root: InitRoot) {
}
function onLeafletLoad(map_root: InitRoot) {
is_leaflet_loaded = true;
const maps = select_all<HTMLElement>(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);
Expand Down Expand Up @@ -371,9 +362,10 @@ const initialized_file_inputs = new WeakSet<HTMLInputElement>();
const initialized_auto_submit_forms = new WeakSet<HTMLFormElement>();

function sqlpage_form(root: InitRoot) {
const file_inputs = select_all<HTMLInputElement>(
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;
Expand All @@ -393,9 +385,10 @@ function sqlpage_form(root: InitRoot) {
});
}

const auto_submit_forms = select_all<HTMLFormElement>(
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;
Expand All @@ -411,9 +404,9 @@ function get_tabler_color(name: string) {
}

function load_scripts(root: InitRoot) {
const addjs = select_all<HTMLElement>(root, "[data-sqlpage-js]");
const addjs = select_all(root, "[data-sqlpage-js]", HTMLElement);
const existing_scripts = new Map(
[...document.querySelectorAll<InitScript>("script")].map((s) => [s.src, s]),
[...document.querySelectorAll("script")].map((s) => [s.src, s]),
);
for (const el of addjs) {
if (!el.dataset.sqlpageJs) continue;
Expand All @@ -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;
Expand All @@ -446,7 +439,10 @@ function open_toasts_for_hash(toasts: Iterable<HTMLElement>) {
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();
}
}
}
Expand All @@ -470,7 +466,7 @@ function sqlpage_toast(root: InitRoot) {
const Toast = page_bootstrap().Toast;

const initialized_toasts: HTMLElement[] = [];
const toasts = select_all<HTMLElement>(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;
Expand All @@ -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);
Expand Down Expand Up @@ -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 <body> keeps them viewport-fixed.
for (const modal of select_all<HTMLElement>(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.
Expand All @@ -546,26 +543,15 @@ window.addEventListener("hashchange", () =>

function init_bootstrap_components(root: InitRoot) {
const bootstrap = page_bootstrap();
for (const el of select_all<HTMLElement>(
root,
'[data-bs-toggle="tooltip"]',
)) {
bootstrap.Tooltip.getOrCreateInstance(el);
}
for (const el of select_all<HTMLElement>(
root,
'[data-bs-toggle="popover"]',
)) {
bootstrap.Popover.getOrCreateInstance(el);
}
for (const el of select_all<HTMLElement>(
root,
'[data-bs-toggle="dropdown"]',
)) {
bootstrap.Dropdown.getOrCreateInstance(el);
}
for (const el of select_all<HTMLElement>(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);
}
}
}

Expand All @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/tomselect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLSelectElement>(
const selects = select_all(
root,
"[data-pre-init=select-dropdown]",
HTMLSelectElement,
);
for (const s of selects) {
try {
Expand Down
65 changes: 35 additions & 30 deletions tests/end-to-end/component-assertions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -130,39 +131,43 @@ 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);
await expect(page.locator("#toast-plain")).toContainText(
"<strong>Plain text stays escaped</strong>",
);
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);
Expand Down
11 changes: 7 additions & 4 deletions tests/end-to-end/fixtures/chart/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
3 changes: 2 additions & 1 deletion tests/end-to-end/fixtures/fragment-loaded/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 3 additions & 1 deletion tests/js/component_documentation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,8 +250,10 @@ function read_documentation(): Map<string, TemplateProperties> {
const documented = new Map<string, TemplateProperties>();
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() };
Expand Down
Loading