diff --git a/docs/docs/developers/embed/postmessage.md b/docs/docs/developers/embed/postmessage.md index d8e21f461f9e..05ed6f15f60f 100644 --- a/docs/docs/developers/embed/postmessage.md +++ b/docs/docs/developers/embed/postmessage.md @@ -307,6 +307,104 @@ iframe.contentWindow.postMessage({ **Note:** The AI pane is only available for dashboards when the chat feature is enabled. If the AI pane is not available, calling this method will not cause an error, but the pane will not be shown. + +### `navigateToDashboard({ name, state, failOnError })` + +Navigates the iframe to another dashboard in the same project. + +```js +iframe.contentWindow.postMessage({ + id: 9, + method: "navigateToDashboard", + params: { name: "bids_explore", state: "view=pivot&tr=PT24H&grain=hour" }, +}, "*"); +``` + +**Parameters:** +- `name` (string): The name of the explore or canvas dashboard to navigate to, as defined in the project's YAML files. +- `state` (string, optional): A URL query string to apply to the dashboard being navigated to. When omitted, the dashboard opens in its default state. The state of the dashboard being navigated away from is never carried over. +- `failOnError` (boolean, optional): Behaves the same as in `setValidState`. When `false` (the default), the cleaned state is applied even when some parameters were invalid; when `true`, the navigation is skipped entirely if validation produced errors. + +**Response:** + +```json +{ "id": 9, "result": { "success": true, "appliedState": "view=pivot&tr=PT24H&grain=hour", "errors": [] } } +``` + +The response has the same shape as `setValidState`: `state` is validated against the target dashboard's metrics view and explore specs, and `appliedState` is the canonicalized query string that was actually applied. As with `setValidState`, validation is currently supported for explore dashboards; for other dashboard types the state is applied as-is. + +**Error Response (if the dashboard does not exist):** + +```json +{ + "id": 9, + "error": { + "code": -32603, + "message": "Dashboard \"bids_explore\" not found" + } +} +``` + +The same error is returned when `name` refers to a resource that is not an explore or canvas dashboard, or to a dashboard the embed's access token does not grant access to. + +Each call adds a browser history entry, so it can be undone with `navigateBack`. + +**Note:** All three navigation methods require navigation to be enabled in the embed configuration. When the embed is configured with `navigation=false`, they return an error instead of navigating: + +```json +{ + "id": 9, + "error": { + "code": -32603, + "message": "Navigation is disabled for this embed" + } +} +``` + + +### `navigateBack()` + +Navigates back to the previous entry in the iframe's browser history, equivalent to the browser's back button. + +```js +iframe.contentWindow.postMessage({ + id: 10, + method: "navigateBack", +}, "*"); +``` + +**Parameters:** None. + +**Response:** + +```json +{ "id": 10, "result": true } +``` + +**Note:** This method returns the `Navigation is disabled for this embed` error when the embed is configured with `navigation=false`. When navigation is enabled but there is no previous history entry, for example on the first dashboard the embed loaded, the call succeeds without navigating. + + +### `navigateForward()` + +Navigates forward to the next entry in the iframe's browser history, equivalent to the browser's forward button. + +```js +iframe.contentWindow.postMessage({ + id: 11, + method: "navigateForward", +}, "*"); +``` + +**Parameters:** None. + +**Response:** + +```json +{ "id": 11, "result": true } +``` + +**Note:** As with `navigateBack`, this method returns an error when the embed is configured with `navigation=false`, and succeeds without navigating when there is no next history entry. + ## Notifications Notifications are sent **from the iframe** to the parent window. These do not include an `id`. @@ -329,7 +427,7 @@ Fired whenever the internal state of the iframe changes. ### `navigation({ from: string, to: string })` -Fired whenever a user navigates between dashboards. This event is only emitted when navigation is enabled in the embed configuration. +Fired whenever navigation between dashboards happens, either through a user interaction or through a `navigateToDashboard` call. This event is only emitted when navigation is enabled in the embed configuration. - `from`: The name of the dashboard the user navigated from, or `"dashboardListing"` if navigating from the dashboard listing page - `to`: The name of the dashboard the user navigated to, or `"dashboardListing"` if navigating to the dashboard listing page @@ -451,6 +549,9 @@ window.addEventListener("message", async (event) => { const aiPaneState = await sendRequest("getAiPane"); console.log("AI pane open:", aiPaneState.open); await sendRequest("setAiPane", true); + + await sendRequest("navigateToDashboard", { name: "bids_canvas" }); + await sendRequest("navigateBack"); } if (event.data?.method === "stateChanged") { 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 index 769fed01c79a..dcbc2f3cf0c4 100644 --- a/web-admin/src/features/embeds/init-embed-public-api.spec.ts +++ b/web-admin/src/features/embeds/init-embed-public-api.spec.ts @@ -9,6 +9,8 @@ import { } 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 { EmbedStore } from "@rilldata/web-common/features/embeds/embed-store"; +import { ResourceKind } from "@rilldata/web-common/features/entity-management/resource-selectors"; 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"; @@ -53,10 +55,17 @@ vi.mock("@rilldata/web-common/features/embeds/embed-theme", () => ({ }), })); +const EMBED_URL = "http://localhost/-/embed"; +const EXPLORE_ROUTE = "/[organization]/[project]/-/embed/explore/[name]"; +const CANVAS_ROUTE = "/[organization]/[project]/-/embed/canvas/[name]"; +const AD_BIDS_CANVAS_NAME = "AdBids_canvas"; + describe("initEmbedPublicAPI", () => { let harness: EmbedPublicAPIHarness; let cleanup: () => void; + const mocks = DashboardFetchMocks.useDashboardFetchMocks(); + const client = new RuntimeClient({ host: "http://localhost", instanceId: "test", @@ -65,6 +74,10 @@ describe("initEmbedPublicAPI", () => { beforeEach(() => { vi.useFakeTimers(); + // initEmbedPublicAPI reads the embed's config (theme mode, navigation) from the + // EmbedStore singleton, which the embed layout initializes before calling it. + EmbedStore.init(new URL(`${EMBED_URL}?navigation=true`)); + // Construct the harness (mocks window.parent, installs the RPC handler) // before init so the "ready" and initial notifications are captured. harness = new EmbedPublicAPIHarness(hoistedPage); @@ -121,11 +134,6 @@ describe("initEmbedPublicAPI", () => { // 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(); @@ -248,6 +256,226 @@ describe("initEmbedPublicAPI", () => { }); }); + describe("navigateBack / navigateForward", () => { + it("drives the browser history and returns true", async () => { + const back = vi + .spyOn(window.history, "back") + .mockImplementation(() => {}); + const forward = vi + .spyOn(window.history, "forward") + .mockImplementation(() => {}); + + expect((await harness.call("navigateBack")).result).toBe(true); + expect(back).toHaveBeenCalledOnce(); + + expect((await harness.call("navigateForward")).result).toBe(true); + expect(forward).toHaveBeenCalledOnce(); + + back.mockRestore(); + forward.mockRestore(); + }); + }); + + describe("with navigation disabled", () => { + // Re-initialize the embed without `navigation=true` and re-register the methods + // against it, mirroring an embed configured with navigation disabled. + beforeEach(() => { + cleanup(); + EmbedStore.init(new URL(EMBED_URL)); + cleanup = initEmbedPublicAPI(client); + }); + + it.each(["navigateBack", "navigateForward", "navigateToDashboard"])( + "returns a JSON-RPC error from %s", + async (method) => { + const gotoCountBefore = harness.gotoCalls.length; + + const response = await harness.call(method, { + name: AD_BIDS_EXPLORE_NAME, + }); + + expect(response.result).toBeUndefined(); + expect(response.error?.message).toBe( + "Navigation is disabled for this embed", + ); + expect(harness.gotoCalls.length).toBe(gotoCountBefore); + }, + ); + + it("still allows state changes on the current dashboard", async () => { + const response = await harness.call("setState", "foo=bar"); + + expect(response.result).toBe(true); + expect(harness.lastGoto()?.url.search).toBe("?foo=bar"); + }); + }); + + // navigateToDashboard resolves the target dashboard's kind through ListResources and then + // applies the state the same way setValidState does. + describe("navigateToDashboard", () => { + 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, + }); + mocks.mockListResources([ + { + meta: { + name: { + kind: ResourceKind.MetricsView, + name: AD_BIDS_METRICS_NAME, + }, + }, + }, + { + meta: { + name: { kind: ResourceKind.Explore, name: AD_BIDS_EXPLORE_NAME }, + }, + }, + { + meta: { + name: { kind: ResourceKind.Canvas, name: AD_BIDS_CANVAS_NAME }, + }, + }, + ]); + }); + + // The ListResources and buildValidatedExploreUrl fetches resolve on a real setTimeout, + // so advance fake timers to let the RPC response settle before returning it. + async function callNavigate(params: unknown) { + const response = harness.call("navigateToDashboard", params); + await vi.advanceTimersByTimeAsync(50); + return response; + } + + it("navigates to an explore dashboard with the validated state", async () => { + harness.setRoute(CANVAS_ROUTE, { name: AD_BIDS_CANVAS_NAME }); + + const response = await callNavigate({ + name: AD_BIDS_EXPLORE_NAME, + state: "measures=impressions&dims=publisher", + }); + + expect(response.result).toEqual({ + success: true, + appliedState: "measures=impressions&dims=publisher", + errors: [], + }); + const last = harness.lastGoto(); + expect(last?.url.pathname).toBe( + `/-/embed/explore/${AD_BIDS_EXPLORE_NAME}`, + ); + expect(last?.url.search).toBe("?measures=impressions&dims=publisher"); + // Navigating to another dashboard should be undoable via `navigateBack`. + expect(last?.opts).toEqual({ replaceState: false }); + }); + + it("navigates to a canvas dashboard applying the state as-is", async () => { + harness.setRoute(EXPLORE_ROUTE, { name: AD_BIDS_EXPLORE_NAME }); + + const response = await callNavigate({ + name: AD_BIDS_CANVAS_NAME, + state: "foo=bar", + }); + + expect(response.result).toEqual({ + success: true, + appliedState: "foo=bar", + errors: [], + }); + expect(harness.lastGoto()?.url.pathname).toBe( + `/-/embed/canvas/${AD_BIDS_CANVAS_NAME}`, + ); + expect(harness.lastGoto()?.url.search).toBe("?foo=bar"); + }); + + it("navigates without any state when state is not given", async () => { + harness.setRoute(CANVAS_ROUTE, { name: AD_BIDS_CANVAS_NAME }); + // Params of the dashboard being navigated away from should not carry over. + harness.navigateTo("foo=bar"); + + const response = await callNavigate({ name: AD_BIDS_EXPLORE_NAME }); + + expect(response.result).toEqual({ + success: true, + appliedState: "", + errors: [], + }); + const last = harness.lastGoto(); + expect(last?.url.pathname).toBe( + `/-/embed/explore/${AD_BIDS_EXPLORE_NAME}`, + ); + expect(last?.url.search).toBe(""); + }); + + it("does not navigate on validation errors when failOnError is true", async () => { + harness.setRoute(CANVAS_ROUTE, { name: AD_BIDS_CANVAS_NAME }); + const gotoCountBefore = harness.gotoCalls.length; + + const response = await callNavigate({ + name: AD_BIDS_EXPLORE_NAME, + state: "measures=does_not_exist", + failOnError: true, + }); + + expect(response.result).toEqual({ + success: false, + errors: ['Selected measure: "does_not_exist" is not valid.'], + }); + expect(harness.gotoCalls.length).toBe(gotoCountBefore); + }); + + it("clears prior embed session storage before navigating to an explore", async () => { + const sessionKey = getKeyForSessionStore( + AD_BIDS_EXPLORE_NAME, + EmbedStorageNamespacePrefix, + ExploreUrlWebView.Explore, + ); + // Simulate state left over from an earlier visit to the target explore. Without clearing, + // handleURLChange would restore it instead of the state we just applied. + sessionStorage.setItem(sessionKey, "f=publisher+IN+%28%27Google%27%29"); + + await callNavigate({ name: AD_BIDS_EXPLORE_NAME, state: "" }); + + expect(sessionStorage.getItem(sessionKey)).toBeNull(); + }); + + it("returns a JSON-RPC error when the dashboard does not exist", async () => { + const response = await callNavigate({ name: "does_not_exist" }); + + expect(response.result).toBeUndefined(); + expect(response.error?.message).toBe( + 'Dashboard "does_not_exist" not found', + ); + }); + + it("returns a JSON-RPC error when the name is not a resource of a dashboard kind", async () => { + const response = await callNavigate({ name: AD_BIDS_METRICS_NAME }); + + expect(response.error?.message).toBe( + `Dashboard "${AD_BIDS_METRICS_NAME}" not found`, + ); + }); + + it("returns a JSON-RPC error when params is missing a string name", async () => { + const notObject = await callNavigate(AD_BIDS_EXPLORE_NAME); + expect(notObject.error?.message).toBe( + "Expected params to be an object with a string `name` property", + ); + + const nonStringState = await callNavigate({ + name: AD_BIDS_EXPLORE_NAME, + state: 123, + }); + expect(nonStringState.error?.message).toBe( + "Expected `state` to be a string", + ); + }); + }); + describe("stateChange notification", () => { // The page.subscribe callback fires immediately on subscribe during init, // which schedules the first (throttled) emission. 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 34f1b791c642..dd6b117b971b 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 { + type DashboardInfo, + 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 { + fetchResources, + 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"; @@ -21,11 +27,18 @@ import { dashboardChatActions, dashboardChatOpen, } from "@rilldata/web-common/features/chat/layouts/sidebar/sidebar-store"; +import { queryClient } from "@rilldata/web-common/lib/svelte-query/globalQueryClient.ts"; const STATE_CHANGE_THROTTLE_TIMEOUT = 200; const RESIZE_THROTTLE_TIMEOUT = 200; const AI_PANE_CHANGE_THROTTLE_TIMEOUT = 200; +// The resource kinds an embed can render as a dashboard. +const DashboardResourceKinds = new Set([ + ResourceKind.Explore, + ResourceKind.Canvas, +]); + type SetValidStateParams = { state: string; // When false (the default) the cleaned state is applied even if some params were invalid. @@ -33,6 +46,15 @@ type SetValidStateParams = { failOnError?: boolean; }; +type NavigateToDashboardParams = { + name: string; + // State is optional. Uses default behaviour of navigation when not specified. + 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(); @@ -83,42 +105,11 @@ export default function initEmbedPublicAPI(client: RuntimeClient): () => void { 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, - }; + return applyDashboardState(client, activeDashboard, state, pageState.url, { + failOnError, + // The dashboard stays the same, so the state change should not add a history entry. + replaceState: true, + }); }); registerRPCMethod("getThemeMode", () => { @@ -175,6 +166,75 @@ export default function initEmbedPublicAPI(client: RuntimeClient): () => void { return true; }); + // The embed suppresses all navigation when configured with `navigation=false`, + // so fail loudly rather than accepting a call that would silently do nothing. + function assertNavigationEnabled() { + if (!embedStore?.navigationEnabled) { + throw new Error("Navigation is disabled for this embed"); + } + } + + registerRPCMethod("navigateBack", () => { + assertNavigationEnabled(); + window.history.back(); + return true; + }); + registerRPCMethod("navigateForward", () => { + assertNavigationEnabled(); + window.history.forward(); + return true; + }); + registerRPCMethod( + "navigateToDashboard", + async (params: NavigateToDashboardParams) => { + assertNavigationEnabled(); + if ( + typeof params !== "object" || + params === null || + typeof params.name !== "string" + ) { + throw new Error( + "Expected params to be an object with a string `name` property", + ); + } + + const { name, state, failOnError = false } = params; + if (state !== undefined && typeof state !== "string") { + throw new Error("Expected `state` to be a string"); + } + + const resources = await fetchResources(queryClient, client, true); + const targetKind = resources.find( + (r) => + r.meta?.name?.name === name && + DashboardResourceKinds.has(r.meta?.name?.kind ?? ""), + )?.meta?.name?.kind as ResourceKind | undefined; + if (!targetKind) { + throw new Error(`Dashboard "${name}" not found`); + } + const dashboard: DashboardInfo = { name, kind: targetKind }; + + const targetUrl = new URL(get(page).url); + targetUrl.pathname = `/-/embed/${ + targetKind === ResourceKind.Canvas ? "canvas" : "explore" + }/${encodeURIComponent(name)}`; + // Params of the dashboard being navigated away from should never carry over. + targetUrl.search = ""; + + if (state === undefined) { + // Navigate without any state so that the dashboard falls back to its default behaviour. + void goto(targetUrl); + return { success: true, appliedState: "", errors: [] }; + } + + return applyDashboardState(client, dashboard, state, targetUrl, { + failOnError, + // Navigating to another dashboard should be undoable via `navigateBack`. + replaceState: false, + }); + }, + ); + emitNotification("ready"); const stateChangeThrottler = new Throttler( @@ -233,6 +293,67 @@ export default function initEmbedPublicAPI(client: RuntimeClient): () => void { }; } +type ApplyDashboardStateResult = { + success: boolean; + // The state that was actually applied. Omitted when the state was not applied. + appliedState?: string; + errors: string[]; +}; + +/** + * Applies `state` to `targetUrl` and navigates there. + * + * For explore dashboards the state is first validated against the explore and metrics view specs, + * so that invalid params are dropped and the applied url is canonicalized the same way it would be + * if the user had navigated there directly. Upfront validation is not supported for anything else + * (e.g. canvas or the dashboard listing), where the state is applied as-is. + */ +async function applyDashboardState( + client: RuntimeClient, + dashboard: DashboardInfo | null, + state: string, + targetUrl: URL, + { + failOnError, + replaceState, + }: { failOnError: boolean; replaceState: boolean }, +): Promise { + const url = new URL(targetUrl); + + if (dashboard?.kind !== ResourceKind.Explore) { + url.search = state; + void goto(url, { replaceState }); + return { success: true, appliedState: state, errors: [] }; + } + + const { url: validatedParams, errors } = await buildValidatedExploreUrl( + client, + dashboard.name, + new URLSearchParams(state), + targetUrl, + ); + 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(dashboard.name, EmbedStorageNamespacePrefix); + + url.search = validatedParams.toString(); + void goto(url, { replaceState }); + return { + success: true, + appliedState: url.search.replace(/^\?/, ""), + errors: errorMessages, + }; +} + const EmbedParams = [ "instance_id", "runtime_host", diff --git a/web-admin/tests/embeds.spec.ts b/web-admin/tests/embeds.spec.ts index 50ef7772f1b3..fa63466fc1fe 100644 --- a/web-admin/tests/embeds.spec.ts +++ b/web-admin/tests/embeds.spec.ts @@ -606,6 +606,60 @@ test.describe("Embeds", () => { ); }); }); + + test("embedded canvas navigation APIs to go back/forward", async ({ + embedPage, + }) => { + const recorder = new EmbedMessageRecorder(embedPage); + await recorder.waitForReady(); + const frame = embedPage.frameLocator("iframe"); + + // Hover over leaderboard component to show the `go to explore` button + await frame.locator("#bids_canvas--component-1-0").hover(); + await frame.getByLabel("Go to Programmatic Ads Bids").nth(0).click(); + // Navigation event is fired for going to explore + await recorder.expectContaining( + `{"method":"navigation","params":{"from":"bids_canvas","to":"bids_explore"}}`, + ); + + // Assert the selected filters in explore + await expect(frame.getByText("Last 24 hours")).toBeVisible(); + await expect(frame.getByText("instacart.com $1.1k")).toBeVisible(); + // Only one measures shown. + await expect( + frame.getByLabel("Choose measures to display"), + ).toContainText("1 of 12 Measures"); + // Only 3 dimensions shown. + await expect( + frame.getByLabel("Choose dimensions to display"), + ).toContainText("3 of 22 Dimensions"); + + // Call `navigateBack` + await embedPage.evaluate(() => { + const iframe = document.querySelector("iframe"); + iframe?.contentWindow?.postMessage( + { id: 1337, method: "navigateBack" }, + "*", + ); + }); + // Navigation event is fired for going back to canvas + await recorder.expectContaining( + `{"method":"navigation","params":{"from":"bids_explore","to":"bids_canvas"}}`, + ); + + // Call `navigateForward` + await embedPage.evaluate(() => { + const iframe = document.querySelector("iframe"); + iframe?.contentWindow?.postMessage( + { id: 1337, method: "navigateForward" }, + "*", + ); + }); + // Navigation event is fired for going forward to explore + await recorder.expectContaining( + `{"method":"navigation","params":{"from":"bids_canvas","to":"bids_explore"}}`, + ); + }); }); test.describe("embedded canvas with a hidden navigation bar", () => { diff --git a/web-common/src/features/dashboards/dashboard-fetch-mocks.ts b/web-common/src/features/dashboards/dashboard-fetch-mocks.ts index 59481ec2c06b..dcab5527f0a0 100644 --- a/web-common/src/features/dashboards/dashboard-fetch-mocks.ts +++ b/web-common/src/features/dashboards/dashboard-fetch-mocks.ts @@ -92,6 +92,10 @@ export class DashboardFetchMocks { } as V1GetExploreResponse); } + public mockListResources(resources: V1Resource[]) { + this.responses.set("resources__list", { resources }); + } + /** * Mocks the ResolveCanvas response, which is the single request a canvas dashboard loads from. * `metricsViews` are the metrics views the canvas references, which reach the canvas as @@ -255,10 +259,15 @@ export class DashboardFetchMocks { const name = parsed.name?.name; responseData = this.responses.get(`resource__${name}`); } else if (service === "RuntimeService" && method === "ListResources") { - const resources = [...this.resources.values()].filter( - (resource) => !parsed.kind || resource.meta?.name?.kind === parsed.kind, - ); - responseData = { resources }; + if (this.responses.has("resources__list")) { + responseData = this.responses.get("resources__list"); + } else { + const resources = [...this.resources.values()].filter( + (resource) => + !parsed.kind || resource.meta?.name?.kind === parsed.kind, + ); + responseData = { resources }; + } } else if (service === "QueryService" && method === "ResolveCanvas") { responseData = this.responses.get(`canvas__${parsed.canvas}`); } else if ( diff --git a/web-common/src/features/entity-management/resource-selectors.ts b/web-common/src/features/entity-management/resource-selectors.ts index d62ea23fac27..a9f8d04feb8a 100644 --- a/web-common/src/features/entity-management/resource-selectors.ts +++ b/web-common/src/features/entity-management/resource-selectors.ts @@ -329,10 +329,12 @@ export function fetchProjectParser( export async function fetchResources( queryClient: QueryClient, client: RuntimeClient, + fromCache = false, ) { const resp = await queryClient.fetchQuery({ queryKey: getRuntimeServiceListResourcesQueryKey(client.instanceId, {}), queryFn: () => runtimeServiceListResources(client, {}), + ...(fromCache ? { staleTime: Infinity } : {}), }); return resp.resources ?? []; }