Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Keeps preview across different tabs #38271

Merged
merged 16 commits into from
Feb 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
111 changes: 110 additions & 1 deletion e2e/test/scenarios/embedding/embedding-dashboard.cy.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import {
downloadAndAssert,
assertSheetRowsCount,
modal,
getIframeBody,
describeEE,
setTokenFeatures,
dashboardParametersContainer,
goToTab,
} from "e2e/support/helpers";
Expand All @@ -23,7 +26,7 @@ import {
mapParameters,
} from "./shared/embedding-dashboard";

const { ORDERS, PEOPLE, PRODUCTS } = SAMPLE_DATABASE;
const { ORDERS, PEOPLE, PRODUCTS, ORDERS_ID } = SAMPLE_DATABASE;

describe("scenarios > embedding > dashboard parameters", () => {
beforeEach(() => {
Expand Down Expand Up @@ -471,6 +474,112 @@ describe("scenarios > embedding > dashboard parameters with defaults", () => {
});
});

describeEE("scenarios > embedding > dashboard appearance", () => {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a new test to ensure:

  • Changing between parameters and appearance tabs doesn't rerender the preview
  • Changing anything under appearance tab doesn't rerender the preview

beforeEach(() => {
restore();
cy.signInAsAdmin();
setTokenFeatures("all");
});

it("should not rerender the static embed preview unnecessarily (metabase#38271)", () => {
const textFilter = {
id: "3",
name: "Text filter",
slug: "filter-text",
type: "string/contains",
};

const dashboardDetails = {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@npretto I added this filter, since I found out today that having a parameter will cause the appearance page preview to rerender every time we change any settings

name: "dashboard name",
enable_embedding: true,
embedding_params: {
/**
* Make sure the parameter is shown in embed preview, because it previously
* caused the iframe to rerender even when only the hash part of the embed
* preview URL is changed.
*
* @see useSyncedQueryString in frontend/src/metabase/hooks/use-synced-query-string.ts
*/
[textFilter.slug]: "enabled",
},
parameters: [textFilter],
};

const questionDetails = {
name: "Orders",
query: {
"source-table": ORDERS_ID,
},
};
cy.createQuestionAndDashboard({
questionDetails,
dashboardDetails,
}).then(({ body: { dashboard_id } }) => {
visitDashboard(dashboard_id);
});

cy.intercept(
"GET",
"api/preview_embed/dashboard/*",
cy.spy().as("previewEmbedSpy"),
).as("previewEmbed");

openStaticEmbeddingModal({
activeTab: "parameters",
previewMode: "preview",
// EE users don't have to accept terms
acceptTerms: false,
});

cy.wait("@previewEmbed");

modal().within(() => {
cy.findByRole("tab", { name: "Appearance" }).click();
cy.get("@previewEmbedSpy").should("have.callCount", 1);

cy.log("Assert dashboard theme");
getIframeBody()
.findByTestId("embed-frame")
.should("not.have.class", "Theme--transparent");
// We're getting an input element which is 0x0 in size
cy.findByLabelText("Transparent").click({ force: true });
getIframeBody()
.findByTestId("embed-frame")
.should("have.class", "Theme--transparent");
cy.get("@previewEmbedSpy").should("have.callCount", 1);

cy.log("Assert dashboard title");
getIframeBody().findByText(dashboardDetails.name).should("exist");
// We're getting an input element which is 0x0 in size
cy.findByLabelText("Dashboard title").click({ force: true });
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of finding the element with that label, can we click the label?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good point. Do you have an example where we click the labels? I looked up testing-library, and they could only get the input using various matchers e.g. byPlaceholderText, byLabelText, but to find labels I could use findByText which is less specific, or findByRole with a lot of hoops. I'd rather use findByLabelText with force: true because I think it's still more readable.

What do you think?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah that's ok, I tried with https://testing-playground.com/ and it only prompted by findByText

getIframeBody().findByText(dashboardDetails.name).should("not.exist");
cy.get("@previewEmbedSpy").should("have.callCount", 1);

cy.log("Assert dashboard border");
getIframeBody()
.findByTestId("embed-frame")
.should("have.css", "border-top-width", "1px");
// We're getting an input element which is 0x0 in size
cy.findByLabelText("Border").click({ force: true });
getIframeBody()
.findByTestId("embed-frame")
.should("have.css", "border-top-width", "0px");
cy.get("@previewEmbedSpy").should("have.callCount", 1);

cy.log("Assert font");
getIframeBody().should("have.css", "font-family", "Lato, sans-serif");
cy.findByLabelText("Font").click();
});

// Since the select popover is rendered outside of the modal, we need to exit the modal context first.
popover().findByText("Oswald").click();
modal().within(() => {
getIframeBody().should("have.css", "font-family", "Oswald, sans-serif");
cy.get("@previewEmbedSpy").should("have.callCount", 1);
});
});
});

function openFilterOptions(name) {
filterWidget().contains(name).click();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {

const { ORDERS, PRODUCTS } = SAMPLE_DATABASE;

describe("scenarios > embedding > questions ", () => {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

describe("scenarios > embedding > questions", () => {
beforeEach(() => {
restore();
cy.signInAsAdmin();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
addOrUpdateDashboardCard,
dashboardHeader,
modal,
getIframeBody,
} from "e2e/support/helpers";
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";

Expand Down Expand Up @@ -52,7 +53,9 @@ const dashboardDetails = {

describe("issue 37914", () => {
beforeEach(() => {
cy.intercept("GET", "/api/embed/dashboard/**").as("getEmbed");
cy.intercept("GET", "/api/preview_embed/dashboard/**").as(
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The preview didn't seem to work in Cypress before. I've fixed that in frontend/src/metabase/lib/dom.js

"previewDashboard",
);

restore();
cy.signInAsAdmin();
Expand Down Expand Up @@ -115,7 +118,7 @@ describe("issue 37914", () => {
cy.findByText("Preview").click();

// Makes it less likely to flake.
cy.wait("@getEmbed");
cy.wait("@previewDashboard");

getIframeBody().within(() => {
cy.log(
Expand All @@ -142,32 +145,3 @@ describe("issue 37914", () => {
});
});
});

// Code grabbed from https://www.cypress.io/blog/2020/02/12/working-with-iframes-in-cypress
const getIframeDocument = () => {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We seem to already have an existing util which does exactly this. So I'm removing a one-off util.

return (
cy
.get("iframe")
// Cypress yields jQuery element, which has the real
// DOM element under property "0".
// From the real DOM iframe element we can get
// the "document" element, it is stored in "contentDocument" property
// Cypress "its" command can access deep properties using dot notation
// https://on.cypress.io/its
.its("0.contentDocument")
.should("exist")
);
};

const getIframeBody = () => {
// get the document
return (
getIframeDocument()
// automatically retries until body is loaded
.its("body")
.should("not.be.undefined")
// wraps "body" DOM element to allow
// chaining more Cypress commands, like ".find(...)"
.then(cy.wrap)
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ import { ORDERS_ID } from "metabase-types/api/mocks/presets";
describeEE("issue 26988", () => {
beforeEach(() => {
restore();
cy.intercept("GET", "/api/embed/dashboard/*").as("dashboard");
cy.intercept("GET", "/api/preview_embed/dashboard/*").as(
"previewDashboard",
);

cy.signInAsAdmin();
setTokenFeatures("all");
Expand Down Expand Up @@ -40,7 +42,7 @@ describeEE("issue 26988", () => {
acceptTerms: false,
});

cy.wait("@dashboard");
cy.wait("@previewDashboard");
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The preview didn't seem to work in Cypress before. I've fixed that in frontend/src/metabase/lib/dom.js

getIframeBody().should("have.css", "font-family", `Lato, sans-serif`);

cy.findByLabelText("Playing with appearance options")
Expand All @@ -49,13 +51,11 @@ describeEE("issue 26988", () => {
.click();
popover().findByText("Oswald").click();

cy.wait("@dashboard");
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we're not rerendering the embed preview when changing appearance settings anymore. There would be only one @dashboard call in this test.

getIframeBody().should("have.css", "font-family", `Oswald, sans-serif`);

cy.get("@font-control").click();
popover().findByText("Slabo 27px").click();

cy.wait("@dashboard");
getIframeBody().should(
"have.css",
"font-family",
Expand Down
20 changes: 16 additions & 4 deletions frontend/src/metabase/dashboard/hoc/DashboardControls.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,12 @@ export const DashboardControls = ComposedComponent =>
this.loadDashboardParams();
}

componentDidUpdate() {
this.updateDashboardParams();
componentDidUpdate(prevProps) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've tried to not do 2-way sync between React state <-> URL and using the URL as a source of truth instead, but that failed a lot of tests. So, the safest option at this point is to do 2-way sync.

if (prevProps.location !== this.props.location) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

location can change from 2 actions

  1. Change from syncing React state to the URL or
  2. The URL is actually changed, e.g. users changing the hash in the URL, or in this case, the embed preview gets hash.

For the first, it won't trigger the render loop because we'll set the same values to redux state.
For the second scenario, that's what we want, we want the state to get updated when the URL is changed.

this.syncUrlHashToState();
} else {
this.syncStateToUrlHash();
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Other than the URL update, we can call the same function that will try to sync the state to the URL

}
this._showNav(!this.state.isFullscreen);
}

Expand Down Expand Up @@ -72,7 +76,16 @@ export const DashboardControls = ComposedComponent =>
this.setHideParameters(options.hide_parameters);
};

updateDashboardParams = () => {
syncUrlHashToState() {
const { location } = this.props;

const { refresh, fullscreen, theme } = parseHashOptions(location.hash);
this.setRefreshPeriod(refresh);
this.setFullscreen(fullscreen);
this.setTheme(theme);
}

syncStateToUrlHash = () => {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rename updateDashboardParams to syncStateToUrlHash for better understanding.

const { location, replace } = this.props;

const options = parseHashOptions(location.hash);
Expand Down Expand Up @@ -224,7 +237,6 @@ export const DashboardControls = ComposedComponent =>
hasNightModeToggle={this.state.theme !== "transparent"}
setRefreshElapsedHook={this.setRefreshElapsedHook}
loadDashboardParams={this.loadDashboardParams}
updateDashboardParams={this.updateDashboardParams}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this was unused. You can verify by using a text search of updateDashboardParams, and no file except this file uses this function.

onNightModeChange={this.setNightMode}
onFullscreenChange={this.setFullscreen}
onRefreshPeriodChange={this.setRefreshPeriod}
Expand Down
10 changes: 10 additions & 0 deletions frontend/src/metabase/hooks/use-synced-query-string.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,21 @@
import { useEffect } from "react";
import querystring from "querystring";
import { IS_EMBED_PREVIEW } from "metabase/lib/embed";

export function useSyncedQueryString(
fn: () => Record<string, any>,
deps?: any[],
) {
useEffect(() => {
/**
* We don't want to sync the query string to the URL because when previewing,
* this changes the URL of the iframe by appending the query string to the src.
* This causes the iframe to reload when changing the preview hash from appearance
* settings because now the base URL (including the query string) is different.
*/
if (IS_EMBED_PREVIEW) {
return;
}
const object = fn();
const searchString = buildSearchString(object);

Expand Down
2 changes: 1 addition & 1 deletion frontend/src/metabase/lib/dom.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ window.METABASE = true;
// used for detecting if we're previewing an embed
export const IFRAMED_IN_SELF = (function () {
try {
return window.self !== window.top && window.top.METABASE;
return window.self !== window.parent && window.parent.METABASE;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IFRAMED_IN_SELF was only used in a few places. And all of them relate to how we're rendering the embed preview.

This was failing in Cypress because there are 3 frames in Cypress

  • Cypress
    • Metabase Iframe
      • Embed preview Iframe

But in our application, there are only 2 frames

  • Metabase
    • Embed preview Iframe

For this particular code, when running in Cypress window.parent will get Cypress frame, so the function will return false, which was wrong.

Changing it to window.parent, makes sure it returns the immediate parent frame which will be Metabase frame in both cases.

} catch (e) {
return false;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ReactNode } from "react";
import { useState } from "react";
import { useEffect, useState } from "react";

import { withRouter } from "react-router";
import { connect } from "react-redux";
import cx from "classnames";
Expand All @@ -24,6 +25,8 @@ import type {
} from "metabase-types/api";
import type { State } from "metabase-types/store";

import { useDispatch } from "metabase/lib/redux";
import { setOptions } from "metabase/redux/embed";
import type Question from "metabase-lib/Question";
import { getValuePopulatedParameters } from "metabase-lib/parameters/utils/parameter-values";

Expand Down Expand Up @@ -104,6 +107,11 @@ function EmbedFrame({
initializeIframeResizer(() => setInnerScroll(false));
});

const dispatch = useDispatch();
useEffect(() => {
dispatch(setOptions(location));
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is needed for the font change to work, since font is being get from the selector which reads store.embed.options.

This essentially works the same way when we first load the embedding

store.dispatch(setOptions(window.location));

}, [dispatch, location]);

const {
bordered = isWithinIframe(),
titled = true,
Expand Down