diff --git a/docs/docs/developers/embed/postmessage.md b/docs/docs/developers/embed/postmessage.md index c9bbfe0096ef..d8e21f461f9e 100644 --- a/docs/docs/developers/embed/postmessage.md +++ b/docs/docs/developers/embed/postmessage.md @@ -92,6 +92,31 @@ iframe.contentWindow.postMessage({ ``` +### `setValidState({ state, failOnError })` + +Like `setState`, but validates the state against the dashboard's metrics view and explore specs before applying it. Invalid parameters (for example, an unknown dimension or measure) are reported back, and the applied URL is canonicalized (defaults removed, parameters ordered) the same way it would be if the user navigated there directly. The applied state is returned in `appliedState`. Validation is currently supported for explore dashboards; for other dashboard types the state is applied as-is. + +```js +iframe.contentWindow.postMessage({ + id: 1, + method: "setValidState", + params: { state: "view=pivot&tr=PT24H&grain=hour", failOnError: true }, +}, "*"); +``` + +**Parameters:** +- `state` (string): A URL query string describing the dashboard view, filters, time range, etc. Uses the same format as the query strings in URLs on Rill Cloud. +- `failOnError` (boolean, optional): When `false` (the default), the cleaned state is applied even when some parameters were invalid; the dropped parameters are still reported in `errors`. When `true`, the state is applied only if there are no validation errors. + +**Response:** + +```json +{ "id": 1, "result": { "success": true, "appliedState": "view=pivot&tr=PT24H&grain=hour", "errors": [] } } +``` + +`success` is `false` when `failOnError` is `true` and validation produced errors, in which case the state is not applied and `appliedState` is omitted. `appliedState` is the canonicalized query string that was actually applied to the dashboard. `errors` contains a message for each invalid parameter. + + ### `getState()` Returns the iframe's dashboard's current UI state. diff --git a/web-admin/src/features/embeds/init-embed-public-api.spec.ts b/web-admin/src/features/embeds/init-embed-public-api.spec.ts new file mode 100644 index 000000000000..769fed01c79a --- /dev/null +++ b/web-admin/src/features/embeds/init-embed-public-api.spec.ts @@ -0,0 +1,308 @@ +// @vitest-environment jsdom +import { DashboardFetchMocks } from "@rilldata/web-common/features/dashboards/dashboard-fetch-mocks"; +import { + AD_BIDS_EXPLORE_INIT, + AD_BIDS_EXPLORE_NAME, + AD_BIDS_METRICS_INIT, + AD_BIDS_METRICS_NAME, + AD_BIDS_PRESET_WITHOUT_TIMESTAMP, +} from "@rilldata/web-common/features/dashboards/stores/test-data/data"; +import { getKeyForSessionStore } from "@rilldata/web-common/features/dashboards/state-managers/loaders/explore-web-view-store.ts"; +import { ExploreUrlWebView } from "@rilldata/web-common/features/dashboards/url-state/mappers.ts"; +import { queryClient } from "@rilldata/web-common/lib/svelte-query/globalQueryClient"; +import { RuntimeClient } from "@rilldata/web-common/runtime-client/v2"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import initEmbedPublicAPI from "./init-embed-public-api"; +import { EmbedStorageNamespacePrefix } from "./constants.ts"; +import { + EmbedPublicAPIHarness, + type HoistedEmbedPage, +} from "./test/EmbedPublicAPIHarness"; + +// Mirrors STATE_CHANGE_THROTTLE_TIMEOUT in init-embed-public-api.ts (not exported). +const STATE_CHANGE_THROTTLE_TIMEOUT = 200; + +// vi.hoisted runs before the mock factories and before init-embed-public-api is imported. +const { hoistedPage } = vi.hoisted(() => ({ + hoistedPage: {} as HoistedEmbedPage, +})); + +// The SvelteKit page store and navigation cannot be reached through `window`, +// so they remain module-level mocks. Everything RPC-related goes through the +// real transport and is observed via `window.parent.postMessage` in the harness. +vi.mock("$app/navigation", () => ({ + goto: (url: URL, opts?: { replaceState?: boolean }) => + hoistedPage.goto(url, opts), +})); +vi.mock("$app/stores", () => ({ + page: hoistedPage, +})); + +// theme-control reads window.matchMedia at import time and neither theme module is +// relevant to setState/stateChange, so stub them to keep the harness isolated. +vi.mock("@rilldata/web-common/features/themes/theme-control", () => ({ + themeControl: { + set: { light: vi.fn(), dark: vi.fn(), system: vi.fn() }, + preference: { subscribe: () => () => {} }, + }, +})); +vi.mock("@rilldata/web-common/features/embeds/embed-theme", () => ({ + getEmbedThemeStoreInstance: () => ({ + subscribe: () => () => {}, + set: vi.fn(), + }), +})); + +describe("initEmbedPublicAPI", () => { + let harness: EmbedPublicAPIHarness; + let cleanup: () => void; + + const client = new RuntimeClient({ + host: "http://localhost", + instanceId: "test", + }); + + beforeEach(() => { + vi.useFakeTimers(); + + // Construct the harness (mocks window.parent, installs the RPC handler) + // before init so the "ready" and initial notifications are captured. + harness = new EmbedPublicAPIHarness(hoistedPage); + cleanup = initEmbedPublicAPI(client); + }); + + afterEach(() => { + cleanup(); + harness.destroy(); + vi.useRealTimers(); + }); + + it("emits the ready notification during init", () => { + expect(harness.notifications("ready")).toHaveLength(1); + }); + + describe("setState", () => { + it("replaces the url search via replaceState and returns true", async () => { + const response = await harness.call("setState", "a=1&b=2"); + + expect(response.result).toBe(true); + + const last = harness.lastGoto(); + expect(last?.url.pathname).toBe("/-/embed"); + expect(last?.url.search).toBe("?a=1&b=2"); + expect(last?.opts).toEqual({ replaceState: true }); + }); + + it("replaces existing search params rather than merging them", async () => { + harness.navigateTo("existing=value"); + + await harness.call("setState", "a=1"); + + expect(harness.lastGoto()?.url.search).toBe("?a=1"); + }); + + it("clears the search when given an empty string", async () => { + harness.navigateTo("existing=value"); + + await harness.call("setState", ""); + + expect(harness.lastGoto()?.url.search).toBe(""); + }); + + it("returns a JSON-RPC error when state is not a string", async () => { + const response = await harness.call("setState", 123); + + expect(response.result).toBeUndefined(); + expect(response.error?.message).toBe("Expected state to be a string"); + }); + }); + + // setValidState runs the real buildValidatedExploreUrl for explore routes, so the + // runtime GetExplore/metrics fetches are mocked with the AD_BIDS fixtures. See + // DashboardStateManager.spec.ts for the same API mocking approach. + describe("setValidState", () => { + const EXPLORE_ROUTE = "/[organization]/[project]/-/embed/explore/[name]"; + const CANVAS_ROUTE = "/[organization]/[project]/-/embed/canvas/[name]"; + + const mocks = DashboardFetchMocks.useDashboardFetchMocks(); + + beforeEach(() => { + queryClient.clear(); + sessionStorage.clear(); + mocks.mockMetricsView(AD_BIDS_METRICS_NAME, AD_BIDS_METRICS_INIT); + mocks.mockMetricsExplore(AD_BIDS_EXPLORE_NAME, AD_BIDS_METRICS_INIT, { + ...AD_BIDS_EXPLORE_INIT, + defaultPreset: AD_BIDS_PRESET_WITHOUT_TIMESTAMP, + }); + }); + + function onExploreRoute() { + harness.setRoute(EXPLORE_ROUTE, { name: AD_BIDS_EXPLORE_NAME }); + } + + // buildValidatedExploreUrl awaits fetches that resolve on a real setTimeout, + // so advance fake timers to let the RPC response settle before returning it. + async function callSetValidState(params: unknown) { + const response = harness.call("setValidState", params); + await vi.advanceTimersByTimeAsync(50); + return response; + } + + it("applies state as-is on non-explore routes without validating", async () => { + harness.setRoute(CANVAS_ROUTE, { name: "my_canvas" }); + + const response = await callSetValidState({ state: "foo=bar" }); + + expect(response.result).toEqual({ + success: true, + appliedState: "foo=bar", + errors: [], + }); + expect(harness.lastGoto()?.url.search).toBe("?foo=bar"); + expect(harness.lastGoto()?.opts).toEqual({ replaceState: true }); + }); + + it("applies the validated, canonicalized url on explore routes", async () => { + onExploreRoute(); + + const response = await callSetValidState({ + state: "measures=impressions&dims=publisher", + }); + + expect(response.result).toEqual({ + success: true, + appliedState: "measures=impressions&dims=publisher", + errors: [], + }); + // Params matching rill defaults (sort etc.) are stripped, leaving the canonical url. + expect(harness.lastGoto()?.url.search).toBe( + "?measures=impressions&dims=publisher", + ); + expect(harness.lastGoto()?.opts).toEqual({ replaceState: true }); + }); + + it("does not navigate on validation errors when failOnError is true", async () => { + onExploreRoute(); + const gotoCountBefore = harness.gotoCalls.length; + + const response = await callSetValidState({ + state: "measures=does_not_exist", + failOnError: true, + }); + + expect(response.result).toEqual({ + success: false, + errors: ['Selected measure: "does_not_exist" is not valid.'], + }); + // No navigation happened. + expect(harness.gotoCalls.length).toBe(gotoCountBefore); + }); + + it("applies the cleaned url despite errors when failOnError defaults to false", async () => { + onExploreRoute(); + + const response = await callSetValidState({ + state: "dims=publisher&measures=does_not_exist", + }); + + // The invalid measure is dropped and its error reported, but the valid + // dimension survives and the cleaned url is applied. + expect(response.result).toEqual({ + success: true, + appliedState: "dims=publisher", + errors: ['Selected measure: "does_not_exist" is not valid.'], + }); + expect(harness.lastGoto()?.url.search).toBe("?dims=publisher"); + }); + + it("clears prior embed session storage before applying the validated state", async () => { + onExploreRoute(); + + const sessionKey = getKeyForSessionStore( + AD_BIDS_EXPLORE_NAME, + EmbedStorageNamespacePrefix, + ExploreUrlWebView.Explore, + ); + // Simulate a filter left over from a prior interaction. Without clearing, applying an + // empty / view-only url lets handleURLChange restore this stale state instead of the + // validated state we just returned in `appliedState`. + sessionStorage.setItem(sessionKey, "f=publisher+IN+%28%27Google%27%29"); + + await callSetValidState({ state: "" }); + + expect(sessionStorage.getItem(sessionKey)).toBeNull(); + }); + + it("returns a JSON-RPC error when params is not an object with a string state", async () => { + onExploreRoute(); + + const notObject = await callSetValidState("foo=bar"); + expect(notObject.error?.message).toBe( + "Expected params to be an object with a string `state` property", + ); + + const missingState = await callSetValidState({}); + expect(missingState.error?.message).toBe( + "Expected params to be an object with a string `state` property", + ); + }); + }); + + describe("stateChange notification", () => { + // The page.subscribe callback fires immediately on subscribe during init, + // which schedules the first (throttled) emission. + // Flush and clear it so each test starts from a clean slate. + function flushInitialEmission() { + vi.advanceTimersByTime(STATE_CHANGE_THROTTLE_TIMEOUT); + harness.clearMessages(); + } + + it("emits stateChange with embed params stripped when the url changes", () => { + flushInitialEmission(); + + harness.navigateTo( + "view=explore&instance_id=abc&access_token=xyz&foo=bar", + ); + vi.advanceTimersByTime(STATE_CHANGE_THROTTLE_TIMEOUT); + + const events = harness.notifications("stateChange"); + expect(events).toHaveLength(1); + expect(events[0].params).toEqual({ state: "view=explore&foo=bar" }); + }); + + it("coalesces rapid url changes into a single emission with the latest state", () => { + flushInitialEmission(); + + harness.navigateTo("step=1"); + harness.navigateTo("step=2"); + harness.navigateTo("step=3"); + // Only one timer is scheduled for the burst. + vi.advanceTimersByTime(STATE_CHANGE_THROTTLE_TIMEOUT); + + const events = harness.notifications("stateChange"); + expect(events).toHaveLength(1); + expect(events[0].params).toEqual({ state: "step=3" }); + }); + + it("does not emit until the throttle window elapses", () => { + flushInitialEmission(); + + harness.navigateTo("foo=bar"); + vi.advanceTimersByTime(STATE_CHANGE_THROTTLE_TIMEOUT - 1); + expect(harness.notifications("stateChange")).toHaveLength(0); + + vi.advanceTimersByTime(1); + expect(harness.notifications("stateChange")).toHaveLength(1); + }); + + it("stops emitting stateChange after cleanup", () => { + flushInitialEmission(); + cleanup(); + + harness.navigateTo("foo=bar"); + vi.advanceTimersByTime(STATE_CHANGE_THROTTLE_TIMEOUT); + + expect(harness.notifications("stateChange")).toHaveLength(0); + }); + }); +}); diff --git a/web-admin/src/features/embeds/init-embed-public-api.ts b/web-admin/src/features/embeds/init-embed-public-api.ts index 0e58a260ec5e..b087f2941365 100644 --- a/web-admin/src/features/embeds/init-embed-public-api.ts +++ b/web-admin/src/features/embeds/init-embed-public-api.ts @@ -1,8 +1,14 @@ import { goto } from "$app/navigation"; import { page } from "$app/stores"; +import { getDashboardFromEmbedRoute } from "@rilldata/web-admin/features/embeds/embed-route-utils.ts"; +import { EmbedStorageNamespacePrefix } from "@rilldata/web-admin/features/embeds/constants.ts"; +import { ResourceKind } from "@rilldata/web-common/features/entity-management/resource-selectors.ts"; +import { buildValidatedExploreUrl } from "@rilldata/web-common/features/dashboards/state-managers/loaders/build-validated-explore-url.ts"; +import { clearExploreSessionStore } from "@rilldata/web-common/features/dashboards/state-managers/loaders/explore-web-view-store.ts"; import { eventBus } from "@rilldata/web-common/lib/event-bus/event-bus.ts"; import type { PageContentResized } from "@rilldata/web-common/lib/event-bus/events.ts"; import { Throttler } from "@rilldata/web-common/lib/throttler.ts"; +import type { RuntimeClient } from "@rilldata/web-common/runtime-client/v2"; import { get } from "svelte/store"; import { emitNotification, @@ -20,7 +26,14 @@ const STATE_CHANGE_THROTTLE_TIMEOUT = 200; const RESIZE_THROTTLE_TIMEOUT = 200; const AI_PANE_CHANGE_THROTTLE_TIMEOUT = 200; -export default function initEmbedPublicAPI(): () => void { +type SetValidStateParams = { + state: string; + // When false (the default) the cleaned state is applied even if some params were invalid. + // When true the state is only applied if there are no validation errors. + failOnError?: boolean; +}; + +export default function initEmbedPublicAPI(client: RuntimeClient): () => void { const embedThemeStore = getEmbedThemeStoreInstance(); const embedStore = EmbedStore.getInstance(); @@ -52,6 +65,62 @@ export default function initEmbedPublicAPI(): () => void { return true; }); + registerRPCMethod("setValidState", async (params: SetValidStateParams) => { + if ( + typeof params !== "object" || + params === null || + typeof params.state !== "string" + ) { + throw new Error( + "Expected params to be an object with a string `state` property", + ); + } + const { state, failOnError = false } = params; + + const pageState = get(page); + const activeDashboard = getDashboardFromEmbedRoute( + pageState.route.id, + pageState.params, + ); + + // Upfront validation is only supported for explore dashboards. + // For anything else (e.g. canvas) fall back to applying the state as-is. + if (activeDashboard?.kind !== ResourceKind.Explore) { + const currentUrl = new URL(pageState.url); + currentUrl.search = state; + void goto(currentUrl, { replaceState: true }); + return { success: true, appliedState: state, errors: [] }; + } + + const { url, errors } = await buildValidatedExploreUrl( + client, + activeDashboard.name, + new URLSearchParams(state), + pageState.url, + ); + const errorMessages = errors.map((error) => error.message); + + if (errors.length > 0 && failOnError) { + return { success: false, errors: errorMessages }; + } + + // Clear any prior embed session state for this explore before navigating. + // buildValidatedExploreUrl intentionally ignores session storage, + // but applying the url via goto triggers handleURLChange which re-merges session storage for empty / view-only urls. + // Without this, resetting the dashboard (e.g. setValidState({ state: "" })) would restore the previous session filters + // instead of the validated state we just computed and returned. + clearExploreSessionStore(activeDashboard.name, EmbedStorageNamespacePrefix); + + const currentUrl = new URL(pageState.url); + currentUrl.search = url.toString(); + void goto(currentUrl, { replaceState: true }); + return { + success: true, + appliedState: currentUrl.search.replace(/^\?/, ""), + errors: errorMessages, + }; + }); + registerRPCMethod("getThemeMode", () => { return { themeMode: get(themeControl.preference) }; }); diff --git a/web-admin/src/features/embeds/test/EmbedPublicAPIHarness.ts b/web-admin/src/features/embeds/test/EmbedPublicAPIHarness.ts new file mode 100644 index 000000000000..d8379fa3c4ab --- /dev/null +++ b/web-admin/src/features/embeds/test/EmbedPublicAPIHarness.ts @@ -0,0 +1,194 @@ +import { createIframeRPCHandler } from "@rilldata/web-common/lib/rpc"; +import type { Page } from "@sveltejs/kit"; +import { get, writable, type Readable, type Updater } from "svelte/store"; + +/** + * Mocking `$app/stores` and `$app/navigation` requires hoisting the shared page + * store via `vi.hoisted` so it exists before those modules and + * `init-embed-public-api.ts` are imported. To avoid rearranging imports we hand + * an empty object to `vi.hoisted` and let this harness attach behaviour to it. + */ +export type HoistedEmbedPage = Readable & { + // Mirrors the single `goto(url, { replaceState })` shape used by the embed API. + goto: (url: URL, opts?: { replaceState?: boolean }) => void; +}; + +// A message posted from the embed iframe to its parent window: either a JSON-RPC +// response ({ id, result | error }) to a `call`, or a notification ({ method, params }). +type PostedMessage = { + id?: string | number | null; + method?: string; + params?: unknown; + result?: unknown; + error?: { code: number; message: string; data?: unknown }; +}; + +export type GotoCall = { url: URL; opts?: { replaceState?: boolean } }; + +const DEFAULT_URL = "http://localhost/-/embed"; +const DEFAULT_ROUTE_ID = "/[organization]/[project]/-/embed"; + +/** + * Drives `initEmbedPublicAPI` through the real RPC transport, i.e. as an + * embedding parent window sees it. It stands in for the parent window + * (capturing everything the iframe `postMessage`s), installs the real + * `createIframeRPCHandler` message listener, and dispatches JSON-RPC requests + * via `window` message events. Only the SvelteKit page store and navigation are + * mocked at the module level, since those cannot be reached through `window`. + * + * Usage: + * ``` + * const { hoistedPage } = vi.hoisted(() => ({ hoistedPage: {} as HoistedEmbedPage })); + * vi.mock("$app/navigation", () => ({ goto: (u, o) => hoistedPage.goto(u, o) })); + * vi.mock("$app/stores", () => ({ page: hoistedPage })); + * + * beforeEach(() => { + * harness = new EmbedPublicAPIHarness(hoistedPage); + * cleanup = initEmbedPublicAPI({} as RuntimeClient); // registers methods, emits "ready" + * }); + * afterEach(() => { + * cleanup(); + * harness.destroy(); + * }); + * ``` + */ +export class EmbedPublicAPIHarness { + private readonly update: (updater: Updater) => void; + // Every `goto` call the embed API makes, in order. + public readonly gotoCalls: GotoCall[] = []; + // Every message the iframe posted to its (mocked) parent window, in order. + public readonly messages: PostedMessage[] = []; + + private readonly removeRPCHandler: () => void; + private readonly restoreParent: () => void; + private nextRPCId = 0; + private readonly pending = new Map void>(); + + public constructor( + private readonly hoistedPage: HoistedEmbedPage, + initial?: { + url?: string; + routeId?: string; + params?: Record; + }, + ) { + const { update, subscribe } = writable({ + url: new URL(initial?.url ?? DEFAULT_URL), + route: { id: initial?.routeId ?? DEFAULT_ROUTE_ID }, + params: initial?.params ?? {}, + } as Page); + this.update = update; + + hoistedPage.subscribe = subscribe; + hoistedPage.goto = (url, opts) => { + this.gotoCalls.push({ url, opts }); + update((page) => { + page.url = url; + return page; + }); + }; + + // Stand in for the parent window. The RPC layer only sends responses and + // notifications when `window.parent !== window`, and routes them through + // `window.parent.postMessage`, so capturing that captures the full surface. + const parentMock = { + postMessage: (msg: PostedMessage) => { + this.messages.push(msg); + if (msg?.id != null) { + const resolve = this.pending.get(msg.id as number); + if (resolve) { + this.pending.delete(msg.id as number); + resolve(msg); + } + } + }, + }; + const originalParent = Object.getOwnPropertyDescriptor(window, "parent"); + Object.defineProperty(window, "parent", { + value: parentMock, + configurable: true, + }); + this.restoreParent = () => { + if (originalParent) { + Object.defineProperty(window, "parent", originalParent); + } else { + delete (window as unknown as { parent?: unknown }).parent; + } + }; + + // Install the real message listener that dispatches to registered methods. + this.removeRPCHandler = createIframeRPCHandler(); + } + + /** + * Invoke an RPC method the way a parent window would: post a JSON-RPC request + * and resolve with the response message ({ id, result } or { id, error }). + */ + public call(method: string, params?: unknown): Promise { + const id = ++this.nextRPCId; + const response = new Promise((resolve) => + this.pending.set(id, resolve), + ); + window.dispatchEvent( + new MessageEvent("message", { + data: { id, method, params }, + source: window, + }), + ); + return response; + } + + /** + * Put the page store on a given route, e.g. an explore or canvas embed route. + * Used to exercise route-dependent behaviour such as `setValidState`'s + * upfront validation, which only applies to explore dashboards. + */ + public setRoute(routeId: string, params: Record = {}) { + this.update((page) => { + page.route = { id: routeId } as Page["route"]; + page.params = params; + return page; + }); + } + + /** + * Simulate an external navigation (e.g. the user interacting with the dashboard) + * that changes only the url's search params. Fires the page store subscribers, + * which is what the `stateChange` notification listens to. + */ + public navigateTo(search: string) { + this.update((page) => { + const url = new URL(page.url); + url.search = search; + page.url = url; + return page; + }); + } + + /** Notifications posted so far (messages with a `method`), optionally filtered. */ + public notifications(method?: string): PostedMessage[] { + const notifications = this.messages.filter((m) => m.method !== undefined); + if (!method) return notifications; + return notifications.filter((m) => m.method === method); + } + + /** Discard all captured messages, e.g. after flushing init-time emissions. */ + public clearMessages() { + this.messages.length = 0; + } + + /** The most recent `goto` call, or undefined if none has happened yet. */ + public lastGoto(): GotoCall | undefined { + return this.gotoCalls.at(-1); + } + + public get currentUrl(): URL { + return get(this.hoistedPage).url; + } + + /** Remove the message listener and restore the real `window.parent`. */ + public destroy() { + this.removeRPCHandler(); + this.restoreParent(); + } +} diff --git a/web-admin/src/routes/-/embed/+layout.svelte b/web-admin/src/routes/-/embed/+layout.svelte index e5e9cf4c6481..06d4336c8743 100644 --- a/web-admin/src/routes/-/embed/+layout.svelte +++ b/web-admin/src/routes/-/embed/+layout.svelte @@ -19,6 +19,7 @@ emitNotification, } from "@rilldata/web-common/lib/rpc"; import { waitUntil } from "@rilldata/web-common/lib/waitUtils"; + import { getRuntimeClient } from "@rilldata/web-common/runtime-client/v2/context"; import RuntimeProvider from "@rilldata/web-common/runtime-client/v2/RuntimeProvider.svelte"; import { onMount } from "svelte"; import { m } from "@rilldata/web-common/lib/i18n/gen/messages"; @@ -110,7 +111,14 @@ createIframeRPCHandler(); void waitUntil(() => VegaLiteTooltipHandler.resetElement(), 5000, 100); - return initEmbedPublicAPI(); + // Returns the same cached client that RuntimeProvider uses for this host+instanceId. + const client = getRuntimeClient({ + host: runtimeHost, + instanceId, + jwt: accessToken, + authContext: "embed", + }); + return initEmbedPublicAPI(client); }); diff --git a/web-admin/tests/embeds.spec.ts b/web-admin/tests/embeds.spec.ts index 8d59baa6df19..b42522dfc0c6 100644 --- a/web-admin/tests/embeds.spec.ts +++ b/web-admin/tests/embeds.spec.ts @@ -215,6 +215,78 @@ test.describe("Embeds", () => { await recorder.expectContaining(`{"id":1338,"result":true}`); }); + test("setValidState changes embedded explore", async ({ embedPage }) => { + const recorder = new EmbedMessageRecorder(embedPage); + await recorder.waitForReady(); + const frame = embedPage.frameLocator("iframe"); + + await embedPage.evaluate(() => { + const iframe = document.querySelector("iframe"); + iframe?.contentWindow?.postMessage( + { + id: 1337, + method: "setValidState", + params: { + state: + "tr=P7D&grain=day&f=advertiser_name+IN+%28%27Instacart%27%29", + }, + }, + "*", + ); + }); + await expect( + frame.getByRole("row", { name: "Instacart $2.1k" }), + ).toBeVisible(); + await recorder.expectContaining( + `{"id":1337,"result":{"success":true,"appliedState":"tr=P7D&grain=day&f=advertiser_name+IN+%28%27Instacart%27%29","errors":[]}}`, + ); + + // Set new rill syntax that includes `+` in the syntax. + await embedPage.evaluate(() => { + const iframe = document.querySelector("iframe"); + iframe?.contentWindow?.postMessage( + { + id: 1338, + method: "setValidState", + params: { + state: + "tr=2D+as+of+latest%2FD%2B1D&grain=day&f=advertiser_name+IN+%28%27Instacart%27%29", + }, + }, + "*", + ); + }); + await expect( + frame.getByRole("row", { name: "Instacart $1.1k" }), + ).toBeVisible(); + await recorder.expectContaining( + `{"id":1338,"result":{"success":true,"appliedState":"tr=2D+as+of+latest%2FD%2B1D&grain=hour&f=advertiser_name+IN+%28%27Instacart%27%29","errors":[]}}`, + ); + + // Set with invalid filter. + await embedPage.evaluate(() => { + const iframe = document.querySelector("iframe"); + iframe?.contentWindow?.postMessage( + { + id: 1338, + method: "setValidState", + params: { + state: + "tr=P7D&grain=day&f=advertiser_name_invalid+IN+%28%27Instacart%27%29", + }, + }, + "*", + ); + }); + // Filter was removed + await expect( + frame.getByRole("row", { name: "Instacart $2.1k" }), + ).toBeVisible(); + await recorder.expectContaining( + `[{"id":1338,"result":{"success":true,"appliedState":"tr=P7D&grain=day","errors":["Selected filter dimension: \\"advertiser_name_invalid\\" is not valid."]}}]`, + ); + }); + test("getThemeMode returns current theme mode", async ({ embedPage }) => { const recorder = new EmbedMessageRecorder(embedPage); await recorder.waitForReady(); diff --git a/web-common/src/features/dashboards/state-managers/loaders/build-validated-explore-url.ts b/web-common/src/features/dashboards/state-managers/loaders/build-validated-explore-url.ts new file mode 100644 index 000000000000..88585c9e1ea8 --- /dev/null +++ b/web-common/src/features/dashboards/state-managers/loaders/build-validated-explore-url.ts @@ -0,0 +1,106 @@ +import { cascadingExploreStateMerge } from "@rilldata/web-common/features/dashboards/state-managers/cascading-explore-state-merge"; +import { correctExploreState } from "@rilldata/web-common/features/dashboards/stores/correct-explore-state"; +import type { ExploreState } from "@rilldata/web-common/features/dashboards/stores/explore-state"; +import { getRillDefaultExploreState } from "@rilldata/web-common/features/dashboards/stores/get-rill-default-explore-state"; +import { getTimeControlState } from "@rilldata/web-common/features/dashboards/time-controls/time-control-store"; +import { cleanEmbedUrlParams } from "@rilldata/web-common/features/dashboards/url-state/clean-url-params"; +import { convertURLSearchParamsToExploreState } from "@rilldata/web-common/features/dashboards/url-state/convertURLSearchParamsToExploreState"; +import { getCleanedUrlParamsForGoto } from "@rilldata/web-common/features/dashboards/url-state/convert-partial-explore-state-to-url-params"; +import { getRillDefaultExploreUrlParams } from "@rilldata/web-common/features/dashboards/url-state/get-rill-default-explore-url-params"; +import { queryClient } from "@rilldata/web-common/lib/svelte-query/globalQueryClient"; +import { + getQueryServiceMetricsViewTimeRangeQueryOptions, + getRuntimeServiceGetExploreQueryOptions, + type V1TimeRangeSummary, +} from "@rilldata/web-common/runtime-client"; +import type { RuntimeClient } from "@rilldata/web-common/runtime-client/v2"; + +export type ValidatedExploreUrl = { + // The cleaned and canonicalized url params for the given state. + // Invalid params are dropped, so this is always safe to apply even when `errors` is non-empty. + url: URLSearchParams; + errors: Error[]; +}; + +/** + * Converts a set of url params into the canonical explore url, collecting any validation errors. + * + * This reuses the same conversion parts as DashboardStateSync.handleURLChange so that the returned + * url matches what the dashboard would end up on: url params are validated against the explore and + * metrics view specs, back-filled with rill opinionated defaults, and then converted back to url + * params with defaults removed. + * + * Unlike handleURLChange it does not mutate the explore store, touch session storage or navigate. + * It also intentionally skips the session storage, most recent and bookmark sources so that the + * resulting url is determined solely by the provided params (plus rill defaults), not the viewer's + * local state. + * + * Errors are returned rather than thrown; the caller decides whether to apply the url regardless. + */ +export async function buildValidatedExploreUrl( + client: RuntimeClient, + exploreName: string, + searchParams: URLSearchParams, + currentUrl: URL, +): Promise { + const exploreResp = await queryClient.fetchQuery( + getRuntimeServiceGetExploreQueryOptions(client, { name: exploreName }), + ); + const metricsViewSpec = + exploreResp.metricsView?.metricsView?.state?.validSpec ?? {}; + const exploreSpec = exploreResp.explore?.explore?.state?.validSpec ?? {}; + + let timeRangeSummary: V1TimeRangeSummary | undefined; + if (metricsViewSpec.timeDimension) { + const timeRangeResp = await queryClient.fetchQuery( + getQueryServiceMetricsViewTimeRangeQueryOptions(client, { + metricsViewName: exploreSpec.metricsView ?? "", + }), + ); + timeRangeSummary = timeRangeResp.timeRangeSummary; + } + + const cleanedParams = cleanEmbedUrlParams(searchParams); + const { partialExploreState, errors } = convertURLSearchParamsToExploreState( + cleanedParams, + metricsViewSpec, + exploreSpec, + {}, + ); + + // Back-fill rill opinionated defaults so that fields not present in the url (like time range) + // resolve to a complete, valid state. + const rillDefaultExploreState = getRillDefaultExploreState( + metricsViewSpec, + exploreSpec, + timeRangeSummary, + ); + const exploreState = cascadingExploreStateMerge([ + partialExploreState, + rillDefaultExploreState, + ]) as ExploreState; + correctExploreState(metricsViewSpec, exploreState); + + const timeControlsState = getTimeControlState( + metricsViewSpec, + exploreSpec, + timeRangeSummary, + exploreState, + ); + const defaultExploreUrlParams = getRillDefaultExploreUrlParams( + metricsViewSpec, + exploreSpec, + timeRangeSummary, + ); + + const url = getCleanedUrlParamsForGoto( + exploreSpec, + metricsViewSpec, + exploreState, + timeControlsState, + defaultExploreUrlParams, + currentUrl, + ); + + return { url, errors }; +}