diff --git a/CHANGELOG.md b/CHANGELOG.md index ff6cc86f6..1d8d36e81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # CHANGELOG.md ## v0.47.0 (unreleased) +- Loading embedded card content no longer adds duplicate form submission or file validation handlers to existing fields. Fragment initialization also includes the element announcing the fragment. - **Mac users:** the downloadable `sqlpage-macos.tgz` is now code-signed with a Developer ID certificate and notarized by Apple. macOS may still ask you to confirm opening it the first time. The binary can still load third-party ODBC database drivers. - **Mac users:** the downloadable `sqlpage-macos.tgz` now runs natively on Apple silicon (M-series Macs) and no longer runs on Intel Macs. Homebrew remains the recommended and easiest installation method. On an Intel Mac, [install Homebrew](https://brew.sh/) if needed, then run `brew install sqlpage` (or `brew update` followed by `brew upgrade sqlpage` if you already installed it with Homebrew). Open Terminal in your existing website folder and run `sqlpage` instead of `./sqlpage.bin`; keep your SQL files, database, and `sqlpage` configuration folder in place. Intel installations may build from source and take longer; see the [macOS installation guide](https://sql-page.com/your-first-sql-website/?os=macos#download) for setup and older macOS requirements. - Chart data points can now include a `link`. Clicking a point or its tooltip value opens that URL; a text x value in the tooltip title links to it too. The tooltip remains open while the pointer moves onto the link. ApexCharts was updated to [v7.6.0](https://github.com/apexcharts/apexcharts.js/releases/tag/v7.6.0) to support this. diff --git a/examples/official-site/sqlpage/migrations/31_card_docs_update.sql b/examples/official-site/sqlpage/migrations/31_card_docs_update.sql index 2cdaa11ad..1b813b8a9 100644 --- a/examples/official-site/sqlpage/migrations/31_card_docs_update.sql +++ b/examples/official-site/sqlpage/migrations/31_card_docs_update.sql @@ -39,7 +39,12 @@ INSERT INTO parameter(component, name, description_md, type, top_level, optional ('embed', 'A url whose contents will be fetched and injected into the body of this card. This can be used to inject arbitrary html content, but is especially useful for injecting the output of other sql files rendered by SQLPage. For the latter case you can pass the - `?_sqlpage_embed` query parameter, which will skip the shell layout', 'TEXT', FALSE, TRUE), + `?_sqlpage_embed` query parameter, which will skip the shell layout. + Components inside the fetched content initialize automatically. Custom scripts that insert + SQLPage fragments should dispatch a bubbling `fragment-loaded` event on the element containing + the new content. Initialization includes that element and its descendants; dispatching the + event on `document` initializes pending components throughout the page. Repeated events + preserve existing form and widget handlers', 'TEXT', FALSE, TRUE), ('embed_mode', 'Set to ''iframe'' to embed the target (specified through embed property) in an iframe. Unless this is explicitly set, the embed target is fetched and injected within the parent page. If embed_mode is set to iframe, You can also set height and width parameters to configure the appearance and the sandbox and allow parameters to configure diff --git a/frontend/src/apexcharts.ts b/frontend/src/apexcharts.ts index 3098f755c..8cfd29978 100644 --- a/frontend/src/apexcharts.ts +++ b/frontend/src/apexcharts.ts @@ -7,7 +7,7 @@ import { type Series, xaxis_type_for, } from "./chart_series.ts"; -import { add_init_fn } from "./init.ts"; +import { add_init_fn, type InitRoot, select_all } from "./init.ts"; type DataPoint = { name: string; @@ -51,10 +51,8 @@ const numberLabel = (value: string | number) => value == null ? "" : value.toLocaleString?.() || String(value); const sqlpage_chart = (() => { - function sqlpage_chart() { - const charts = document.querySelectorAll( - "[data-pre-init=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); diff --git a/frontend/src/init.ts b/frontend/src/init.ts index 4c3452219..1f9fac1a4 100644 --- a/frontend/src/init.ts +++ b/frontend/src/init.ts @@ -1,5 +1,37 @@ -export function add_init_fn(f: () => void) { - document.addEventListener("DOMContentLoaded", f); - document.addEventListener("fragment-loaded", f); - if (document.readyState !== "loading") setTimeout(f, 0); +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, +): T[] { + const descendants = [...root.querySelectorAll(selector)]; + if (root instanceof Element && root.matches(selector)) { + descendants.unshift(root as T); + } + 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 initialize = () => { + if (script) delete script.sqlpage_init_roots; + for (const root of pending_roots ?? [document]) { + if (root instanceof Document || root.isConnected) f(root); + } + }; + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initialize, { once: true }); + } else { + setTimeout(initialize, 0); + } + document.addEventListener("fragment-loaded", ({ target }) => { + if (target instanceof Element || target instanceof Document) f(target); + }); } diff --git a/frontend/src/sqlpage.ts b/frontend/src/sqlpage.ts index f06685a7e..efd315751 100644 --- a/frontend/src/sqlpage.ts +++ b/frontend/src/sqlpage.ts @@ -1,6 +1,11 @@ import { bootstrap as bundled_bootstrap } from "@tabler/core"; import type * as Leaflet from "leaflet"; -import { add_init_fn } from "./init.ts"; +import { + add_init_fn, + type InitRoot, + type InitScript, + 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; @@ -14,8 +19,8 @@ type ModalWidget = InstanceType; const nonce = (document.currentScript as HTMLScriptElement).nonce; -function sqlpage_card() { - const cards = document.querySelectorAll("[data-pre-init=card]"); +function sqlpage_card(root: InitRoot) { + const cards = select_all(root, "[data-pre-init=card]"); for (const c of cards) { c.removeAttribute("data-pre-init"); if (!c.dataset.embed) continue; @@ -173,10 +178,8 @@ function setup_sort_behavior( }); } -function sqlpage_table() { - const tables = document.querySelectorAll( - "[data-pre-init=table]", - ); +function sqlpage_table(root: InitRoot) { + const tables = select_all(root, "[data-pre-init=table]"); for (const r of tables) { r.removeAttribute("data-pre-init"); try { @@ -198,9 +201,14 @@ type MarkerStyle = Leaflet.MarkerOptions & let is_leaflet_injected = false; let is_leaflet_loaded = false; +const pending_maps = new Set(); -function sqlpage_map() { - const first_map = document.querySelector("[data-pre-init=map]"); +function sqlpage_map(root: InitRoot) { + const maps = select_all(root, "[data-pre-init=map]"); + const first_map = maps[0]; + if (!is_leaflet_loaded) { + for (const map of maps) pending_maps.add(map); + } const leaflet_base_url = "https://cdn.jsdelivr.net/npm/leaflet@1.9.4"; if (first_map && !is_leaflet_injected) { // Add the leaflet js and css to the page @@ -217,12 +225,19 @@ function sqlpage_map() { "sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="; leaflet_js.crossOrigin = "anonymous"; leaflet_js.nonce = nonce; - leaflet_js.onload = onLeafletLoad; + // Preserve each announced map, including maps arriving during loading. + leaflet_js.onload = () => { + is_leaflet_loaded = true; + for (const map of pending_maps) { + if (map.isConnected) onLeafletLoad(map); + } + pending_maps.clear(); + }; document.head.appendChild(leaflet_js); is_leaflet_injected = true; } if (first_map && is_leaflet_loaded) { - onLeafletLoad(); + onLeafletLoad(root); } function parseCoords( coords: string | undefined, @@ -237,9 +252,9 @@ function sqlpage_map() { } return [parsed[0], parsed[1]]; } - function onLeafletLoad() { + function onLeafletLoad(map_root: InitRoot) { is_leaflet_loaded = true; - const maps = document.querySelectorAll("[data-pre-init=map]"); + const maps = select_all(map_root, "[data-pre-init=map]"); for (const m of maps) { const tile_source = m.dataset.tile_source; const maxZoom = Number(m.dataset.max_zoom); @@ -344,11 +359,17 @@ function sqlpage_map() { } } -function sqlpage_form() { - const file_inputs = document.querySelectorAll( +const initialized_file_inputs = new WeakSet(); +const initialized_auto_submit_forms = new WeakSet(); + +function sqlpage_form(root: InitRoot) { + const file_inputs = select_all( + root, "input[type=file][data-max-size]", ); for (const input of file_inputs) { + if (initialized_file_inputs.has(input)) continue; + initialized_file_inputs.add(input); const max_size = Number(input.dataset.maxSize); input.addEventListener("change", () => { input.classList.remove("is-invalid"); @@ -364,10 +385,13 @@ function sqlpage_form() { }); } - const auto_submit_forms = document.querySelectorAll( + const auto_submit_forms = select_all( + root, "form[data-auto-submit]", ); for (const form of auto_submit_forms) { + if (initialized_auto_submit_forms.has(form)) continue; + initialized_auto_submit_forms.add(form); form.addEventListener("change", () => form.submit()); } } @@ -378,17 +402,22 @@ function get_tabler_color(name: string) { ); } -function load_scripts() { - const addjs = document.querySelectorAll("[data-sqlpage-js]"); - const existing_scripts = new Set( - [...document.querySelectorAll("script")].map((s) => s.src), +function load_scripts(root: InitRoot) { + const addjs = select_all(root, "[data-sqlpage-js]"); + const existing_scripts = new Map( + [...document.querySelectorAll("script")].map((s) => [s.src, s]), ); 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); - const script = document.createElement("script"); + const existing = existing_scripts.get(js); + if (existing) { + existing.sqlpage_init_roots?.add(el); + continue; + } + const script: InitScript = document.createElement("script"); + script.sqlpage_init_roots = new Set([el]); + existing_scripts.set(js, script); script.src = js; document.head.appendChild(script); } @@ -429,13 +458,11 @@ function restore_focus_after_toast(toast: HTMLElement, container: HTMLElement) { main.focus({ preventScroll: true }); } -function sqlpage_toast() { +function sqlpage_toast(root: InitRoot) { const Toast = page_bootstrap().Toast; const initialized_toasts: HTMLElement[] = []; - const toasts = document.querySelectorAll( - '[data-pre-init="toast"]', - ); + const toasts = select_all(root, '[data-pre-init="toast"]'); for (const toast of toasts) { const source_container = toast.parentElement; if (!source_container) continue; @@ -479,7 +506,7 @@ function sqlpage_toast() { open_toasts_for_hash(initialized_toasts); } -function sqlpage_modal() { +function sqlpage_modal(root: InitRoot) { // Bootstrap modals use position: fixed and are documented to live as // direct children of // (https://getbootstrap.com/docs/5.3/components/modal/#how-it-works). @@ -488,8 +515,11 @@ function sqlpage_modal() { // .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 document.querySelectorAll("body .page .modal")) { + for (const modal of select_all(root, "body .page .modal")) { 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. + modal.dispatchEvent(new CustomEvent("fragment-loaded", { bubbles: true })); } } @@ -506,34 +536,32 @@ window.addEventListener("hashchange", () => ), ); -function init_bootstrap_components(fragment: Element | Document) { +function init_bootstrap_components(root: InitRoot) { const bootstrap = page_bootstrap(); - for (const el of fragment.querySelectorAll( + for (const el of select_all( + root, '[data-bs-toggle="tooltip"]', )) { - new bootstrap.Tooltip(el); + bootstrap.Tooltip.getOrCreateInstance(el); } - for (const el of fragment.querySelectorAll( + for (const el of select_all( + root, '[data-bs-toggle="popover"]', )) { - new bootstrap.Popover(el); + bootstrap.Popover.getOrCreateInstance(el); } - for (const el of fragment.querySelectorAll( + for (const el of select_all( + root, '[data-bs-toggle="dropdown"]', )) { - new bootstrap.Dropdown(el); + bootstrap.Dropdown.getOrCreateInstance(el); } - for (const el of fragment.querySelectorAll( - '[data-bs-ride="carousel"]', - )) { - new bootstrap.Carousel(el); + for (const el of select_all(root, '[data-bs-ride="carousel"]')) { + bootstrap.Carousel.getOrCreateInstance(el); } } -document.addEventListener("fragment-loaded", ({ target }) => { - if (target instanceof Element || target instanceof Document) - init_bootstrap_components(target); -}); +add_init_fn(init_bootstrap_components); function open_modal_for_hash() { const hash = window.location.hash.substring(1); diff --git a/frontend/src/tomselect.ts b/frontend/src/tomselect.ts index a7ceaacfe..21b01bc16 100644 --- a/frontend/src/tomselect.ts +++ b/frontend/src/tomselect.ts @@ -1,8 +1,9 @@ import TomSelect from "tom-select/popular"; -import { add_init_fn } from "./init.ts"; +import { add_init_fn, type InitRoot, select_all } from "./init.ts"; -function sqlpage_select_dropdown() { - const selects = document.querySelectorAll( +function sqlpage_select_dropdown(root: InitRoot) { + const selects = select_all( + root, "[data-pre-init=select-dropdown]", ); for (const s of selects) { diff --git a/tests/end-to-end/component-assertions.ts b/tests/end-to-end/component-assertions.ts new file mode 100644 index 000000000..4a4350fea --- /dev/null +++ b/tests/end-to-end/component-assertions.ts @@ -0,0 +1,245 @@ +import { expect, type Locator, type Page } from "@playwright/test"; + +// Shared assertions keep documentation page smokes and component fragments aligned. +export async function checkToastNotifications(page: Page) { + const automatic = page.locator("#toast-auto"); + await expect(automatic).toBeVisible(); + await expect(automatic).toHaveAttribute("data-bs-delay", "5000"); + await expect(automatic).toHaveAttribute("data-bs-autohide", "true"); + await expect( + automatic.getByRole("button", { name: "Close notification" }), + ).toBeVisible(); + await expect(page.locator(".toast.show")).toHaveCount(1); + const automaticHandle = await automatic.elementHandle(); + await automatic.getByRole("button", { name: "Close notification" }).click(); + await expect(automatic).toBeHidden(); + await expect(page.locator("main")).toBeFocused(); + + const stackOne = page.locator("#toast-stack-one"); + const stackTwo = page.locator("#toast-stack-two"); + await expect(stackOne).toBeHidden(); + await page.evaluate(() => { + document.addEventListener("shown.bs.toast", (event) => { + const toast = event.target as HTMLElement; + toast.dataset.shownCount = String( + Number(toast.dataset.shownCount ?? 0) + 1, + ); + }); + }); + await page.getByRole("button", { name: "Show queued notifications" }).click(); + await expect(stackOne).toBeVisible(); + await expect(stackTwo).toBeVisible(); + await expect(stackOne).toHaveAttribute("data-shown-count", "1"); + await expect(stackTwo).toHaveAttribute("data-shown-count", "1"); + await expect(page.locator("#toast-short")).toHaveAttribute( + "data-shown-count", + "1", + ); + await page.evaluate(() => + document.dispatchEvent(new CustomEvent("fragment-loaded")), + ); + await page.waitForTimeout(250); + await expect(page.locator("#toast-short")).toHaveAttribute( + "data-shown-count", + "1", + ); + expect(decodeURIComponent(new URL(page.url()).hash)).toBe( + "#queued notifications", + ); + const stackContainer = stackOne.locator("xpath=.."); + await expect(stackContainer).toHaveAttribute( + "data-sqlpage-toast-position", + "top-end", + ); + await expect(stackContainer).toHaveClass(/\bmh-100\b/); + await expect(stackContainer).toHaveClass(/\boverflow-auto\b/); + expect( + await stackContainer.evaluate( + (container) => container.parentElement === document.body, + ), + ).toBe(true); + expect( + await stackTwo + .locator("xpath=..") + .getAttribute("data-sqlpage-toast-position"), + ).toBe("top-end"); + const firstBox = await stackOne.boundingBox(); + const secondBox = await stackTwo.boundingBox(); + expect(firstBox).not.toBeNull(); + expect(secondBox).not.toBeNull(); + expect(secondBox?.y).toBeGreaterThanOrEqual( + (firstBox?.y ?? 0) + (firstBox?.height ?? 0), + ); + + const temporary = page.locator("#toast-short"); + await expect(temporary).toHaveAttribute("data-bs-delay", "2000"); + await expect(temporary).toBeVisible(); + await expect(temporary).toBeHidden({ timeout: 5000 }); + await expect(stackOne).toBeVisible(); + await expect(automatic).toBeHidden({ timeout: 7000 }); + expect( + await automaticHandle?.evaluate((element) => element.isConnected), + ).toBe(false); + + const dismissible = page.locator("#toast-dismissible"); + await expect(dismissible).toHaveAttribute("data-bs-delay", "0"); + await expect(dismissible).toHaveAttribute("data-bs-autohide", "false"); + await page.getByRole("button", { name: "Show dismissible error" }).click(); + const closeButton = dismissible.getByRole("button", { + name: "Close notification", + }); + await expect(closeButton).toBeVisible(); + const closeStyle = await closeButton.evaluate((button) => { + const style = getComputedStyle(button); + const toastStyle = getComputedStyle( + button.closest(".toast") as HTMLElement, + ); + return { + backgroundColor: style.backgroundColor, + color: style.color, + filter: style.filter, + maskImage: style.maskImage, + toastColor: toastStyle.color, + }; + }); + expect(closeStyle.filter).toBe("none"); + expect(closeStyle.maskImage).not.toBe("none"); + expect(closeStyle.color).toBe(closeStyle.toastColor); + expect(closeStyle.backgroundColor).toBe(closeStyle.toastColor); + await page.evaluate(() => { + window.history.replaceState({ toastTest: true }, "", window.location.href); + }); + await dismissible.getByRole("button", { name: "Close notification" }).click(); + await expect(dismissible).toBeHidden(); + expect(await page.evaluate(() => window.history.state)).toEqual({ + toastTest: true, + }); + await page.getByRole("button", { name: "Show dismissible error" }).click(); + await expect(dismissible).toBeVisible(); + await dismissible.getByRole("button", { name: "Close notification" }).click(); + await page + .getByRole("button", { name: "Show non-dismissible status" }) + .click(); + await expect( + page.locator("#toast-nondismissible").getByRole("button"), + ).toHaveCount(0); + + await page.getByRole("button", { name: "Show rich notifications" }).click(); + await expect(page.locator("#toast-markdown strong")).toHaveText("2.0"); + await expect(page.locator("#toast-markdown a")).toHaveAttribute( + "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, + })); + 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( + "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), + }; + }); + expect(whiteToastStyle.backgroundColor).toEqual([255, 255, 255, 255]); + expect(whiteToastStyle.color).toEqual([31, 41, 55, 255]); + expect(whiteToastStyle.closeColor).toEqual(whiteToastStyle.color); + + const bottomContainer = page.locator( + '[data-sqlpage-toast-position="bottom-center"]', + ); + await page.getByRole("button", { name: "Show bottom notification" }).click(); + await expect(page.locator("#toast-bottom-center")).toBeVisible(); + await expect(bottomContainer).toHaveClass(/\bbottom-0\b/); + await expect(bottomContainer).toHaveClass(/\bstart-50\b/); + await expect(bottomContainer).toHaveClass(/\btranslate-middle-x\b/); +} + +export async function checkTableFiltering(page: Page, fragment = false) { + const tableSection = page.locator(".card", { + has: page.getByRole("cell", { name: "Chart", exact: true }), + }); + + if (fragment) { + await tableSection + .locator(".card-body") + .dispatchEvent("fragment-loaded", { bubbles: true }); + await expect(page.locator('[data-pre-init="table"]')).not.toHaveCount(0); + } + const searchInput = tableSection.getByPlaceholder("Search…"); + await searchInput.fill("chart"); + const chartCell = tableSection.getByRole("cell", { name: "Chart" }); + await expect(chartCell).toBeVisible(); + await expect(chartCell).toHaveClass(/\b_col_name\b/); + await expect(chartCell).toHaveCSS("vertical-align", "middle"); + await expect( + tableSection.getByRole("cell", { name: "Table" }), + ).not.toBeVisible(); +} + +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); + +export async function checkTableSort( + page: Page, + column: "id" | "Amount in stock", + reverse = false, +) { + const table = page.locator(".table-responsive", { + has: page.getByRole("cell", { name: "31456" }), + }); + await table.getByRole("button", { name: column }).click(); + if (reverse) await table.getByRole("button", { name: column }).click(); + const values = await numbersInColumn( + table, + column === "id" ? "td._col_id" : "td._col_Amount_in_stock", + ); + expect(values).toEqual( + reverse ? ascending(values).reverse() : ascending(values), + ); +} + +export async function checkModal(page: Page) { + await expect(page.locator("body > #my_modal")).toBeAttached(); + await expect(page.locator("body > #my_embed_form_modal")).toBeAttached(); + 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(); + + await page.keyboard.press("Escape"); + await expect(modal).not.toBeVisible(); + + await openButton.click(); + await expect(modal).toBeVisible(); + await modal.getByRole("button", { name: "Close" }).first().click(); + await expect(modal).not.toBeVisible(); +} diff --git a/tests/end-to-end/fixture.ts b/tests/end-to-end/fixture.ts index c6addb1c0..5e754b37f 100644 --- a/tests/end-to-end/fixture.ts +++ b/tests/end-to-end/fixture.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import { test as base, expect } from "@playwright/test"; +import { test as base, expect, type Page } from "@playwright/test"; const fixturesDirectory = path.resolve(import.meta.dirname, "fixtures"); @@ -32,3 +32,36 @@ export const test = base.extend({ export type { Page } from "@playwright/test"; export { expect }; + +/** Insert server-rendered SQL output, then announce it like a custom fragment consumer. */ +export async function loadFragment( + page: Page, + url: string, + root: string | null = "main", + parent = "main", +) { + await page.evaluate( + async ({ url, root, parent }) => { + const request = new URL(url, location.href); + request.searchParams.set("_sqlpage_embed", "1"); + const response = await fetch(request); + if (!response.ok) + throw new Error(`Fragment request failed: ${response.status}`); + const fragment = document.createElement("template"); + fragment.innerHTML = await response.text(); + const destination = document.querySelector(parent); + if (!destination) + throw new Error(`Missing fragment destination: ${parent}`); + destination.append(fragment.content); + if (root) { + const target = + root === "document" ? document : document.querySelector(root); + if (!target) throw new Error(`Missing fragment root: ${root}`); + target.dispatchEvent( + new CustomEvent("fragment-loaded", { bubbles: true }), + ); + } + }, + { url, root, parent }, + ); +} diff --git a/tests/end-to-end/fixtures/facet/index.sql b/tests/end-to-end/fixtures/facet/index.sql new file mode 100644 index 000000000..2a1e36f7a --- /dev/null +++ b/tests/end-to-end/fixtures/facet/index.sql @@ -0,0 +1,2 @@ +SELECT 'facet' AS component, TRUE AS compact, 'Constitution' AS dropdown_title; +SELECT 'One' AS title, '#one' AS link; diff --git a/tests/end-to-end/fixtures/facet/test.ts b/tests/end-to-end/fixtures/facet/test.ts new file mode 100644 index 000000000..ca26a1a0e --- /dev/null +++ b/tests/end-to-end/fixtures/facet/test.ts @@ -0,0 +1,19 @@ +import { expect, test } from "../../fixture.ts"; + +test("fragment dropdown keeps one Bootstrap instance after repeated events", async ({ + page, +}) => { + await page + .locator("main") + .dispatchEvent("fragment-loaded", { bubbles: true }); + await page.evaluate(() => + document.dispatchEvent(new CustomEvent("fragment-loaded")), + ); + const toggle = page + .locator('[data-bs-toggle="dropdown"]') + .filter({ hasText: "Constitution" }); + await toggle.click(); + await expect(page.locator(".dropdown-menu.show")).toBeVisible(); + await toggle.click(); + await expect(page.locator(".dropdown-menu.show")).toHaveCount(0); +}); diff --git a/tests/end-to-end/fixtures/form/index.sql b/tests/end-to-end/fixtures/form/index.sql index 441647a02..43e993a05 100644 --- a/tests/end-to-end/fixtures/form/index.sql +++ b/tests/end-to-end/fixtures/form/index.sql @@ -1,7 +1,7 @@ -- Exercise every form layout that renders a field description: -- standard controls, the legacy checkbox/radio controls, and switches. SELECT - 'form' AS component, + 'form' AS component, $id AS id, $fragment AS auto_submit, 'Form description markdown' AS title, '' AS validate; @@ -19,7 +19,7 @@ SELECT SELECT 'modern_select' AS name, 'Modern select' AS label, - 'select' AS type, + 'select' AS type, $fragment AS searchable, '[{"label":"Option","value":"option"}]' AS options, '**Bold select** and *italic select*.' AS description_md; @@ -43,3 +43,6 @@ SELECT 'switch' AS type, 'switch' AS value, '**Bold switch** and *italic switch*.' AS description_md; + +-- The lifecycle suite reuses this form with searchable and upload fields. +SELECT 'upload' AS name, 'Upload' AS label, 'file' AS type WHERE $fragment; diff --git a/tests/end-to-end/fixtures/fragment-loaded/index.sql b/tests/end-to-end/fixtures/fragment-loaded/index.sql index 59b04c74a..4dcde2a37 100644 --- a/tests/end-to-end/fixtures/fragment-loaded/index.sql +++ b/tests/end-to-end/fixtures/fragment-loaded/index.sql @@ -1 +1,6 @@ -SELECT 'text' AS component, 'Markup added after load is initialized when a fragment is announced.' AS contents; +SELECT 'dynamic' AS component, + sqlpage.run_sql('form/index.sql', '{"fragment":"1","id":"existing-form"}') AS properties WHERE $lazy IS NULL; +SELECT 'card' AS component WHERE $lazy IS NULL; +SELECT 'Embedded form' AS title, '/form/?fragment=1&id=first-form' AS embed WHERE $lazy IS NULL; +SELECT 'Embedded chart' AS title, '/chart/' AS embed WHERE $lazy IS NULL; +SELECT 'text' AS component, 'Fragment lifecycle' AS contents WHERE $lazy IS NOT NULL; diff --git a/tests/end-to-end/fixtures/fragment-loaded/modal.sql b/tests/end-to-end/fixtures/fragment-loaded/modal.sql new file mode 100644 index 000000000..e8e068163 --- /dev/null +++ b/tests/end-to-end/fixtures/fragment-loaded/modal.sql @@ -0,0 +1,2 @@ +SELECT 'modal' AS component, 'nested-modal' AS id, 'Nested widgets' AS title WHERE $notification IS NULL; +SELECT 'toast' AS component, 'nested-toast' AS id, 'Nested notification' AS title, 0 AS duration WHERE $notification IS NOT NULL; diff --git a/tests/end-to-end/fixtures/fragment-loaded/test.ts b/tests/end-to-end/fixtures/fragment-loaded/test.ts index b82c3298e..f56f19520 100644 --- a/tests/end-to-end/fixtures/fragment-loaded/test.ts +++ b/tests/end-to-end/fixtures/fragment-loaded/test.ts @@ -1,40 +1,223 @@ -import { expect, type Page, test } from "../../fixture.ts"; - -const INJECTED_HINT = "injected hint"; - -async function addTooltip(page: Page) { - await page.evaluate((hint) => { - const span = document.createElement("span"); - span.id = "added"; - span.textContent = "added"; - span.setAttribute("data-bs-toggle", "tooltip"); - span.setAttribute("title", hint); - document.querySelector("main")?.appendChild(span); - }, INJECTED_HINT); -} +import { expect, loadFragment, type Page, test } from "../../fixture.ts"; -test("shows a tooltip added before the document announces a fragment", async ({ - page, -}) => { - await addTooltip(page); - await page.evaluate(() => +const rootForm = "/form/?fragment=1&id=root-form"; +const announceDocument = (page: Page) => + page.evaluate(() => document.dispatchEvent(new CustomEvent("fragment-loaded")), ); - await page.locator("#added").hover(); - await expect(page.locator(".tooltip")).toHaveText(INJECTED_HINT); +async function trackHandlers(page: Page, selector: string) { + await page.locator(selector).evaluate((element) => { + const form = element as HTMLFormElement; + form.dataset.submissions = "0"; + form.submit = () => { + form.dataset.submissions = String(Number(form.dataset.submissions) + 1); + }; + const input = form.querySelector('input[type="file"]'); + if (!input) throw new Error("Missing file input"); + const setValidity = input.setCustomValidity.bind(input); + input.dataset.validations = "0"; + input.setCustomValidity = (message) => { + input.dataset.validations = String(Number(input.dataset.validations) + 1); + setValidity(message); + }; + }); +} + +// Card fetches and their chart/select scripts must finish the initial document pass. +test.beforeEach(async ({ page }) => { + await expect(page.locator(".apexcharts-canvas")).toHaveCount(1); + await expect(page.locator(".ts-wrapper")).toHaveCount(2); }); -test("shows a tooltip added before an element announces a fragment", async ({ +for (const root of ["document", "main", "#added"]) { + test(`initializes a tooltip when ${root} announces a fragment`, async ({ + page, + }) => { + await loadFragment(page, "/fragment-loaded/tooltip.sql", root); + await page.locator("#added").hover(); + await expect(page.locator(".tooltip")).toHaveText("injected hint"); + await announceDocument(page); + await expect(page.locator(".tooltip")).toHaveText("injected hint"); + await page.getByRole("heading", { name: "Embedded form" }).hover(); + await expect(page.locator(".tooltip")).toHaveCount(0); + }); +} + +test("card and document events keep existing and embedded form handlers singular", async ({ page, }) => { - await addTooltip(page); - await page.evaluate(() => - document - .querySelector("main") - ?.dispatchEvent(new CustomEvent("fragment-loaded", { bubbles: true })), - ); - await page.locator("#added").hover(); + await trackHandlers(page, "#existing-form"); + await trackHandlers(page, "#first-form"); + const existing = page.locator("#existing-form"); + await existing.getByLabel("Modern text field").fill("before repeated events"); + await existing.getByLabel("Modern text field").blur(); + await expect(existing).toHaveAttribute("data-submissions", "1"); + for (let i = 0; i < 3; i++) await announceDocument(page); + const upload = existing.getByLabel("Upload", { exact: true }); + await upload.setInputFiles({ + name: "small.txt", + mimeType: "text/plain", + buffer: Buffer.from("small"), + }); + await expect(upload).toHaveAttribute("data-validations", "1"); + await expect(existing).toHaveAttribute("data-submissions", "2"); + const embedded = page.locator("#first-form"); + await embedded.getByLabel("Modern text field").fill("changed"); + await embedded.getByLabel("Modern text field").blur(); + await expect(embedded).toHaveAttribute("data-submissions", "1"); +}); + +for (const root of ["#root-form", '#root-form input[type="file"]']) { + test(`initializes ${root} without adding handlers outside the root`, async ({ + page, + }) => { + await loadFragment(page, rootForm, root); + await trackHandlers(page, "#root-form"); + const form = page.locator("#root-form"); + const isForm = root === "#root-form"; + await expect(form.locator(".ts-wrapper")).toHaveCount(isForm ? 1 : 0); + await form.getByLabel("Modern text field").fill("changed"); + await form.getByLabel("Modern text field").blur(); + await expect(form).toHaveAttribute("data-submissions", isForm ? "1" : "0"); + const upload = form.getByLabel("Upload", { exact: true }); + await upload.setInputFiles({ + name: "small.txt", + mimeType: "text/plain", + buffer: Buffer.from("small"), + }); + await expect(form).toHaveAttribute("data-submissions", isForm ? "2" : "0"); + const maxSize = Number(await upload.getAttribute("data-max-size")); + await upload.setInputFiles({ + name: "large.txt", + mimeType: "text/plain", + buffer: Buffer.alloc(maxSize + 1), + }); + await expect(upload).toHaveClass(/is-invalid/); + await expect(upload).toHaveJSProperty( + "validationMessage", + `File size must be less than ${maxSize / 1000} kB.`, + ); + }); +} - await expect(page.locator(".tooltip")).toHaveText(INJECTED_HINT); +test("chart and select roots leave unrelated fragments pending", async ({ + page, +}) => { + await loadFragment(page, rootForm, null); + await loadFragment(page, "/chart/", "main > #test-chart"); + await expect( + page.locator("main > #test-chart .apexcharts-canvas"), + ).toBeAttached(); + const select = page.locator("#root-form select"); + await expect(select).toHaveAttribute("data-pre-init", "select-dropdown"); + await select.dispatchEvent("fragment-loaded", { bubbles: true }); + await expect(page.locator("#root-form .ts-wrapper")).toBeAttached(); +}); + +test("map roots initialize before and after their shared dependency loads", async ({ + page, +}) => { + let releaseLeaflet = () => {}; + const dependencyReady = new Promise((resolve) => { + releaseLeaflet = resolve; + }); + await page.route("**/dist/leaflet.js", async (route) => { + await dependencyReady; + await route.continue(); + }); + await loadFragment(page, "/map/", null); + const pending = page.locator("main > .card > .card-body > .leaflet").first(); + const root = "main > .card:last-child .leaflet"; + try { + for (let i = 0; i < 2; i++) await loadFragment(page, "/map/", root); + } finally { + releaseLeaflet(); + } + await expect(page.locator(".leaflet-map-pane")).toHaveCount(2); + await expect(pending).toHaveAttribute("data-pre-init", "map"); + await loadFragment(page, "/map/", root); + await expect(page.locator(".leaflet-map-pane")).toHaveCount(3); + await page.locator(root).dispatchEvent("fragment-loaded", { bubbles: true }); + await expect(page.locator(".leaflet-map-pane")).toHaveCount(3); + await expect(pending).toHaveAttribute("data-pre-init", "map"); + await pending.dispatchEvent("fragment-loaded", { bubbles: true }); + await expect(page.locator(".leaflet-map-pane")).toHaveCount(4); }); + +for (const bundle of ["apexcharts", "tomselect"]) { + test(`${bundle} initializes only announced roots while its bundle loads`, async ({ + page, + }) => { + await page.goto("/fragment-loaded/?lazy=1"); + const select = bundle === "tomselect"; + const url = select ? "/form/?fragment=1" : "/chart/"; + const component = select ? "form" : "[data-sqlpage-js]"; + const ready = select ? ".ts-wrapper" : ".apexcharts-canvas"; + await loadFragment(page, url, null); + const pending = page.locator(`main > ${component}`).first(); + let releaseBundle = () => {}; + const gate = new Promise((resolve) => { + releaseBundle = resolve; + }); + await page.route(`**/${bundle}.*.js`, async (route) => { + await gate; + await route.continue(); + }); + try { + for (let i = 0; i < 2; i++) + await loadFragment(page, url, `main > ${component}:last-child`); + } finally { + releaseBundle(); + } + await expect(page.locator(ready)).toHaveCount(2); + await expect(select ? pending.locator("select") : pending).toHaveAttribute( + "data-pre-init", + select ? "select-dropdown" : "chart", + ); + await pending.dispatchEvent("fragment-loaded", { bubbles: true }); + await expect(page.locator(ready)).toHaveCount(3); + }); +} + +for (const lazy of [false, true]) { + test(`relocated modals initialize their contents with ${lazy ? "lazy" : "loaded"} bundles`, async ({ + page, + }) => { + if (lazy) await page.goto("/fragment-loaded/?lazy=1"); + await loadFragment(page, "/fragment-loaded/modal.sql", null); + const parent = "#nested-modal .modal-body"; + for (const url of [ + rootForm, + "/chart/", + "/fragment-loaded/tooltip.sql", + "/facet/", + "/fragment-loaded/modal.sql?notification=1", + ]) { + await loadFragment(page, url, null, parent); + } + await page + .locator("main") + .dispatchEvent("fragment-loaded", { bubbles: true }); + await expect(page.locator("body > #nested-modal")).toBeAttached(); + await expect(page.locator("#nested-modal .ts-wrapper")).toBeAttached(); + await expect( + page.locator("#nested-modal .apexcharts-canvas"), + ).toBeAttached(); + await expect(page.locator("#nested-toast")).toBeVisible(); + await page.evaluate(() => { + window.location.hash = "nested-modal"; + }); + await expect(page.getByRole("dialog")).toBeVisible(); + await page.locator("#added").hover(); + await expect(page.locator(".tooltip")).toHaveText("injected hint"); + await page + .getByRole("button", { name: "Constitution", exact: true }) + .click(); + await expect(page.locator(".dropdown-menu.show")).toBeVisible(); + await page + .getByRole("button", { name: "Constitution", exact: true }) + .click(); + await expect(page.locator(".dropdown-menu.show")).toHaveCount(0); + }); +} diff --git a/tests/end-to-end/fixtures/fragment-loaded/tooltip.sql b/tests/end-to-end/fixtures/fragment-loaded/tooltip.sql new file mode 100644 index 000000000..bf07e821b --- /dev/null +++ b/tests/end-to-end/fixtures/fragment-loaded/tooltip.sql @@ -0,0 +1,2 @@ +SELECT 'html' AS component; +SELECT 'added' AS html; diff --git a/tests/end-to-end/fixtures/modal/index.sql b/tests/end-to-end/fixtures/modal/index.sql new file mode 100644 index 000000000..8cb6e6e2f --- /dev/null +++ b/tests/end-to-end/fixtures/modal/index.sql @@ -0,0 +1,11 @@ +SELECT 'text' AS component, 'Component lifecycle' AS contents WHERE $fragment IS NULL; + +-- The simple modal and embedded form from 63_modal.sql, reusing the form fixture. +SELECT 'dynamic' AS component, JSON('[ + {"component":"modal","id":"my_modal","title":"A modal box","close":"Close"}, + {"contents":"I''m a modal window, and I allow you to display additional information or help for the user."}, + {"component":"button"}, + {"title":"Open a simple modal","link":"#my_modal"}, + {"component":"modal","id":"my_embed_form_modal","title":"Embeded form content","large":true,"embed":"/form/?_sqlpage_embed=1"} +]') AS properties +WHERE $fragment IS NOT NULL; diff --git a/tests/end-to-end/fixtures/modal/test.ts b/tests/end-to-end/fixtures/modal/test.ts new file mode 100644 index 000000000..02870bf85 --- /dev/null +++ b/tests/end-to-end/fixtures/modal/test.ts @@ -0,0 +1,7 @@ +import { checkModal } from "../../component-assertions.ts"; +import { loadFragment, test } from "../../fixture.ts"; + +test("modal (fragment)", async ({ page }) => { + await loadFragment(page, "/modal/?fragment=1"); + await checkModal(page); +}); diff --git a/tests/end-to-end/fixtures/table/index.sql b/tests/end-to-end/fixtures/table/index.sql new file mode 100644 index 000000000..1eee9d203 --- /dev/null +++ b/tests/end-to-end/fixtures/table/index.sql @@ -0,0 +1,13 @@ +SELECT 'text' AS component, 'Component lifecycle' AS contents WHERE $fragment IS NULL; + +-- Only the filtering and numeric sorting examples needed from 01_documentation.sql. +SELECT 'dynamic' AS component, JSON('[ + {"component":"table","markdown":"Name","icon":"icon","search":true}, + {"icon":"table","name":"[Table](?component=table)","description":"Displays SQL results as a searchable table.","_sqlpage_color":"red"}, + {"icon":"timeline","name":"[Chart](?component=chart)","description":"Show graphs based on numeric data."}, + {"component":"table","sort":true,"align_right":["Price","Amount in stock"],"align_center":["part_no"],"raw_numbers":["id"],"currency":"USD","money":["Price"]}, + {"id":31456,"part_no":"SQL-TABLE-856-G","Price":12,"Amount in stock":5}, + {"id":996,"part_no":"SQL-FORMS-86-M","Price":1,"Amount in stock":1234}, + {"id":131456,"part_no":"SQL-CARDS-56-K","Price":127,"Amount in stock":98} +]') AS properties +WHERE $fragment IS NOT NULL; diff --git a/tests/end-to-end/fixtures/table/test.ts b/tests/end-to-end/fixtures/table/test.ts new file mode 100644 index 000000000..028555500 --- /dev/null +++ b/tests/end-to-end/fixtures/table/test.ts @@ -0,0 +1,25 @@ +import { + checkTableFiltering, + checkTableSort, +} from "../../component-assertions.ts"; +import { loadFragment, test } from "../../fixture.ts"; + +test("table filtering (fragment)", async ({ page }) => { + await loadFragment(page, "/table/?fragment=1", null); + await checkTableFiltering(page, true); +}); + +for (const [name, column, reverse] of [ + ["table sorts a column when its header is clicked", "id", false], + ["table reverses the sort when the header is clicked again", "id", true], + [ + "table sorts a column of formatted numbers by value", + "Amount in stock", + false, + ], +] as const) { + test(`${name} (fragment)`, async ({ page }) => { + await loadFragment(page, "/table/?fragment=1", "document"); + await checkTableSort(page, column, reverse); + }); +} diff --git a/tests/end-to-end/fixtures/toast/index.sql b/tests/end-to-end/fixtures/toast/index.sql new file mode 100644 index 000000000..4623f7445 --- /dev/null +++ b/tests/end-to-end/fixtures/toast/index.sql @@ -0,0 +1,24 @@ +SELECT 'text' AS component, 'Component lifecycle' AS contents WHERE $fragment IS NULL; + +-- Properties exercised by the toast documentation smoke assertions (76_toast.sql). +SELECT 'dynamic' AS component, JSON('[ + {"component":"toast","id":"toast-auto","title":"This is a SQLPage toast","description":"This toast will open automatically when the page loads.","icon":"check","color":"green"}, + {"component":"toast","id":"toast-dismissible","trigger":"persistent-error","title":"Could not save","description":"Review the highlighted fields and try again.","icon":"alert-triangle","color":"black","duration":0,"dismissible":true}, + {"component":"toast","id":"toast-nondismissible","trigger":"persistent-status","title":"Connection unavailable","description":"This persistent notification has no manual close control.","duration":0,"dismissible":false}, + {"component":"button"}, + {"title":"Show dismissible error","link":"#persistent-error","color":"red"}, + {"title":"Show non-dismissible status","link":"#persistent-status"}, + {"component":"toast","id":"toast-markdown","trigger":"rich-notifications","title":"Release available","description":"This fallback stays escaped","description_md":"Version **2.0** is ready. [Read the notes](https://example.com/releases).","color":"blue","duration":0}, + {"component":"toast","id":"toast-plain","trigger":"rich-notifications","description":"Plain text stays escaped","color":"white","duration":0}, + {"component":"button"}, + {"title":"Show rich notifications","link":"#rich-notifications"}, + {"component":"toast","id":"toast-stack-one","trigger":"queued notifications","title":"Import started","description":"Preparing records.","duration":0}, + {"component":"toast","id":"toast-stack-two","trigger":"queued notifications","title":"Import running","description":"Processing records.","duration":0}, + {"component":"toast","id":"toast-short","trigger":"queued notifications","title":"Temporary update","description":"This message closes shortly.","duration":2000}, + {"component":"button"}, + {"title":"Show queued notifications","link":"#queued notifications"}, + {"component":"toast","id":"toast-bottom-center","trigger":"bottom-notification","title":"Download ready","description":"Your export is ready.","position":"bottom-center","duration":0}, + {"component":"button"}, + {"title":"Show bottom notification","link":"#bottom-notification"} +]') AS properties +WHERE $fragment IS NOT NULL; diff --git a/tests/end-to-end/fixtures/toast/test.ts b/tests/end-to-end/fixtures/toast/test.ts new file mode 100644 index 000000000..fc97f850c --- /dev/null +++ b/tests/end-to-end/fixtures/toast/test.ts @@ -0,0 +1,9 @@ +import { checkToastNotifications } from "../../component-assertions.ts"; +import { loadFragment, test } from "../../fixture.ts"; + +test("toast notifications initialize, stack, dismiss, and render safely (fragment)", async ({ + page, +}) => { + await loadFragment(page, "/toast/?fragment=1"); + await checkToastNotifications(page); +}); diff --git a/tests/end-to-end/official-site.spec.ts b/tests/end-to-end/official-site.spec.ts index f0d5bb372..8b1a483b7 100644 --- a/tests/end-to-end/official-site.spec.ts +++ b/tests/end-to-end/official-site.spec.ts @@ -1,4 +1,10 @@ import { expect, type Locator, type Page, test } from "@playwright/test"; +import { + checkModal, + checkTableFiltering, + checkTableSort, + checkToastNotifications, +} from "./component-assertions.ts"; test("Open documentation", async ({ page }) => { await page.goto("/"); @@ -139,184 +145,11 @@ test("map", async ({ page }) => { await expect(page.locator(".leaflet-marker-icon").first()).toBeVisible(); }); -test("toast notifications initialize, stack, dismiss, and render safely", async ({ +test("toast notifications initialize, stack, dismiss, and render safely (page)", async ({ page, }) => { await page.goto("/documentation.sql?component=toast#component"); - - const automatic = page.locator("#toast-auto"); - await expect(automatic).toBeVisible(); - await expect(automatic).toHaveAttribute("data-bs-delay", "5000"); - await expect(automatic).toHaveAttribute("data-bs-autohide", "true"); - await expect( - automatic.getByRole("button", { name: "Close notification" }), - ).toBeVisible(); - await expect(page.locator(".toast.show")).toHaveCount(1); - const automaticHandle = await automatic.elementHandle(); - await automatic.getByRole("button", { name: "Close notification" }).click(); - await expect(automatic).toBeHidden(); - await expect(page.locator("main")).toBeFocused(); - - const stackOne = page.locator("#toast-stack-one"); - const stackTwo = page.locator("#toast-stack-two"); - await expect(stackOne).toBeHidden(); - await page.evaluate(() => { - document.addEventListener("shown.bs.toast", (event) => { - const toast = event.target as HTMLElement; - toast.dataset.shownCount = String( - Number(toast.dataset.shownCount ?? 0) + 1, - ); - }); - }); - await page.getByRole("button", { name: "Show queued notifications" }).click(); - await expect(stackOne).toBeVisible(); - await expect(stackTwo).toBeVisible(); - await expect(stackOne).toHaveAttribute("data-shown-count", "1"); - await expect(stackTwo).toHaveAttribute("data-shown-count", "1"); - await expect(page.locator("#toast-short")).toHaveAttribute( - "data-shown-count", - "1", - ); - await page.evaluate(() => - document.dispatchEvent(new CustomEvent("fragment-loaded")), - ); - await page.waitForTimeout(250); - await expect(page.locator("#toast-short")).toHaveAttribute( - "data-shown-count", - "1", - ); - expect(decodeURIComponent(new URL(page.url()).hash)).toBe( - "#queued notifications", - ); - const stackContainer = stackOne.locator("xpath=.."); - await expect(stackContainer).toHaveAttribute( - "data-sqlpage-toast-position", - "top-end", - ); - await expect(stackContainer).toHaveClass(/\bmh-100\b/); - await expect(stackContainer).toHaveClass(/\boverflow-auto\b/); - expect( - await stackContainer.evaluate( - (container) => container.parentElement === document.body, - ), - ).toBe(true); - expect( - await stackTwo - .locator("xpath=..") - .getAttribute("data-sqlpage-toast-position"), - ).toBe("top-end"); - const firstBox = await stackOne.boundingBox(); - const secondBox = await stackTwo.boundingBox(); - expect(firstBox).not.toBeNull(); - expect(secondBox).not.toBeNull(); - expect(secondBox?.y).toBeGreaterThanOrEqual( - (firstBox?.y ?? 0) + (firstBox?.height ?? 0), - ); - - const temporary = page.locator("#toast-short"); - await expect(temporary).toHaveAttribute("data-bs-delay", "2000"); - await expect(temporary).toBeVisible(); - await expect(temporary).toBeHidden({ timeout: 5000 }); - await expect(stackOne).toBeVisible(); - await expect(automatic).toBeHidden({ timeout: 7000 }); - expect( - await automaticHandle?.evaluate((element) => element.isConnected), - ).toBe(false); - - const dismissible = page.locator("#toast-dismissible"); - await expect(dismissible).toHaveAttribute("data-bs-delay", "0"); - await expect(dismissible).toHaveAttribute("data-bs-autohide", "false"); - await page.getByRole("button", { name: "Show dismissible error" }).click(); - const closeButton = dismissible.getByRole("button", { - name: "Close notification", - }); - await expect(closeButton).toBeVisible(); - const closeStyle = await closeButton.evaluate((button) => { - const style = getComputedStyle(button); - const toastStyle = getComputedStyle( - button.closest(".toast") as HTMLElement, - ); - return { - backgroundColor: style.backgroundColor, - color: style.color, - filter: style.filter, - maskImage: style.maskImage, - toastColor: toastStyle.color, - }; - }); - expect(closeStyle.filter).toBe("none"); - expect(closeStyle.maskImage).not.toBe("none"); - expect(closeStyle.color).toBe(closeStyle.toastColor); - expect(closeStyle.backgroundColor).toBe(closeStyle.toastColor); - await page.evaluate(() => { - window.history.replaceState({ toastTest: true }, "", window.location.href); - }); - await dismissible.getByRole("button", { name: "Close notification" }).click(); - await expect(dismissible).toBeHidden(); - expect(await page.evaluate(() => window.history.state)).toEqual({ - toastTest: true, - }); - await page.getByRole("button", { name: "Show dismissible error" }).click(); - await expect(dismissible).toBeVisible(); - await dismissible.getByRole("button", { name: "Close notification" }).click(); - await page - .getByRole("button", { name: "Show non-dismissible status" }) - .click(); - await expect( - page.locator("#toast-nondismissible").getByRole("button"), - ).toHaveCount(0); - - await page.getByRole("button", { name: "Show rich notifications" }).click(); - await expect(page.locator("#toast-markdown strong")).toHaveText("2.0"); - await expect(page.locator("#toast-markdown a")).toHaveAttribute( - "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, - })); - 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( - "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), - }; - }); - expect(whiteToastStyle.backgroundColor).toEqual([255, 255, 255, 255]); - expect(whiteToastStyle.color).toEqual([31, 41, 55, 255]); - expect(whiteToastStyle.closeColor).toEqual(whiteToastStyle.color); - - const bottomContainer = page.locator( - '[data-sqlpage-toast-position="bottom-center"]', - ); - await page.getByRole("button", { name: "Show bottom notification" }).click(); - await expect(page.locator("#toast-bottom-center")).toBeVisible(); - await expect(bottomContainer).toHaveClass(/\bbottom-0\b/); - await expect(bottomContainer).toHaveClass(/\bstart-50\b/); - await expect(bottomContainer).toHaveClass(/\btranslate-middle-x\b/); + await checkToastNotifications(page); }); test("form example", async ({ page }) => { @@ -369,63 +202,30 @@ test("Authentication example", async ({ page }) => { await expect(page.getByText("You are logged in as admin")).toBeVisible(); }); -test("table filtering", async ({ page }) => { +test("table filtering (page)", async ({ page }) => { await page.goto("/documentation.sql?component=table"); - const tableSection = page.locator(".card", { - has: page.getByRole("cell", { name: "Chart", exact: true }), - }); - - const searchInput = tableSection.getByPlaceholder("Search…"); - await searchInput.fill("chart"); - const chartCell = tableSection.getByRole("cell", { name: "Chart" }); - await expect(chartCell).toBeVisible(); - await expect(chartCell).toHaveClass(/\b_col_name\b/); - await expect(chartCell).toHaveCSS("vertical-align", "middle"); - await expect( - tableSection.getByRole("cell", { name: "Table" }), - ).not.toBeVisible(); + await checkTableFiltering(page); }); -const sortableTable = async (page: Page) => { +test("table sorts a column when its header is clicked (page)", async ({ + page, +}) => { await page.goto("/documentation.sql?component=table"); - return page.locator(".table-responsive", { - has: page.getByRole("cell", { name: "31456" }), - }); -}; - -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)); + await checkTableSort(page, "id"); }); -test("table reverses the sort when the header is clicked again", async ({ +test("table reverses the sort when the header is clicked again (page)", 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()); + await page.goto("/documentation.sql?component=table"); + await checkTableSort(page, "id", true); }); -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)); +test("table sorts a column of formatted numbers by value (page)", async ({ + page, +}) => { + await page.goto("/documentation.sql?component=table"); + await checkTableSort(page, "Amount in stock"); }); async function checkNoConsoleErrors(page: Page, component: string) { @@ -628,21 +428,9 @@ test("form type=select searchable=true", async ({ page }) => { await expect(page.getByText(":region = SA", { exact: true })).toBeVisible(); }); -test("modal", async ({ page }) => { +test("modal (page)", async ({ page }) => { await page.goto("/documentation.sql?component=modal#component"); - 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(); - - await page.keyboard.press("Escape"); - await expect(modal).not.toBeVisible(); - - await openButton.click(); - await expect(modal).toBeVisible(); - await modal.getByRole("button", { name: "Close" }).first().click(); - await expect(modal).not.toBeVisible(); + await checkModal(page); }); test("table action buttons - edit_url and delete_url", async ({ page }) => {