feat(sdk, embed-js): Add Guest Embeds via Embed JS and SDK (#64850)

This commit is contained in:
Timofey Kachalov
2025-12-02 13:22:11 +04:00
committed by GitHub
parent 1591300b49
commit bfc0e2077a
343 changed files with 11895 additions and 4885 deletions

View File

@@ -8,6 +8,7 @@ export interface DashboardDetails extends Omit<CreateDashboardRequest, "name"> {
name?: string;
auto_apply_filters?: Dashboard["auto_apply_filters"];
enable_embedding?: Dashboard["enable_embedding"];
embedding_type?: Dashboard["embedding_type"];
embedding_params?: Dashboard["embedding_params"];
dashcards?: Partial<DashboardCard>[];
}
@@ -33,6 +34,7 @@ export const createDashboard = (
name = "Test Dashboard",
auto_apply_filters,
enable_embedding,
embedding_type,
embedding_params,
dashcards,
...restDashboardDetails
@@ -59,6 +61,7 @@ export const createDashboard = (
return cy.request<Dashboard>("PUT", `/api/dashboard/${body.id}`, {
auto_apply_filters,
enable_embedding,
embedding_type,
embedding_params,
dashcards,
});

View File

@@ -45,6 +45,7 @@ export type QuestionDetails = {
* Defaults to false.
*/
enable_embedding?: Card["enable_embedding"];
embedding_type?: Card["embedding_type"];
};
export type StructuredQuestionDetails = Omit<

View File

@@ -0,0 +1,31 @@
import type { DictionaryArray } from "metabase-types/api";
export const getCSVWithHeaderRow = (dictionary: DictionaryArray) => {
const header = ["Language", "String", "Translation"];
return [header, ...dictionary]
.map((row) => Object.values(row).join(","))
.join("\n");
};
export const uploadTranslationDictionaryViaAPI = (rows: DictionaryArray) => {
cy.signInAsAdmin();
const csvString = getCSVWithHeaderRow(rows);
const fileBlob = new Blob([csvString], { type: "text/csv" });
const formData = new FormData();
formData.append("file", fileBlob, "dictionary.csv");
return cy
.request({
method: "POST",
url: "/api/ee/content-translation/upload-dictionary",
body: formData,
headers: {
"Content-Type": "multipart/form-data",
},
})
.then((response) => {
expect(response.status).to.equal(200);
});
};

View File

@@ -1,5 +1,13 @@
import * as jose from "jose";
import { METABASE_SECRET_KEY } from "e2e/support/cypress_data";
import {
embedModalContent,
embedModalEnableEmbedding,
legacyStaticEmbeddingButton,
} from "e2e/support/helpers/e2e-embedding-iframe-sdk-setup-helpers";
import { modal, popover } from "e2e/support/helpers/e2e-ui-elements-helpers";
import { JWT_SHARED_SECRET } from "e2e/support/helpers/embedding-sdk-helpers/constants";
import { openSharingMenu } from "./e2e-sharing-helpers";
@@ -188,49 +196,72 @@ export function getEmbedModalSharingPane() {
}
/**
* Open Static Embedding setup modal
* Open Legacy Static Embedding setup modal
* @param {object} params
* @param {("question"|"dashboard")} [params.resource] - resource type
* @param {(string|number)} [params.resourceId] - resource id
* @param {("overview"|"parameters"|"lookAndFeel")} [params.activeTab] - modal tab to open
* @param {("code"|"preview")} [params.previewMode] - preview mode type to activate
* @param {boolean} [params.acceptTerms] - whether we need to go through the legalese step
* @param {boolean} [params.unpublishBeforeOpen] - either unpublish entity before legacy modal is opened or not
*/
export function openStaticEmbeddingModal({
export function openLegacyStaticEmbeddingModal({
resource,
resourceId,
activeTab,
previewMode,
acceptTerms = true,
confirmSave,
unpublishBeforeOpen = true,
} = {}) {
const apiPath = resource === "question" ? "card" : "dashboard";
cy.request("PUT", `/api/${apiPath}/${resourceId}`, {
enable_embedding: true,
embedding_type: "static-legacy",
});
openSharingMenu("Embed");
if (confirmSave) {
cy.findByRole("button", { name: "Save" }).click();
}
embedModalContent().should("exist");
cy.findByText("Static embedding").click();
cy.get("body").then(($body) => {
const isEmbeddingDisabled =
$body.find('[data-testid="enable-embedding-card"]').length > 0;
if (acceptTerms) {
cy.findByTestId("accept-legalese-terms-button").click();
}
modal().within(() => {
if (activeTab) {
const tabKeyToNameMap = {
overview: "Overview",
parameters: "Parameters",
lookAndFeel: "Look and Feel",
};
cy.findByRole("tab", { name: tabKeyToNameMap[activeTab] }).click();
if (isEmbeddingDisabled) {
embedModalEnableEmbedding();
}
if (previewMode) {
const previewModeToKeyMap = {
code: "Code",
preview: "Preview",
};
embedModalContent().within(() => {
legacyStaticEmbeddingButton().click();
});
cy.findByText(previewModeToKeyMap[previewMode]).click();
}
modal().within(() => {
cy.findByText("Static embedding").should("be.visible");
// Some tests after a modal is opened expect the entity to be unpublished;
// Other tests want to keep it published.
if (unpublishBeforeOpen) {
unpublishChanges(apiPath);
}
if (activeTab) {
const tabKeyToNameMap = {
overview: "Overview",
parameters: "Parameters",
lookAndFeel: "Look and Feel",
};
cy.findByRole("tab", { name: tabKeyToNameMap[activeTab] }).click();
}
if (previewMode) {
const previewModeToKeyMap = {
code: "Code",
preview: "Preview",
};
cy.findByText(previewModeToKeyMap[previewMode]).click();
}
});
});
}
@@ -241,7 +272,7 @@ export function closeStaticEmbeddingModal() {
/**
* Publish a static dashboard or question
* @param {"card" | "dashboard"} apiPath
* @param callback
* @param [callback]
*/
export function publishChanges(apiPath, callback) {
cy.intercept("PUT", `/api/${apiPath}/*`).as("publishChanges");
@@ -265,7 +296,13 @@ export function publishChanges(apiPath, callback) {
* @param callback
*/
export function unpublishChanges(apiPath, callback) {
cy.intercept("PUT", `/api/${apiPath}/*`).as("unpublishChanges");
cy.intercept("PUT", `/api/${apiPath}/*`, (req) => {
const body = req.body;
if (body?.enable_embedding === false) {
req.alias = "unpublishChanges";
}
});
cy.button("Unpublish").click();
@@ -336,3 +373,34 @@ export const visitFullAppEmbeddingUrl = ({ url, qs, onBeforeLoad }) => {
},
});
};
/**
* @param {Object} options
* @param {number} options.resourceId
* @param {'question' | 'dashboard'} options.resourceType
* @param [options.params]
* @param [options.expirationMinutes]
* @return {Promise<string>}
*/
export const getSignedJwtForResource = async ({
resourceId,
resourceType,
params = {},
expirationMinutes = 10,
}) => {
const secret = new TextEncoder().encode(JWT_SHARED_SECRET);
const iat = Math.round(new Date().getTime() / 1000);
const exp = iat + 60 * expirationMinutes;
const payload = {
resource: { [resourceType]: resourceId },
params,
iat,
exp,
};
return new jose.SignJWT(payload)
.setProtectedHeader({ alg: "HS256" })
.sign(secret);
};

View File

@@ -1,10 +1,11 @@
import { match } from "ts-pattern";
import { openSharingMenu } from "e2e/support/helpers/e2e-sharing-helpers";
import { JWT_SHARED_SECRET } from "e2e/support/helpers/embedding-sdk-helpers/constants";
import type { MetabaseTheme } from "metabase/embedding-sdk/theme/MetabaseTheme";
import type { CreateApiKeyResponse } from "metabase-types/api";
import { createApiKey } from "./api";
import { createApiKey, updateSetting } from "./api";
import { getIframeBody } from "./e2e-embedding-helpers";
import { enableJwtAuth } from "./e2e-jwt-helpers";
import { restore } from "./e2e-setup-helpers";
@@ -14,6 +15,8 @@ import {
mockAuthProviderAndJwtSignIn,
} from "./embedding-sdk-testing";
const { IS_ENTERPRISE } = Cypress.env();
const EMBED_JS_PATH = "http://localhost:4000/app/embed.js";
/**
@@ -22,6 +25,7 @@ const EMBED_JS_PATH = "http://localhost:4000/app/embed.js";
export interface BaseEmbedTestPageOptions {
// Passed to defineMetabaseConfig
metabaseConfig?: {
isGuest?: boolean;
instanceUrl?: string;
apiKey?: string;
useExistingUserSession?: boolean;
@@ -217,6 +221,47 @@ export function prepareSdkIframeEmbedTest({
}
}
/**
* Prepares the testing environment for sdk iframe embedding tests in guest embed mode.
*/
export function prepareGuestEmbedSdkIframeEmbedTest({
withTokenFeatures = true,
onPrepare,
}: {
withTokenFeatures?: boolean;
onPrepare?: () => void;
} = {}) {
restore();
cy.signInAsAdmin();
if (IS_ENTERPRISE) {
if (withTokenFeatures) {
activateToken("bleeding-edge");
} else {
activateToken("starter");
}
}
onPrepare?.();
cy.request("PUT", "/api/setting/enable-embedding-simple", {
value: true,
});
cy.request("PUT", "/api/setting/enable-embedding-static", {
value: true,
});
cy.intercept("GET", "/api/embed/card/*").as("getCard");
cy.intercept("GET", "/api/embed/card/*/query*").as("getCardQuery");
cy.intercept("GET", "/api/embed/pivot/card/*/query*").as("getCardPivotQuery");
updateSetting("embedding-secret-key", JWT_SHARED_SECRET);
mockEmbedJsToDevServer();
cy.signOut();
}
type EnabledAuthMethods = "jwt" | "saml" | "api-key";
function setupMockAuthProviders(enabledAuthMethods: EnabledAuthMethods[]) {
@@ -265,6 +310,7 @@ export const getNewEmbedScriptTag = ({
export const getNewEmbedConfigurationScript = ({
instanceUrl = "http://localhost:4000",
isGuest,
theme,
apiKey,
useExistingUserSession,
@@ -273,6 +319,7 @@ export const getNewEmbedConfigurationScript = ({
}: BaseEmbedTestPageOptions["metabaseConfig"] = {}) => {
const config = {
instanceUrl,
isGuest,
apiKey,
useExistingUserSession,
theme,
@@ -343,6 +390,4 @@ export const mockEmbedJsToDevServer = () => {
export function openEmbedJsModal() {
openSharingMenu("Embed");
cy.findByText("Embedded Analytics JS").click();
}

View File

@@ -0,0 +1,13 @@
export const embedModalContent = () =>
cy.findByTestId("sdk-iframe-embed-setup-modal-content");
export const embedModalEnableEmbeddingCard = () =>
cy.findByTestId("enable-embedding-card");
export const embedModalEnableEmbedding = () =>
embedModalEnableEmbeddingCard().within(() => {
cy.findByText(/(Agree and (continue|enable)|Enable)/).click();
});
export const legacyStaticEmbeddingButton = () =>
cy.findByTestId("legacy-static-embedding-button");

View File

@@ -105,11 +105,11 @@ export const cypressWaitAll = function (commands) {
*/
export function visitQuestion(questionIdOrAlias) {
if (typeof questionIdOrAlias === "number") {
visitQuestionById(questionIdOrAlias);
return visitQuestionById(questionIdOrAlias);
}
if (typeof questionIdOrAlias === "string") {
cy.get(questionIdOrAlias).then((id) => visitQuestionById(id));
return cy.get(questionIdOrAlias).then((id) => visitQuestionById(id));
}
}
@@ -126,6 +126,8 @@ function visitQuestionById(id) {
cy.wait("@" + metadataAlias);
cy.wait("@" + alias);
return cy.wrap(id);
}
/**

View File

@@ -0,0 +1,42 @@
import { StaticDashboard, StaticQuestion } from "@metabase/embedding-sdk-react";
import type { ComponentProps } from "react";
import { mountSdkContent } from "./component-embedding-sdk-helpers";
export function mountGuestEmbedQuestion(
extraProps: Partial<ComponentProps<typeof StaticQuestion>> = {},
{
shouldAssertCardQuery,
locale,
}: { shouldAssertCardQuery?: boolean; locale?: string } = {
shouldAssertCardQuery: true,
},
) {
cy.intercept("GET", "/api/embed/card/*").as("getCard");
cy.intercept("POST", "/api/embed/card/*/query").as("cardQuery");
cy.intercept("GET", "/api/embed/pivot/card/*/query*").as("getCardPivotQuery");
mountSdkContent(<StaticQuestion {...extraProps} />, {
sdkProviderProps: { authConfig: { isGuest: true }, locale },
});
if (shouldAssertCardQuery) {
cy.wait("@getCard").then(({ response }) => {
expect(response?.statusCode).to.equal(200);
});
}
}
export function mountGuestEmbedDashboard(
extraProps: Partial<ComponentProps<typeof StaticDashboard>> = {},
) {
cy.intercept("GET", "/api/embed/dashboard/*").as("getDashboard");
mountSdkContent(<StaticDashboard {...extraProps} />, {
sdkProviderProps: { authConfig: { isGuest: true } },
});
cy.wait("@getDashboard").then(({ response }) => {
expect(response?.statusCode).to.equal(200);
});
}

View File

@@ -6,6 +6,7 @@ import type { JSX } from "react";
import React from "react";
import { METABASE_INSTANCE_URL } from "e2e/support/helpers";
import type { DeepPartial } from "metabase/embedding-sdk/types/utils";
import { ThemeProvider } from "metabase/ui";
export const DEFAULT_SDK_AUTH_PROVIDER_CONFIG = {
@@ -26,7 +27,7 @@ export function mountSdk(
}
export interface MountSdkContentOptions extends MountSdkOptions {
sdkProviderProps?: Partial<MetabaseProviderProps>;
sdkProviderProps?: DeepPartial<MetabaseProviderProps>;
waitForUser?: boolean;
}
@@ -38,7 +39,11 @@ export function mountSdkContent(
waitForUser = true,
}: MountSdkContentOptions = {},
) {
cy.intercept("GET", "/api/user/current").as("getUser");
const isGuest = !!sdkProviderProps?.authConfig?.isGuest;
if (!isGuest) {
cy.intercept("GET", "/api/user/current").as("getUser");
}
const reactNode = (
<ThemeProvider>
@@ -60,7 +65,7 @@ export function mountSdkContent(
cy.mount(reactNode);
}
if (waitForUser) {
if (!isGuest && waitForUser) {
// When running stress tests with network throttling, the request can take longer to complete
// as it first needs to fetch the bundle from the server
cy.wait("@getUser", { timeout: 20_000 }).then(({ response }) => {

View File

@@ -40,7 +40,7 @@ export function mountInteractiveQuestion(
export function mountStaticQuestion(
extraProps: Partial<ComponentProps<typeof StaticQuestion>> = {},
{ shouldAssertCardQuery }: MountQuestionOptions = {
{ shouldAssertCardQuery, sdkProviderProps }: MountQuestionOptions = {
shouldAssertCardQuery: true,
},
) {
@@ -48,7 +48,10 @@ export function mountStaticQuestion(
cy.intercept("POST", "/api/card/*/query").as("cardQuery");
cy.get<number>("@questionId").then((questionId) => {
mountSdkContent(<StaticQuestion questionId={questionId} {...extraProps} />);
mountSdkContent(
<StaticQuestion questionId={questionId} {...extraProps} />,
{ sdkProviderProps },
);
});
if (shouldAssertCardQuery) {

View File

@@ -1,3 +1,4 @@
export * from "./component-embedding-sdk-helpers";
export * from "./component-embedding-sdk-question-helpers";
export * from "./component-embedding-sdk-console-helpers";
export * from "./component-embedding-sdk-guest-embeds-helpers";

View File

@@ -8,6 +8,7 @@ import {
METABASE_INSTANCE_URL,
activateToken,
restore,
updateSetting,
} from "e2e/support/helpers";
import { enableJwtAuth } from "e2e/support/helpers/e2e-jwt-helpers";
@@ -77,6 +78,21 @@ export function signInAsAdminAndEnableEmbeddingSdk() {
});
}
export function signInAsAdminAndSetupGuestEmbedding({
token,
}: {
token: "starter" | "pro-cloud";
}) {
Cypress.config("baseUrl", METABASE_INSTANCE_URL);
restore();
cy.signInAsAdmin();
activateToken(token);
updateSetting("embedding-secret-key", JWT_SHARED_SECRET);
}
const MOCK_SAML_IDP_URI = "https://example.test/saml";
export function enableSamlAuth() {

View File

@@ -27,6 +27,7 @@ export * from "./e2e-email-helpers";
export * from "./e2e-embedding-helpers";
export * from "./e2e-embedding-iframe-interactive-helpers";
export * from "./e2e-embedding-iframe-sdk-helpers";
export * from "./e2e-embedding-iframe-sdk-setup-helpers";
export * from "./e2e-embedding-sdk-assertion-helpers";
export * from "./e2e-enterprise-helpers";
export * from "./e2e-filter-helpers";

View File

@@ -0,0 +1,80 @@
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
import { createQuestion, getSignedJwtForResource } from "e2e/support/helpers";
import { uploadTranslationDictionaryViaAPI } from "e2e/support/helpers/e2e-content-translation-helpers";
import { getSdkRoot } from "e2e/support/helpers/e2e-embedding-sdk-helpers";
import { mountGuestEmbedQuestion } from "e2e/support/helpers/embedding-sdk-component-testing";
import { signInAsAdminAndSetupGuestEmbedding } from "e2e/support/helpers/embedding-sdk-testing";
import type { Card } from "metabase-types/api";
const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE;
describe("scenarios > embedding-sdk > guest-embed-content-translations", () => {
describe("question", () => {
const setup = ({ display }: { display?: Card["display"] } = {}) => {
signInAsAdminAndSetupGuestEmbedding({
token: "pro-cloud",
});
createQuestion({
name: "Question for Guest Embed SDK",
enable_embedding: true,
embedding_type: "guest-embed",
query: {
"source-table": ORDERS_ID,
aggregation: [["max", ["field", ORDERS.QUANTITY, null]]],
breakout: [["field", ORDERS.PRODUCT_ID, null]],
limit: 2,
},
display,
}).then(({ body: question }) => {
cy.wrap(question.id).as("questionId");
});
cy.signOut();
};
describe("content translation", () => {
it("should show question content with applied content translation", () => {
setup();
cy.signInAsAdmin();
uploadTranslationDictionaryViaAPI([
{
locale: "de",
msgid: "Question for Guest Embed SDK",
msgstr: "Override title für Deutsch",
},
{
locale: "de",
msgid: "Product ID",
msgstr: "Override Product ID für Deutsch",
},
]);
cy.signOut();
cy.get("@questionId").then(async (questionId) => {
const token = await getSignedJwtForResource({
resourceId: questionId as unknown as number,
resourceType: "question",
});
mountGuestEmbedQuestion(
{ token, title: true },
{
locale: "de",
},
);
getSdkRoot().within(() => {
cy.findByText("Override title für Deutsch").should("be.visible");
cy.findByText("Override Product ID für Deutsch").should(
"be.visible",
);
});
});
});
});
});
});

View File

@@ -0,0 +1,91 @@
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
import { createQuestion, getSignedJwtForResource } from "e2e/support/helpers";
import { getSdkRoot } from "e2e/support/helpers/e2e-embedding-sdk-helpers";
import { mountGuestEmbedQuestion } from "e2e/support/helpers/embedding-sdk-component-testing";
import { signInAsAdminAndSetupGuestEmbedding } from "e2e/support/helpers/embedding-sdk-testing";
const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE;
describe("scenarios > embedding-sdk > guest-embed-error-handling", () => {
beforeEach(() => {
signInAsAdminAndSetupGuestEmbedding({
token: "starter",
});
createQuestion({
name: "47563",
enable_embedding: true,
embedding_type: "guest-embed",
query: {
"source-table": ORDERS_ID,
aggregation: [["max", ["field", ORDERS.QUANTITY, null]]],
breakout: [["field", ORDERS.PRODUCT_ID, null]],
limit: 2,
},
}).then(({ body: question }) => {
cy.wrap(question.id).as("questionId");
});
cy.signOut();
});
it("should show JWT token error for invalid token", () => {
cy.get("@questionId").then(async () => {
mountGuestEmbedQuestion(
{ token: "foo" },
{
shouldAssertCardQuery: false,
},
);
getSdkRoot().within(() => {
cy.findByText("Passed token is not a valid JWT token.").should(
"be.visible",
);
});
});
});
it("should show an error for an unpublished entity", () => {
cy.get("@questionId").then(async (questionId) => {
cy.signInAsAdmin();
cy.request("PUT", `/api/card/${questionId}`, { enable_embedding: false });
cy.signOut();
const token = await getSignedJwtForResource({
resourceId: questionId as unknown as number,
resourceType: "question",
});
mountGuestEmbedQuestion(
{ token },
{
shouldAssertCardQuery: false,
},
);
getSdkRoot().within(() => {
cy.findByText("Embedding is not enabled for this object.").should(
"be.visible",
);
});
});
});
it("should show an error when an id is passed instead of a token", () => {
cy.get("@questionId").then(async (questionId) => {
mountGuestEmbedQuestion(
{ questionId },
{
shouldAssertCardQuery: false,
},
);
getSdkRoot().within(() => {
cy.findByText(
"A valid JWT token is required to be passed in guest embeds mode.",
).should("be.visible");
});
});
});
});

View File

@@ -0,0 +1,519 @@
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
import {
type NativeQuestionDetails,
type StructuredQuestionDetails,
createNativeQuestion,
createNativeQuestionAndDashboard,
createQuestion,
createQuestionAndDashboard,
getSignedJwtForResource,
} from "e2e/support/helpers";
import { getSdkRoot } from "e2e/support/helpers/e2e-embedding-sdk-helpers";
import {
mountGuestEmbedDashboard,
mountGuestEmbedQuestion,
} from "e2e/support/helpers/embedding-sdk-component-testing";
import { signInAsAdminAndSetupGuestEmbedding } from "e2e/support/helpers/embedding-sdk-testing";
import { questionAsPinMapWithTiles } from "e2e/test/scenarios/embedding/shared/embedding-questions";
import type { Card } from "metabase-types/api";
import { createMockParameter } from "metabase-types/api/mocks";
const { ORDERS, ORDERS_ID, PEOPLE_ID } = SAMPLE_DATABASE;
const WAIT_FOR_INTERNAL_API_REQUESTS_MS = 1000;
const PARAMETERS = [
createMockParameter({
id: "quantity",
name: "Internal",
slug: "quantity",
type: "number/=",
target: ["dimension", ["template-tag", "quantity"]],
}),
createMockParameter({
id: "product_id_fk",
name: "FK",
slug: "product_id_fk",
type: "id",
target: ["dimension", ["template-tag", "product_id_fk"]],
}),
createMockParameter({
id: "user_id_pk",
name: "PK->Name",
slug: "user_id_pk",
type: "id",
target: ["dimension", ["template-tag", "user_id_pk"]],
values_source_type: "static-list",
values_source_config: {
values: ["74"],
},
}),
];
const TEMPLATE_TAGS = {
quantity: {
id: "quantity",
name: "quantity",
"display-name": "Internal",
type: "dimension",
"widget-type": "number/=",
dimension: ["field", ORDERS.QUANTITY, null],
},
product_id_fk: {
id: "product_id_fk",
name: "product_id_fk",
"display-name": "FK",
type: "dimension",
"widget-type": "id",
dimension: ["field", ORDERS.PRODUCT_ID, null],
},
user_id_pk: {
id: "user_id_pk",
name: "user_id_pk",
"display-name": "PK->Name",
type: "dimension",
"widget-type": "id",
dimension: ["field", PEOPLE_ID, null],
},
};
describe("scenarios > embedding-sdk > guest-embed-happy-path", () => {
describe("question", () => {
const setup = ({
display,
createQuestionOverride,
}: {
display?: Card["display"];
createQuestionOverride?: () => void;
} = {}) => {
signInAsAdminAndSetupGuestEmbedding({
token: "starter",
});
if (!createQuestionOverride) {
createQuestion({
name: "Question for Guest Embed SDK",
enable_embedding: true,
embedding_type: "guest-embed",
query: {
"source-table": ORDERS_ID,
aggregation: [["max", ["field", ORDERS.QUANTITY, null]]],
breakout: [["field", ORDERS.PRODUCT_ID, null]],
limit: 2,
},
display,
}).then(({ body: question }) => {
cy.wrap(question.id).as("questionId");
});
} else {
createQuestionOverride();
}
cy.signOut();
};
it("should show question content for unauthorized user", () => {
setup();
cy.intercept("/api/user/*").as("internalApiRequest");
cy.intercept("/api/card/*").as("internalApiRequest");
cy.get("@questionId").then(async (questionId) => {
const token = await getSignedJwtForResource({
resourceId: questionId as unknown as number,
resourceType: "question",
});
mountGuestEmbedQuestion({ token });
getSdkRoot().within(() => {
cy.findByText("Product ID").should("be.visible");
cy.findByText("Max of Quantity").should("be.visible");
});
cy.get("@internalApiRequest.all").then((interceptions) => {
expect(interceptions).to.have.length(0);
});
});
});
it("should show content of a question with `pivot table` type for unauthorized user", () => {
setup({ display: "pivot" });
cy.intercept("/api/card/pivot/*/query*").as("internalApiRequest");
cy.get("@questionId").then(async (questionId) => {
const token = await getSignedJwtForResource({
resourceId: questionId as unknown as number,
resourceType: "question",
});
mountGuestEmbedQuestion(
{ token },
{
shouldAssertCardQuery: false,
},
);
cy.wait("@getCardPivotQuery");
getSdkRoot().within(() => {
cy.findByText("Product ID").should("be.visible");
cy.findByText("Max of Quantity").should("be.visible");
});
cy.get("@internalApiRequest.all").then((interceptions) => {
expect(interceptions).to.have.length(0);
});
});
});
it("should show content of a question with `map` type with tiles for unauthorized user", () => {
setup({
createQuestionOverride: () => {
createNativeQuestion(
questionAsPinMapWithTiles as NativeQuestionDetails,
{
wrapId: true,
},
);
},
});
cy.intercept("GET", "/api/embed/tiles/card/**").as("mapTilesApiRequest");
cy.get("@questionId").then(async (questionId) => {
const token = await getSignedJwtForResource({
resourceId: questionId as unknown as number,
resourceType: "question",
});
mountGuestEmbedQuestion(
{ token },
{
shouldAssertCardQuery: false,
},
);
cy.wait("@mapTilesApiRequest");
cy.get("@mapTilesApiRequest.all").then((interceptions) => {
expect(interceptions).to.have.length.greaterThan(1);
});
});
});
it("should properly work with question parameters", () => {
setup({
createQuestionOverride: () => {
createNativeQuestion(
{
name: "Orders native question",
native: {
query:
"SELECT * " +
"FROM ORDERS " +
"JOIN PEOPLE ON ORDERS.USER_ID = PEOPLE.ID " +
"WHERE {{quantity}} AND {{product_id_fk}} AND {{user_id_pk}}",
"template-tags": TEMPLATE_TAGS,
},
parameters: PARAMETERS,
enable_embedding: true,
embedding_params: {
quantity: "enabled",
product_id_fk: "locked",
user_id_pk: "enabled",
},
},
{
wrapId: true,
},
);
},
});
cy.intercept("GET", "/api/embed/card/*/params/*/values").as(
"parameterValuesApiRequest",
);
cy.get("@questionId").then(async (questionId) => {
const token = await getSignedJwtForResource({
resourceId: questionId as unknown as number,
resourceType: "question",
params: {
product_id_fk: 1,
},
});
mountGuestEmbedQuestion(
{ token },
{
shouldAssertCardQuery: false,
},
);
getSdkRoot().within(() => {
cy.findAllByTestId("parameter-widget").should("have.length", 2);
cy.findAllByTestId("parameter-widget")
.filter(':contains("PK->Name")')
.click();
});
cy.wait("@parameterValuesApiRequest");
cy.get("@parameterValuesApiRequest.all").then((interceptions) => {
expect(interceptions).to.have.length(1);
});
cy.findByTestId("table-body")
.findAllByRole("row")
.should("have.length.above", 10);
getSdkRoot().within(() => {
cy.findByTestId("parameter-value-dropdown").contains("74").click();
cy.findByTestId("parameter-value-dropdown")
.contains("Add filter")
.click();
cy.findByTestId("table-body")
.findAllByRole("row")
.should("have.length", 1);
});
});
});
});
describe("dashboard", () => {
const setup = ({
display,
questionDetails,
}: {
display?: Card["display"];
questionDetails?: StructuredQuestionDetails;
} = {}) => {
signInAsAdminAndSetupGuestEmbedding({
token: "starter",
});
createQuestionAndDashboard({
questionDetails: questionDetails ?? {
name: "Sample Question",
query: {
"source-table": ORDERS_ID,
aggregation: [["count"]],
breakout: [
["field", ORDERS.CREATED_AT, { "temporal-unit": "month" }],
["field", ORDERS.QUANTITY, null],
],
},
display,
},
dashboardDetails: {
name: "Embedding SDK Test Dashboard",
enable_embedding: true,
embedding_type: "guest-embed",
},
}).then(({ body: { dashboard_id } }) => {
cy.wrap(dashboard_id).as("dashboardId");
});
cy.signOut();
};
it("should show dashboard content for unauthorized user", () => {
cy.intercept("/api/user/*").as("internalApiRequest");
cy.intercept("/api/dashboard/*").as("internalApiRequest");
setup();
cy.get("@dashboardId").then(async (dashboardId) => {
const token = await getSignedJwtForResource({
resourceId: dashboardId as unknown as number,
resourceType: "dashboard",
});
mountGuestEmbedDashboard({ token });
getSdkRoot().within(() => {
cy.findByText("Embedding SDK Test Dashboard").should("be.visible");
cy.findByText("Sample Question").should("be.visible");
});
// Wait for requests
cy.wait(WAIT_FOR_INTERNAL_API_REQUESTS_MS);
cy.get("@internalApiRequest.all").then((interceptions) => {
expect(interceptions).to.have.length(0);
});
});
});
it("should show dashboard content with a pivot card for unauthorized user", () => {
cy.intercept("/api/user/*").as("internalApiRequest");
cy.intercept("/api/dashboard/*").as("internalApiRequest");
setup({ display: "pivot" });
cy.get("@dashboardId").then(async (dashboardId) => {
const token = await getSignedJwtForResource({
resourceId: dashboardId as unknown as number,
resourceType: "dashboard",
});
mountGuestEmbedDashboard({ token });
getSdkRoot().within(() => {
cy.findByText("Embedding SDK Test Dashboard").should("be.visible");
});
// Wait for requests
cy.wait(WAIT_FOR_INTERNAL_API_REQUESTS_MS);
cy.get("@internalApiRequest.all").then((interceptions) => {
expect(interceptions).to.have.length(0);
});
});
});
it("should show content of a question with `map` type with tiles for unauthorized user", () => {
setup({
questionDetails: {
...questionAsPinMapWithTiles,
query: {
"source-table": PEOPLE_ID,
},
} as StructuredQuestionDetails,
});
cy.intercept("GET", "/api/embed/tiles/dashboard/**").as(
"mapTilesApiRequest",
);
cy.get("@dashboardId").then(async (dashboardId) => {
const token = await getSignedJwtForResource({
resourceId: dashboardId as unknown as number,
resourceType: "dashboard",
});
mountGuestEmbedDashboard({ token });
cy.wait("@mapTilesApiRequest");
cy.get("@mapTilesApiRequest.all").then((interceptions) => {
expect(interceptions).to.have.length.greaterThan(1);
});
});
});
it("should properly work with dashboard parameters", () => {
signInAsAdminAndSetupGuestEmbedding({
token: "starter",
});
createNativeQuestionAndDashboard({
questionDetails: {
name: "Orders native question",
native: {
query:
"SELECT * " +
"FROM ORDERS " +
"JOIN PEOPLE ON ORDERS.USER_ID = PEOPLE.ID " +
"WHERE {{quantity}} AND {{product_id_fk}} AND {{user_id_pk}}",
"template-tags": TEMPLATE_TAGS,
},
},
dashboardDetails: {
name: "Embedding SDK Test Dashboard",
embedding_type: "guest-embed",
parameters: PARAMETERS,
enable_embedding: true,
embedding_params: {
quantity: "enabled",
product_id_fk: "locked",
user_id_pk: "enabled",
},
},
}).then(({ body: { id: dashcardId, card_id, dashboard_id } }) => {
// Connect dashboard parameters to the card's template-tags
cy.request("PUT", `/api/dashboard/${dashboard_id}`, {
dashcards: [
{
id: dashcardId,
card_id,
row: 0,
col: 0,
size_x: 16,
size_y: 8,
parameter_mappings: [
{
parameter_id: "quantity",
card_id,
target: ["dimension", ["template-tag", "quantity"]],
},
{
parameter_id: "product_id_fk",
card_id,
target: ["dimension", ["template-tag", "product_id_fk"]],
},
{
parameter_id: "user_id_pk",
card_id,
target: ["dimension", ["template-tag", "user_id_pk"]],
},
],
},
],
});
cy.wrap(dashboard_id).as("dashboardId");
});
cy.signOut();
cy.intercept("GET", "/api/embed/dashboard/*/params/*/values").as(
"parameterValuesApiRequest",
);
cy.get("@dashboardId").then(async (dashboardId) => {
const token = await getSignedJwtForResource({
resourceId: dashboardId as unknown as number,
resourceType: "dashboard",
params: {
product_id_fk: 1,
},
});
mountGuestEmbedDashboard({ token });
getSdkRoot().within(() => {
cy.findAllByTestId("parameter-widget").should("have.length", 2);
cy.findAllByTestId("parameter-widget")
.filter(':contains("PK->Name")')
.click();
});
cy.wait("@parameterValuesApiRequest");
cy.get("@parameterValuesApiRequest.all").then((interceptions) => {
expect(interceptions).to.have.length(1);
});
cy.findByTestId("table-body")
.findAllByRole("row")
.should("have.length.above", 10);
getSdkRoot().within(() => {
cy.findByTestId("parameter-value-dropdown").contains("74").click();
cy.findByTestId("parameter-value-dropdown")
.contains("Add filter")
.click();
cy.findByTestId("table-body")
.findAllByRole("row")
.should("have.length", 1);
});
});
});
});
});

View File

@@ -354,16 +354,24 @@ const MODEL_NAME = "Test Action Model";
});
cy.visit(`/dashboard/${dashboard.id}`);
cy.wrap(dashboard.id).as("dashboardId");
cy.log("The action should be visible in the dashboard");
cy.findByRole("button", { name: "Create" }).should(
"be.visible",
);
cy.log("Visit static embed dashboard");
H.openLegacyStaticEmbeddingModal({
resource: "dashboard",
resourceId: dashboard.id,
activeTab: "parameters",
unpublishBeforeOpen: false,
});
},
);
});
cy.log("The action should be visible in the dashboard");
cy.findByRole("button", { name: "Create" }).should("be.visible");
cy.log("Visit static embed dashboard");
H.openStaticEmbeddingModal({ activeTab: "parameters" });
H.visitIframe();
cy.log("Assert static embed dashboard");

View File

@@ -7,6 +7,7 @@ import type {
DashboardDetails,
StructuredQuestionDetails,
} from "e2e/support/helpers";
import { uploadTranslationDictionaryViaAPI } from "e2e/support/helpers/e2e-content-translation-helpers";
import {
ORDERS_COUNT_BY_CREATED_AT_AND_PRODUCT_CATEGORY,
PRODUCTS_COUNT_BY_CATEGORY_PIE,
@@ -22,11 +23,10 @@ import {
germanFieldNames,
germanFieldValues,
} from "./constants";
import { uploadTranslationDictionaryViaAPI } from "./helpers/e2e-content-translation-helpers";
const { H } = cy;
describe("scenarios > content translation > static embedding > dashboards", () => {
describe("scenarios > content translation > static embeds > dashboards", () => {
describe("pivot table renamed column (metabase#63296)", () => {
beforeEach(() => {
H.restore();
@@ -85,8 +85,9 @@ describe("scenarios > content translation > static embedding > dashboards", () =
H.saveDashboard();
H.openStaticEmbeddingModal({
acceptTerms: false,
H.openLegacyStaticEmbeddingModal({
resource: "dashboard",
resourceId: dashboardId,
});
H.publishChanges("dashboard", () => {});
@@ -156,7 +157,7 @@ describe("scenarios > content translation > static embedding > dashboards", () =
);
});
it("should translate static embedding dashboard card titles and descriptions", () => {
it("should translate guest embeds dashboard card titles and descriptions", () => {
H.createDashboard({
name: "the_dashboard",
}).then(({ body: { id: dashboardId } }) => {
@@ -167,8 +168,9 @@ describe("scenarios > content translation > static embedding > dashboards", () =
H.sidebar().findByText(PRODUCTS_COUNT_BY_CATEGORY_PIE.name).click();
H.saveDashboard();
H.openStaticEmbeddingModal({
acceptTerms: false,
H.openLegacyStaticEmbeddingModal({
resource: "dashboard",
resourceId: dashboardId,
});
H.publishChanges("dashboard", () => {});
@@ -263,8 +265,9 @@ describe("scenarios > content translation > static embedding > dashboards", () =
H.sidebar().findByText(PRODUCTS_COUNT_BY_CATEGORY_PIE.name).click();
H.saveDashboard();
H.openStaticEmbeddingModal({
acceptTerms: false,
H.openLegacyStaticEmbeddingModal({
resource: "dashboard",
resourceId: dashboardId,
});
H.publishChanges("dashboard", () => {});
@@ -307,7 +310,7 @@ describe("scenarios > content translation > static embedding > dashboards", () =
});
});
it("should translate static embedding dashboard values on visualizer cards (metabase#62373)", () => {
it("should translate guest embeds dashboard values on visualizer cards (metabase#62373)", () => {
H.visitDashboard(ORDERS_DASHBOARD_ID);
H.editDashboard();
@@ -327,8 +330,9 @@ describe("scenarios > content translation > static embedding > dashboards", () =
H.saveDashcardVisualizerModal({ mode: "create" });
H.saveDashboard();
H.openStaticEmbeddingModal({
acceptTerms: false,
H.openLegacyStaticEmbeddingModal({
resource: "dashboard",
resourceId: ORDERS_DASHBOARD_ID,
});
H.publishChanges("dashboard", () => {});

View File

@@ -1,23 +1,17 @@
import { parse } from "csv-parse/browser/esm/sync";
import { METABASE_SECRET_KEY } from "e2e/support/cypress_data";
import { getCSVWithHeaderRow } from "e2e/support/helpers/e2e-content-translation-helpers";
import type { DictionaryArray, DictionaryResponse } from "metabase-types/api";
import { isDictionaryArray } from "metabase-types/guards/content-translation";
export const getCSVWithHeaderRow = (dictionary: DictionaryArray) => {
const header = ["Language", "String", "Translation"];
return [header, ...dictionary]
.map((row) => Object.values(row).join(","))
.join("\n");
};
export const uploadTranslationDictionary = (rows: DictionaryArray) => {
cy.intercept("POST", "/api/ee/content-translation/upload-dictionary").as(
"uploadDictionary",
);
cy.intercept("GET", "/api/setting").as("getSettings");
cy.signInAsAdmin();
cy.visit("/admin/embedding/static");
cy.visit("/admin/embedding");
cy.wait("@getSettings");
cy.findByTestId("content-localization-setting").findByText(
@@ -44,27 +38,6 @@ export const uploadTranslationDictionary = (rows: DictionaryArray) => {
}).scrollIntoView();
};
export const uploadTranslationDictionaryViaAPI = (rows: DictionaryArray) => {
cy.signInAsAdmin();
const csvString = getCSVWithHeaderRow(rows);
const fileBlob = new Blob([csvString], { type: "text/csv" });
const formData = new FormData();
formData.append("file", fileBlob, "dictionary.csv");
return cy
.request({
method: "POST",
url: "/api/ee/content-translation/upload-dictionary",
body: formData,
headers: {
"Content-Type": "multipart/form-data",
},
})
.then((response) => {
expect(response.status).to.equal(200);
});
};
export const assertOnlyTheseTranslationsAreStored = (
rows: DictionaryArray,
locale = "de",

View File

@@ -1,14 +1,14 @@
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
import { NORMAL_USER_ID } from "e2e/support/cypress_sample_instance_data";
import { uploadTranslationDictionaryViaAPI } from "e2e/support/helpers/e2e-content-translation-helpers";
import { germanFieldNames } from "./constants";
import { uploadTranslationDictionaryViaAPI } from "./helpers/e2e-content-translation-helpers";
const { PRODUCTS_ID } = SAMPLE_DATABASE;
const { H } = cy;
describe("scenarios > content translation > static embedding > questions", () => {
describe("scenarios > content translation > guest embeds > questions", () => {
describe("ee", () => {
beforeEach(() => {
cy.intercept("POST", "/api/ee/content-translation/upload-dictionary").as(

View File

@@ -1,5 +1,10 @@
import path from "path";
import {
getCSVWithHeaderRow,
uploadTranslationDictionaryViaAPI,
} from "e2e/support/helpers/e2e-content-translation-helpers";
import {
germanFieldNames,
invalidLocaleXX,
@@ -11,14 +16,12 @@ import {
import {
assertOnlyTheseTranslationsAreStored,
generateLargeCSV,
getCSVWithHeaderRow,
uploadTranslationDictionary,
uploadTranslationDictionaryViaAPI,
} from "./helpers/e2e-content-translation-helpers";
const { H } = cy;
describe("scenarios > admin > embedding > static embedding> content translation", () => {
describe("scenarios > admin > embedding > guest embeds> content translation", () => {
describe("oss", () => {
beforeEach(() => {
H.restore();
@@ -26,7 +29,7 @@ describe("scenarios > admin > embedding > static embedding> content translation"
});
it("admin settings configuration form is not present", () => {
cy.visit("/admin/embedding/static");
cy.visit("/admin/embedding/guest");
cy.findByTestId("content-translation-configuration").should("not.exist");
});
});
@@ -52,7 +55,7 @@ describe("scenarios > admin > embedding > static embedding> content translation"
describe("The translation download button", () => {
it("downloads the stored translations", () => {
uploadTranslationDictionaryViaAPI(germanFieldNames);
cy.visit("/admin/embedding/static");
cy.visit("/admin/embedding");
cy.findByTestId("content-translation-configuration")
.button(/Get translation dictionary template/i)
.click();
@@ -180,7 +183,7 @@ describe("scenarios > admin > embedding > static embedding> content translation"
});
it("rejects, in the frontend, a CSV upload that is too big", () => {
cy.visit("/admin/embedding/static");
cy.visit("/admin/embedding");
cy.get("#content-translation-dictionary-upload-input").selectFile(
{
contents: Cypress.Buffer.from(
@@ -206,7 +209,7 @@ describe("scenarios > admin > embedding > static embedding> content translation"
});
it("rejects invalid CSV", () => {
cy.visit("/admin/embedding/static");
cy.visit("/admin/embedding");
const validCSV = getCSVWithHeaderRow(germanFieldNames);
const invalidCSV = validCSV + '\nde,Price,"Preis"X';

View File

@@ -1958,12 +1958,15 @@ describe("scenarios > dashboard > dashboard cards > click behavior", () => {
})
.then(({ body: dashCard }) => {
H.visitDashboard(dashCard.dashboard_id);
H.openLegacyStaticEmbeddingModal({
resource: "dashboard",
resourceId: dashCard.dashboard_id,
activeTab: "parameters",
unpublishBeforeOpen: false,
});
});
H.openStaticEmbeddingModal({
activeTab: "parameters",
acceptTerms: false,
});
H.visitIframe();
clickLineChartPoint();

View File

@@ -1123,7 +1123,10 @@ describe("scenarios > dashboard", () => {
cy.contains("37.65");
assertScrollBarExists();
H.openSharingMenu("Embed");
H.openLegacyStaticEmbeddingModal({
resource: "dashboard",
resourceId: ORDERS_DASHBOARD_ID,
});
H.modal().within(() => {
cy.icon("close").click();

View File

@@ -554,7 +554,11 @@ describe("scenarios > dashboard > tabs", () => {
cy.spy().as("secondTabQuerySpy"),
).as("secondTabQuery");
H.openStaticEmbeddingModal({ activeTab: "parameters", acceptTerms: true });
H.openLegacyStaticEmbeddingModal({
resource: "dashboard",
resourceId: ORDERS_DASHBOARD_ID,
activeTab: "parameters",
});
// publish the embedded dashboard so that we can directly navigate to its url
H.publishChanges("dashboard", () => {});

View File

@@ -15,48 +15,43 @@ describe(
cy.log("Navigate to Embedding admin section");
cy.visit("/admin/embedding");
cy.log("Check that we're on the modular embedding page");
cy.url().should("include", "/admin/embedding/modular");
cy.get("main").findByText("Modular embedding").should("be.visible");
cy.log("Verify sidebar contains static smbedding link");
cy.findByTestId("admin-layout-sidebar")
.findByRole("link", { name: /Static/ })
.should("have.attr", "href", "/admin/embedding/static");
cy.log("Verify sidebar contains interactive embedding");
cy.findByTestId("admin-layout-sidebar")
.findByRole("link", { name: /Interactive/ })
.should("have.attr", "href", "/admin/embedding/interactive");
cy.log("Check that we're on the embedding settings page");
cy.url().should("include", "/admin/embedding");
cy.get("main").findByText("Embedding settings").should("be.visible");
cy.log("Verify sidebar does not contain setup guide");
cy.findByTestId("admin-layout-sidebar")
.findByRole("link", { name: /Setup guide/ })
.should("not.exist");
cy.log("Verify 2 upsell icons are present in sidebar");
cy.log("Verify sidebar does not contain guest embeds link");
cy.findByTestId("admin-layout-sidebar")
.icon("gem")
.should("have.length", 2);
.findByRole("link", { name: /Guest embeds/ })
.should("not.exist");
cy.log("Verify sidebar does not contain security settings link");
cy.findByTestId("admin-layout-sidebar")
.findByRole("link", { name: /Security/ })
.should("not.exist");
});
it("should show interactive embedding upsell on oss", () => {
it("should show embedding upsell on oss", () => {
cy.visit("/admin/embedding/interactive");
cy.findByTestId("admin-layout-content").within(() => {
cy.findByRole("heading", { name: "Interactive embedding" }).should(
cy.findByRole("heading", { name: "Embedding settings" }).should(
"be.visible",
);
cy.log("upsell gem icon should be visible");
cy.icon("gem").should("be.visible");
cy.findByRole("link", { name: /Check out our Quickstart/i })
.should("be.visible")
.and("have.attr", "href")
.and("contain", "interactive-embedding-quick-start-guide");
cy.findByRole("link", { name: "Try for free" }).should("be.visible");
cy.findByRole("link", { name: "Upgrade" })
.should("have.attr", "href")
.and(
"eq",
"https://www.metabase.com/upgrade?utm_source=product&utm_medium=upsell&utm_content=embedding-page&source_plan=oss&utm_users=10&utm_campaign=embedded-analytics-js",
);
});
});
},

View File

@@ -68,9 +68,12 @@ describe("scenarios > embedding > dashboard parameters", () => {
it("should be disabled by default but able to be set to editable and/or locked (metabase#20357)", () => {
H.visitDashboard("@dashboardId");
H.openStaticEmbeddingModal({
activeTab: "parameters",
acceptTerms: true,
cy.get("@dashboardId").then((dashboardId) => {
H.openLegacyStaticEmbeddingModal({
resource: "dashboard",
resourceId: dashboardId,
activeTab: "parameters",
});
});
cy.findByLabelText("Configuring parameters").as("allParameters");
@@ -119,6 +122,9 @@ describe("scenarios > embedding > dashboard parameters", () => {
assert.deepEqual(request.body.embedding_params, {
id: "locked",
name: "enabled",
source: "disabled",
user_id: "disabled",
not_used: "disabled",
});
});
@@ -153,11 +159,14 @@ describe("scenarios > embedding > dashboard parameters", () => {
H.visitDashboard("@dashboardId");
H.openStaticEmbeddingModal({
activeTab: "parameters",
acceptTerms: false,
cy.get("@dashboardId").then((dashboardId) => {
H.openLegacyStaticEmbeddingModal({
resource: "dashboard",
resourceId: dashboardId,
activeTab: "parameters",
unpublishBeforeOpen: false,
});
});
cy.get("@allParameters").findByText("Locked").click();
H.popover().contains("Disabled").click();
@@ -168,6 +177,9 @@ describe("scenarios > embedding > dashboard parameters", () => {
assert.deepEqual(request.body.embedding_params, {
name: "disabled",
id: "disabled",
source: "disabled",
user_id: "disabled",
not_used: "disabled",
});
});
@@ -235,11 +247,15 @@ describe("scenarios > embedding > dashboard parameters", () => {
});
H.saveDashboard();
// Check that parameter visibility is correct
H.openStaticEmbeddingModal({
activeTab: "parameters",
acceptTerms: true,
cy.get("@dashboardId").then((dashboardId) => {
H.openLegacyStaticEmbeddingModal({
resource: "dashboard",
resourceId: dashboardId,
activeTab: "parameters",
});
});
// Check that parameter visibility is correct
H.assertEmbeddingParameter("Id", "Disabled");
H.assertEmbeddingParameter("Name", "Editable");
H.assertEmbeddingParameter("Source", "Disabled");
@@ -251,7 +267,11 @@ describe("scenarios > embedding > dashboard parameters", () => {
// "enabled" must be set by default for required params.
H.publishChanges("dashboard", ({ request }) => {
assert.deepEqual(request.body.embedding_params, {
id: "disabled",
name: "enabled",
source: "disabled",
user_id: "disabled",
not_used: "disabled",
});
});
@@ -268,17 +288,24 @@ describe("scenarios > embedding > dashboard parameters", () => {
it("should (dis)allow setting parameters as required for a published embedding", () => {
H.visitDashboard("@dashboardId");
// Set an "editable" and "locked" parameters and leave the rest "disabled"
H.openStaticEmbeddingModal({
activeTab: "parameters",
acceptTerms: true,
cy.get("@dashboardId").then((dashboardId) => {
H.openLegacyStaticEmbeddingModal({
resource: "dashboard",
resourceId: dashboardId,
activeTab: "parameters",
});
});
// Set an "editable" and "locked" parameters and leave the rest "disabled"
H.setEmbeddingParameter("Name", "Editable");
H.setEmbeddingParameter("Source", "Locked");
H.publishChanges("dashboard", ({ request }) => {
assert.deepEqual(request.body.embedding_params, {
id: "disabled",
name: "enabled",
source: "locked",
user_id: "disabled",
not_used: "disabled",
});
});
@@ -394,9 +421,13 @@ describe("scenarios > embedding > dashboard parameters", () => {
it("should allow searching dashboard parameters in preview embed modal", () => {
H.visitDashboard("@dashboardId");
H.openStaticEmbeddingModal({
activeTab: "parameters",
previewMode: "preview",
cy.get("@dashboardId").then((dashboardId) => {
H.openLegacyStaticEmbeddingModal({
resource: "dashboard",
resourceId: dashboardId,
activeTab: "parameters",
previewMode: "preview",
});
});
H.modal().within(() => {
@@ -637,15 +668,24 @@ describe("scenarios > embedding > dashboard parameters with defaults", () => {
});
it("card parameter defaults should apply for disabled parameters, but not for editable or locked parameters", () => {
H.openStaticEmbeddingModal({ activeTab: "parameters" });
cy.get("@dashboardId").then((dashboardId) => {
H.openLegacyStaticEmbeddingModal({
resource: "dashboard",
resourceId: dashboardId,
activeTab: "parameters",
});
});
// ID param is disabled by default
H.setEmbeddingParameter("Name", "Editable");
H.setEmbeddingParameter("Source", "Locked");
H.publishChanges("dashboard", ({ request }) => {
assert.deepEqual(request.body.embedding_params, {
id: "disabled",
source: "locked",
name: "enabled",
user_id: "disabled",
not_used: "disabled",
});
});
@@ -723,7 +763,16 @@ describe("scenarios > embedding > dashboard parameters with defaults", () => {
});
H.visitDashboard("@dashboardId");
H.openStaticEmbeddingModal({ activeTab: "parameters" });
cy.get("@dashboardId").then((dashboardId) => {
H.openLegacyStaticEmbeddingModal({
resource: "dashboard",
resourceId: dashboardId,
activeTab: "parameters",
unpublishBeforeOpen: false,
});
});
H.visitIframe();
cy.log("should show card results by default");
@@ -781,19 +830,19 @@ describe("scenarios > embedding > dashboard appearance", () => {
dashboardDetails,
}).then(({ body: { dashboard_id } }) => {
H.visitDashboard(dashboard_id);
});
cy.intercept(
"GET",
"api/preview_embed/dashboard/*",
cy.spy().as("previewEmbedSpy"),
).as("previewEmbed");
cy.intercept(
"GET",
"api/preview_embed/dashboard/*",
cy.spy().as("previewEmbedSpy"),
).as("previewEmbed");
H.openStaticEmbeddingModal({
activeTab: "parameters",
previewMode: "preview",
// EE users don't have to accept terms
acceptTerms: false,
H.openLegacyStaticEmbeddingModal({
resource: "dashboard",
resourceId: dashboard_id,
activeTab: "parameters",
previewMode: "preview",
});
});
cy.wait("@previewEmbed");
@@ -916,21 +965,21 @@ describe("scenarios > embedding > dashboard appearance", () => {
})
.then((dashboard) => {
H.visitDashboard(dashboard.id);
cy.intercept(
"GET",
"api/preview_embed/dashboard/*",
cy.spy().as("previewEmbedSpy"),
).as("previewEmbed");
H.openLegacyStaticEmbeddingModal({
resource: "dashboard",
resourceId: dashboard.id,
activeTab: "parameters",
previewMode: "preview",
});
});
cy.intercept(
"GET",
"api/preview_embed/dashboard/*",
cy.spy().as("previewEmbedSpy"),
).as("previewEmbed");
H.openStaticEmbeddingModal({
activeTab: "parameters",
previewMode: "preview",
// EE users don't have to accept terms
acceptTerms: false,
});
cy.wait("@previewEmbed");
H.modal().within(() => {
@@ -1220,10 +1269,11 @@ describe("scenarios > embedding > dashboard appearance", () => {
}).then(({ body: { dashboard_id } }) => {
H.visitDashboard(dashboard_id);
H.openStaticEmbeddingModal({
H.openLegacyStaticEmbeddingModal({
resource: "dashboard",
resourceId: dashboard_id,
activeTab: "parameters",
previewMode: "preview",
acceptTerms: false,
});
H.modal().within(() => {

View File

@@ -28,14 +28,27 @@ describe("scenarios > embedding > native questions", () => {
visitQuestion: true,
});
H.openStaticEmbeddingModal({ activeTab: "parameters" });
cy.get("@questionId").then((questionId) => {
H.openLegacyStaticEmbeddingModal({
resource: "question",
resourceId: questionId,
activeTab: "parameters",
});
});
}
it("should not display disabled parameters", () => {
createAndVisitQuestion();
H.publishChanges("card", ({ request }) => {
assert.deepEqual(request.body.embedding_params, {});
assert.deepEqual(request.body.embedding_params, {
id: "disabled",
state: "disabled",
created_at: "disabled",
total: "disabled",
source: "disabled",
product_id: "disabled",
});
});
H.visitIframe();
@@ -68,6 +81,7 @@ describe("scenarios > embedding > native questions", () => {
total: "locked",
state: "enabled",
product_id: "enabled",
source: "disabled",
};
assert.deepEqual(actual, expected);
@@ -148,7 +162,12 @@ describe("scenarios > embedding > native questions", () => {
// weren't touched and therefore aren't changed, whereas
// "enabled" must be set by default for required params.
const expected = {
id: "disabled",
state: "disabled",
created_at: "disabled",
total: "enabled",
source: "disabled",
product_id: "disabled",
};
assert.deepEqual(actual, expected);
@@ -352,10 +371,15 @@ describe("scenarios > embedding > native questions", () => {
});
it("locked parameters should still render results in the preview by default (metabase#47570)", () => {
H.visitQuestion("@questionId");
H.openStaticEmbeddingModal({
activeTab: "parameters",
H.visitQuestion("@questionId").then((id) => {
H.openLegacyStaticEmbeddingModal({
resource: "question",
resourceId: id,
activeTab: "parameters",
unpublishBeforeOpen: false,
});
});
H.visitIframe();
cy.log("should show card results by default");
@@ -377,15 +401,23 @@ describe("scenarios > embedding > native questions with default parameters", ()
wrapId: true,
});
H.openStaticEmbeddingModal({ activeTab: "parameters" });
cy.get("@questionId").then((questionId) => {
H.openLegacyStaticEmbeddingModal({
resource: "question",
resourceId: questionId,
activeTab: "parameters",
});
});
// Note: ID is disabled
H.setEmbeddingParameter("Source", "Locked");
H.setEmbeddingParameter("Name", "Editable");
H.publishChanges("card", ({ request }) => {
assert.deepEqual(request.body.embedding_params, {
id: "disabled",
source: "locked",
name: "enabled",
user_id: "disabled",
});
});
});

View File

@@ -44,10 +44,17 @@ describe("scenarios > embedding > questions", () => {
display: "scalar",
enable_embedding: true,
},
{ visitQuestion: true },
{ visitQuestion: true, wrapId: true },
);
H.openStaticEmbeddingModal({ activeTab: "parameters" });
cy.get("@questionId").then((questionId) => {
H.openLegacyStaticEmbeddingModal({
resource: "question",
resourceId: questionId,
activeTab: "parameters",
unpublishBeforeOpen: false,
});
});
H.visitIframe();
@@ -66,10 +73,14 @@ describe("scenarios > embedding > questions", () => {
cy.request("PUT", `/api/card/${id}`, { enable_embedding: true });
H.visitQuestion(id);
H.openLegacyStaticEmbeddingModal({
resource: "question",
resourceId: id,
activeTab: "parameters",
unpublishBeforeOpen: false,
});
});
H.openStaticEmbeddingModal({ activeTab: "parameters" });
H.visitIframe();
cy.findByTestId("embed-frame").within(() => {
@@ -106,9 +117,14 @@ describe("scenarios > embedding > questions", () => {
cy.request("PUT", `/api/card/${id}`, { enable_embedding: true });
H.visitQuestion(id);
});
H.openStaticEmbeddingModal({ activeTab: "parameters" });
H.openLegacyStaticEmbeddingModal({
resource: "question",
resourceId: id,
activeTab: "parameters",
unpublishBeforeOpen: false,
});
});
H.visitIframe();
@@ -141,11 +157,16 @@ describe("scenarios > embedding > questions", () => {
cy.request("PUT", `/api/card/${nestedId}`, { enable_embedding: true });
H.visitQuestion(nestedId);
H.openLegacyStaticEmbeddingModal({
resource: "question",
resourceId: nestedId,
activeTab: "parameters",
unpublishBeforeOpen: false,
});
});
});
H.openStaticEmbeddingModal({ activeTab: "parameters" });
H.visitIframe();
// Global (Data model) settings should be preserved
@@ -178,9 +199,14 @@ describe("scenarios > embedding > questions", () => {
cy.request("PUT", `/api/card/${id}`, { enable_embedding: true });
H.visitQuestion(id);
});
H.openStaticEmbeddingModal({ activeTab: "parameters" });
H.openLegacyStaticEmbeddingModal({
resource: "question",
resourceId: id,
activeTab: "parameters",
unpublishBeforeOpen: false,
});
});
H.visitIframe();
@@ -247,7 +273,7 @@ describe("scenarios [EE] > embedding > questions", () => {
cy.wait("@deLocale");
H.main().findByText("Februar 11, 2025, 9:40 PM");
H.main().findByText("Februar 11, 2025, 9:40 PM").realHover();
cy.findByRole("button", { name: "Ergebnis downloaden" }).should("exist");
cy.url().should("include", "locale=de");
});
@@ -313,7 +339,11 @@ describe("scenarios > embedding > questions > downloads", () => {
cy.get("@questionId").then((questionId) => {
H.visitQuestion(questionId);
H.openStaticEmbeddingModal({ activeTab: "lookAndFeel" });
H.openLegacyStaticEmbeddingModal({
resource: "question",
resourceId: questionId,
activeTab: "lookAndFeel",
});
cy.log(
"Embedding settings page should not show option to disable downloads",
@@ -375,9 +405,10 @@ describe("scenarios > embedding > questions > downloads", () => {
cy.get("@questionId").then((questionId) => {
H.visitQuestion(questionId);
H.openStaticEmbeddingModal({
H.openLegacyStaticEmbeddingModal({
resource: "question",
resourceId: questionId,
activeTab: "lookAndFeel",
acceptTerms: false,
});
cy.log("Disable downloads");

View File

@@ -141,14 +141,17 @@ describe("issue 15860", { tags: "@skip" }, () => {
});
H.visitDashboard(dashboard_id);
H.openLegacyStaticEmbeddingModal({
resource: "dashboard",
resourceId: dashboard_id,
activeTab: "parameters",
unpublishBeforeOpen: false,
});
});
});
it("should work for locked linked filters connected to different cards with the same source table (metabase#15860)", () => {
cy.icon("share").click();
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Embed in your application").click();
setDefaultValueForLockedFilter("Q1 ID", 1);
setDefaultValueForLockedFilter("Q2 ID", 3);
@@ -243,12 +246,22 @@ describe("issue 20438", () => {
embedding_params: { [filter.slug]: "enabled" },
});
cy.wrap(card_id).as("questionId");
cy.wrap(dashboard_id).as("dashboardId");
H.visitDashboard(dashboard_id);
});
});
it("dashboard filter connected to the field filter should work with a single value in embedded dashboards (metabase#20438)", () => {
H.openStaticEmbeddingModal({ activeTab: "parameters" });
cy.get("@dashboardId").then((dashboardId) => {
H.openLegacyStaticEmbeddingModal({
resource: "dashboard",
resourceId: dashboardId,
activeTab: "parameters",
unpublishBeforeOpen: false,
});
});
H.visitIframe();
@@ -294,12 +307,22 @@ describe("locked parameters in embedded question (metabase#20634)", () => {
},
},
},
{ visitQuestion: true },
{
visitQuestion: true,
wrapId: true,
},
);
});
it("should let the user lock parameters to specific values", () => {
H.openStaticEmbeddingModal({ activeTab: "parameters" });
cy.get("@questionId").then((questionId) => {
H.openLegacyStaticEmbeddingModal({
resource: "question",
resourceId: questionId,
activeTab: "parameters",
unpublishBeforeOpen: false,
});
});
H.modal().within(() => {
// select the dropdown next to the Text parameter so that we can set the value to "Locked"
@@ -318,7 +341,7 @@ describe("locked parameters in embedded question (metabase#20634)", () => {
cy.findByPlaceholderText("Text").type("foo{enter}");
// publish the embedded question so that we can directly navigate to its url
cy.findByText("Publish").click();
cy.findByText("Publish changes").click();
cy.wait("@publishChanges");
});
@@ -671,16 +694,18 @@ describe("issue 30535", () => {
cy.request("PUT", `/api/card/${id}`, { enable_embedding: true });
H.visitQuestion(id);
H.openLegacyStaticEmbeddingModal({
resource: "question",
resourceId: id,
activeTab: "parameters",
previewMode: "preview",
unpublishBeforeOpen: false,
});
});
});
it("user session should not apply sandboxing to a signed embedded question (metabase#30535)", () => {
H.openStaticEmbeddingModal({
activeTab: "parameters",
previewMode: "preview",
acceptTerms: false,
});
cy.document().then((doc) => {
const iframe = doc.querySelector("iframe");
@@ -790,11 +815,13 @@ describe("dashboard preview", () => {
});
H.visitDashboard(dashboard_id);
});
H.openStaticEmbeddingModal({
activeTab: "parameters",
previewMode: "preview",
H.openLegacyStaticEmbeddingModal({
resource: "dashboard",
resourceId: dashboard_id,
activeTab: "parameters",
previewMode: "preview",
});
});
H.modal().within(() => {
@@ -875,11 +902,13 @@ describe("dashboard preview", () => {
});
H.visitDashboard(dashboard_id);
});
H.openStaticEmbeddingModal({
activeTab: "parameters",
previewMode: "preview",
H.openLegacyStaticEmbeddingModal({
resource: "dashboard",
resourceId: dashboard_id,
activeTab: "parameters",
previewMode: "preview",
});
});
// Makes it less likely to flake.
@@ -943,7 +972,9 @@ describe("issue 40660", () => {
H.restore();
cy.signInAsAdmin();
});
it("static dashboard content shouldn't overflow its container (metabase#40660)", () => {
H.createQuestionAndDashboard({
questionDetails,
dashboardDetails,
@@ -954,13 +985,13 @@ describe("issue 40660", () => {
});
H.visitDashboard(dashboard_id);
});
});
it("static dashboard content shouldn't overflow its container (metabase#40660)", () => {
H.openStaticEmbeddingModal({
activeTab: "parameters",
previewMode: "preview",
H.openLegacyStaticEmbeddingModal({
resource: "dashboard",
resourceId: dashboard_id,
activeTab: "parameters",
previewMode: "preview",
});
});
H.getIframeBody().within(() => {
@@ -991,20 +1022,23 @@ describe("issue 49142", { tags: "@skip" }, () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
});
it("embedding preview should be always working", () => {
H.createQuestionAndDashboard({
questionDetails,
dashboardDetails,
}).then(({ body: { dashboard_id } }) => {
H.visitDashboard(dashboard_id);
});
});
it("embedding preview should be always working", () => {
H.openStaticEmbeddingModal({
activeTab: "lookAndFeel",
previewMode: "preview",
H.openLegacyStaticEmbeddingModal({
resource: "dashboard",
resourceId: dashboard_id,
activeTab: "lookAndFeel",
previewMode: "preview",
});
});
cy.findByTestId("embed-preview-iframe")
.its("0.contentDocument.body")
.should("be.visible")
@@ -1505,9 +1539,17 @@ describe("issue 63687", () => {
it("should properly display pin map tiles without auth errors for a valid JWT token", () => {
H.createNativeQuestion(questionAsPinMapWithTiles, {
visitQuestion: true,
wrapId: true,
});
H.openStaticEmbeddingModal({ activeTab: "parameters" });
cy.get("@questionId").then((questionId) => {
H.openLegacyStaticEmbeddingModal({
resource: "question",
resourceId: questionId,
activeTab: "parameters",
unpublishBeforeOpen: false,
});
});
cy.intercept("/api/embed/tiles/**").as("getTiles");

View File

@@ -6,7 +6,7 @@ import {
ORDERS_QUESTION_ID,
} from "e2e/support/cypress_sample_instance_data";
const standalonePath = "/admin/embedding/static";
const standalonePath = "/admin/embedding/guest";
// These tests will run on both OSS and EE instances. Both without a token!
describe("scenarios > embedding > smoke tests", { tags: "@OSS" }, () => {
@@ -39,29 +39,23 @@ describe("scenarios > embedding > smoke tests", { tags: "@OSS" }, () => {
});
it("should show the sdk upsell link in oss", () => {
cy.visit("/admin/embedding/modular");
cy.visit("/admin/embedding");
mainPage().within(() => {
cy.findByRole("link", { name: "Try for free" })
cy.findByRole("link", { name: "Upgrade" })
.should("have.attr", "href")
.and(
"eq",
"https://www.metabase.com/product/embedded-analytics?utm_source=product&utm_medium=upsell&utm_campaign=embedded-analytics-js&utm_content=embedding-page&source_plan=oss",
"https://www.metabase.com/upgrade?utm_source=product&utm_medium=upsell&utm_content=embedding-page&source_plan=oss&utm_users=10&utm_campaign=embedded-analytics-js",
);
});
});
it("should not let you embed the question", () => {
it("should not let you use non-guest auth methods", () => {
H.visitQuestion(ORDERS_QUESTION_ID);
ensureEmbeddingIsDisabled();
});
it("should not let you embed the dashboard", () => {
H.visitDashboard(ORDERS_DASHBOARD_ID);
ensureEmbeddingIsDisabled();
});
});
context("embedding enabled", () => {
@@ -238,7 +232,7 @@ describe("scenarios > embedding > smoke tests", { tags: "@OSS" }, () => {
cy.request("PUT", `/api/card/${ORDERS_QUESTION_ID}`, {
enable_embedding: true,
});
visitAndEnableSharing("question");
visitAndEnableSharing("question", false);
H.modal().within(() => {
cy.findByRole("tab", { name: "Parameters" }).click();
@@ -302,29 +296,34 @@ function resetEmbedding() {
}
function ensureEmbeddingIsDisabled() {
H.openSharingMenu();
H.sharingMenu()
.findByRole("menuitem", { name: "Embed" })
.should("be.enabled")
.click();
H.modal()
.findByRole("article", { name: "Static embedding" })
.within(() => {
cy.findByText("Disabled.").should("be.visible");
cy.findByText("Enable in admin settings").should("be.visible");
});
H.openEmbedJsModal();
H.embedModalEnableEmbedding();
H.embedModalContent().within(() => {
cy.findByLabelText("Single sign-on (SSO)").should("be.disabled");
cy.findByLabelText("Existing Metabase session").should("be.disabled");
});
}
function visitAndEnableSharing(object, acceptTerms = true) {
if (object === "question") {
H.visitQuestion(ORDERS_QUESTION_ID);
}
function visitAndEnableSharing(object, unpublishBeforeOpen = true) {
const { id, visitFunction } = {
question: {
id: ORDERS_QUESTION_ID,
visitFunction: H.visitQuestion,
},
dashboard: {
id: ORDERS_DASHBOARD_ID,
visitFunction: H.visitDashboard,
},
}[object];
if (object === "dashboard") {
H.visitDashboard(ORDERS_DASHBOARD_ID);
}
visitFunction(id);
H.openStaticEmbeddingModal({ acceptTerms });
H.openLegacyStaticEmbeddingModal({
resource: object,
resourceId: id,
unpublishBeforeOpen,
});
}
function mainPage() {

View File

@@ -28,7 +28,11 @@ tokens.forEach((token) => {
const defaultDownloadsValue =
token === "pro-self-hosted" ? true : undefined;
H.visitDashboard(ORDERS_DASHBOARD_ID);
H.openStaticEmbeddingModal({ acceptTerms: false });
H.openLegacyStaticEmbeddingModal({
resource: "dashboard",
resourceId: ORDERS_DASHBOARD_ID,
acceptTerms: false,
});
H.modal().within(() => {
cy.findByText(
@@ -127,7 +131,11 @@ tokens.forEach((token) => {
const defaultDownloadsValue =
token === "pro-self-hosted" ? true : undefined;
H.visitQuestion(ORDERS_QUESTION_ID);
H.openStaticEmbeddingModal({ acceptTerms: false });
H.openLegacyStaticEmbeddingModal({
resource: "question",
resourceId: ORDERS_QUESTION_ID,
acceptTerms: false,
});
H.modal().within(() => {
cy.findByText(

View File

@@ -1,43 +0,0 @@
const { H } = cy;
describe("scenarios > embedding > upsells", { tags: "@EE" }, () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
H.updateSetting("show-sdk-embed-terms", false);
});
it("should show the sdk settings page with upsell button", () => {
cy.on("window:before:load", (win) => {
// prevent Cypress opening in a new window/tab and spy on this method
cy.stub(win, "open").as("open");
});
cy.visit("/admin/embedding/modular");
mainPage().within(() => {
cy.contains("SDK for React").should("be.visible");
cy.contains("Embedded Analytics JS").should("be.visible");
});
cy.findByRole("button", { name: "Try for free" }).click();
cy.get<sinon.SinonSpy>("@open").should((spy) => {
const url = spy.getCall(0).args[0];
expect(url).to.include(
"https://test-store.metabase.com/checkout/upgrade/self-hosted",
);
expect(url).to.match(/utm_source=product/);
expect(url).to.match(/utm_medium=upsell/);
expect(url).to.match(/utm_campaign=embedded-analytics-js/);
expect(url).to.match(/utm_content=embedding-page/);
expect(url).to.match(/source_plan=oss/);
});
});
});
function mainPage() {
return cy.findByTestId("admin-layout-content");
}

View File

@@ -59,6 +59,9 @@ describe("scenarios > embedding > sdk iframe embed setup > common", () => {
});
cy.wait("@dashboard");
H.embedModalEnableEmbedding();
cy.get("[data-iframe-loaded]", { timeout: 20000 }).should("have.length", 1);
H.modal().should("exist");

View File

@@ -1,71 +1,139 @@
import { embedModalEnableEmbeddingCard } from "e2e/support/helpers";
import { getEmbedSidebar } from "./helpers";
const { H } = cy;
describe("scenarios > embedding > sdk iframe embed setup > enable embed js", () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
H.activateToken("bleeding-edge");
H.updateSetting("enable-embedding-simple", false);
const { IS_ENTERPRISE } = Cypress.env();
const IS_OSS = !IS_ENTERPRISE;
const MB_EDITION = IS_ENTERPRISE ? "ee" : "oss";
H.mockEmbedJsToDevServer();
});
const DATA_BY_MB_EDITION = {
oss: {
cardTestId: "guest-embeds-setting-card",
cardText:
"To continue, enable Guest embeds and agree to the usage conditions.",
embeddingSettingName: "enable-embedding-static",
showTermsSettingName: "show-static-embed-terms",
},
ee: {
cardTestId: "sdk-setting-card",
cardText:
"To continue, enable Embedded Analytics JS and agree to the usage conditions.",
embeddingSettingName: "enable-embedding-simple",
showTermsSettingName: "show-simple-embed-terms",
},
} as const;
it("shows the Enable to Continue button and enables embedding on click", () => {
cy.visit("/admin/embedding");
describe(
`scenarios > embedding > sdk iframe embed setup > enable embed js > ${MB_EDITION}`,
{ ...(IS_OSS && { tags: "@OSS" }) },
() => {
const { embeddingSettingName, showTermsSettingName, cardTestId, cardText } =
DATA_BY_MB_EDITION[MB_EDITION];
cy.findAllByTestId("sdk-setting-card")
.first()
.within(() => {
cy.findByText("New embed").click();
});
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
cy.log("shows tooltip with fair usage info");
getEmbedSidebar().findByLabelText("info icon").trigger("mouseover");
if (IS_ENTERPRISE) {
H.activateToken("bleeding-edge");
}
H.hovercard()
.contains(/When using the Embedded analytics SDK/)
.should("be.visible");
H.hovercard()
.contains(/Sharing Metabase accounts is a security risk/)
.should("be.visible");
cy.findByRole("button", { name: "Enable to continue" }).should(
"be.visible",
);
cy.log("preview panel should show placeholder");
cy.get('[alt="No results"]').should("be.visible");
cy.findByRole("button", { name: "Enable to continue" }).click();
cy.log("button should change to Enabled state");
cy.findByRole("button", { name: /Enabled/ })
.should("be.visible")
.should("be.disabled");
cy.log("Preview should load after embedding is enabled");
H.waitForSimpleEmbedIframesToLoad();
H.getSimpleEmbedIframeContent().within(() => {
cy.findByText("Orders in a dashboard").should("be.visible");
H.mockEmbedJsToDevServer();
});
});
it("hides the enable card when embedding is already enabled", () => {
H.updateSetting("enable-embedding-simple", true);
it("shows the Enable to Continue button and enables embedding on click", () => {
H.updateSetting(embeddingSettingName, false);
H.updateSetting(showTermsSettingName, true);
cy.visit("/admin/embedding");
cy.visit("/admin/embedding");
cy.findAllByTestId("sdk-setting-card")
.first()
.within(() => {
cy.findByText("New embed").click();
cy.findAllByTestId(cardTestId)
.first()
.within(() => {
cy.findByText("New embed").click();
});
embedModalEnableEmbeddingCard().within(() => {
cy.findByText(cardText).should("exist");
});
getEmbedSidebar()
.contains("Enable Embedded Analytics JS to get started.")
.should("not.exist");
});
});
cy.log("shows tooltip with fair usage info");
getEmbedSidebar().findByLabelText("info icon").trigger("mouseover");
H.hovercard()
.contains(
/You should, however, read the license text linked above as that is the actual license that you will be agreeing to by enabling this feature/,
)
.should("be.visible");
getEmbedSidebar().findByLabelText("info icon").trigger("mouseout");
cy.findByRole("button", { name: "Agree and enable" }).should(
"be.visible",
);
cy.log("preview panel should show placeholder");
cy.get('[alt="No results"]').should("be.visible");
cy.findByRole("button", { name: "Agree and enable" }).click();
cy.log("button should change to Enabled state");
cy.findByRole("button", { name: /Enabled/ })
.should("be.visible")
.should("be.disabled");
cy.log("Preview should load after embedding is enabled");
H.waitForSimpleEmbedIframesToLoad();
H.getSimpleEmbedIframeContent().within(() => {
cy.findByText("Orders in a dashboard").should("be.visible");
});
});
it("shows the enable card with fair usage terms when embedding is already enabled", () => {
H.updateSetting(embeddingSettingName, true);
H.updateSetting(showTermsSettingName, true);
cy.visit("/admin/embedding");
cy.findAllByTestId(cardTestId)
.first()
.within(() => {
cy.findByText("New embed").click();
});
embedModalEnableEmbeddingCard().within(() => {
cy.findByText("Agree to the usage conditions to continue.").should(
"exist",
);
cy.findByText(cardText).should("not.exist");
});
cy.log("shows tooltip with fair usage info");
getEmbedSidebar().findByLabelText("info icon").trigger("mouseover");
H.hovercard()
.contains(
/You should, however, read the license text linked above as that is the actual license that you will be agreeing to by enabling this feature/,
)
.should("be.visible");
});
it("hides the enable card when embedding is already enabled", () => {
H.updateSetting(embeddingSettingName, true);
H.updateSetting(showTermsSettingName, false);
cy.visit("/admin/embedding");
cy.findAllByTestId(cardTestId)
.first()
.within(() => {
cy.findByText("New embed").click();
});
getEmbedSidebar().contains(cardText).should("not.exist");
});
},
);

View File

@@ -22,6 +22,7 @@ describe(suiteTitle, () => {
H.activateToken("bleeding-edge");
H.enableTracking();
H.updateSetting("enable-embedding-simple", true);
H.updateSetting("enable-embedding-static", false);
H.mockEmbedJsToDevServer();
});

View File

@@ -3,9 +3,13 @@ import {
ORDERS_DASHBOARD_ID,
} from "e2e/support/cypress_sample_instance_data";
import { enableJwtAuth } from "e2e/support/helpers/e2e-jwt-helpers";
import { enableSamlAuth } from "e2e/support/helpers/embedding-sdk-testing";
import { codeBlock, getEmbedSidebar, navigateToGetCodeStep } from "./helpers";
import {
codeBlock,
getEmbedSidebar,
navigateToEmbedOptionsStep,
navigateToGetCodeStep,
} from "./helpers";
const { H } = cy;
@@ -35,63 +39,6 @@ describe(suiteTitle, () => {
H.expectNoBadSnowplowEvents();
});
it("should select user session auth method by default", () => {
navigateToGetCodeStep({
experience: "dashboard",
resourceName: DASHBOARD_NAME,
});
getEmbedSidebar().within(() => {
cy.findByText("Authentication").should("be.visible");
cy.findByText("Choose the authentication method for embedding:").should(
"be.visible",
);
cy.findByLabelText("Existing Metabase session")
.should("be.visible")
.should("be.checked");
cy.findByLabelText("Single sign-on (SSO)")
.should("be.visible")
.should("not.be.checked");
});
});
it("should disable SSO radio button when JWT and SAML are not configured", () => {
navigateToGetCodeStep({
experience: "dashboard",
resourceName: DASHBOARD_NAME,
});
getEmbedSidebar().within(() => {
cy.findByLabelText("Single sign-on (SSO)").should("be.disabled");
});
});
it("should enable SSO radio button when JWT is configured", () => {
enableJwtAuth();
navigateToGetCodeStep({
experience: "dashboard",
resourceName: DASHBOARD_NAME,
});
getEmbedSidebar().within(() => {
cy.findByLabelText("Single sign-on (SSO)").should("not.be.disabled");
});
});
it("should enable SSO radio button when SAML is configured", () => {
enableSamlAuth();
navigateToGetCodeStep({
experience: "dashboard",
resourceName: DASHBOARD_NAME,
});
getEmbedSidebar().within(() => {
cy.findByLabelText("Single sign-on (SSO)").should("not.be.disabled");
});
});
it("should display code snippet with syntax highlighting", () => {
navigateToGetCodeStep({
experience: "dashboard",
@@ -107,81 +54,100 @@ describe(suiteTitle, () => {
});
it("should include useExistingUserSession when user session is selected", () => {
navigateToGetCodeStep({
navigateToEmbedOptionsStep({
experience: "dashboard",
resourceName: DASHBOARD_NAME,
});
getEmbedSidebar().within(() => {
cy.findByLabelText("Existing Metabase session").should("be.checked");
cy.findByLabelText("Existing Metabase session").click();
cy.findByText("Get code").click();
codeBlock().should("contain", '"useExistingUserSession": true');
cy.findByText(/Copy code/).click();
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_code_copied",
event_detail: "user_session",
event_detail:
"experience=dashboard,snippetType=frontend,auth=user-session",
});
});
});
it("should track embed_wizard_code_copied when copy event triggers", () => {
navigateToGetCodeStep({
navigateToEmbedOptionsStep({
experience: "dashboard",
resourceName: DASHBOARD_NAME,
});
getEmbedSidebar().within(() => {
cy.findByLabelText("Existing Metabase session").should("be.checked");
cy.findByLabelText("Existing Metabase session").click();
cy.findByText("Get code").click();
codeBlock().should("contain", '"useExistingUserSession": true');
codeBlock().trigger("copy");
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_code_copied",
event_detail: "user_session",
event_detail:
"experience=dashboard,snippetType=frontend,auth=user-session",
});
});
});
it("should not include useExistingUserSession when SSO is selected", () => {
enableJwtAuth();
navigateToGetCodeStep({
navigateToEmbedOptionsStep({
experience: "dashboard",
resourceName: DASHBOARD_NAME,
});
getEmbedSidebar().within(() => {
cy.findByLabelText("Single sign-on (SSO)").click();
cy.findByText("Get code").click();
codeBlock().should("not.contain", "useExistingUserSession");
cy.findByText(/Copy code/).click();
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_code_copied",
event_detail: "sso",
event_detail: "experience=dashboard,snippetType=frontend,auth=sso",
});
});
});
it("should set dashboard-id for dashboard experience", () => {
navigateToGetCodeStep({
it("should set dashboard-id for regular dashboard experience", () => {
navigateToEmbedOptionsStep({
experience: "dashboard",
resourceName: DASHBOARD_NAME,
});
getEmbedSidebar().within(() => {
cy.findByLabelText("Existing Metabase session").click();
cy.findByText("Get code").click();
codeBlock().should("contain", `dashboard-id="${ORDERS_DASHBOARD_ID}"`);
});
});
it("should set question-id for chart experience", () => {
navigateToGetCodeStep({
it("should set question-id for regular chart experience", () => {
navigateToEmbedOptionsStep({
experience: "chart",
resourceName: QUESTION_NAME,
});
getEmbedSidebar().within(() => {
cy.findByLabelText("Existing Metabase session").click();
cy.findByText("Get code").click();
codeBlock().should(
"contain",
`question-id="${ORDERS_COUNT_QUESTION_ID}"`,

View File

@@ -0,0 +1,363 @@
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
import {
JWT_SHARED_SECRET,
entityPickerModal,
getParametersContainer,
} from "e2e/support/helpers";
import { enableJwtAuth } from "e2e/support/helpers/e2e-jwt-helpers";
import {
codeBlock,
getEmbedSidebar,
navigateToEmbedOptionsStep,
visitNewEmbedPage,
} from "./helpers";
const { H } = cy;
const { ORDERS_ID } = SAMPLE_DATABASE;
const DASHBOARD_NAME = "Dashboard with Parameters";
const DASHBOARD_PARAMETERS = [
{
name: "ID",
slug: "id",
id: "11111111",
type: "id",
},
{
name: "Product ID",
slug: "product_id",
id: "22222222",
type: "id",
},
];
const FIRST_QUESTION_NAME = "Question With Params 1";
const SECOND_QUESTION_NAME = "Question With Params 2";
describe("scenarios > embedding > sdk iframe embed setup > guest-embed", () => {
beforeEach(() => {
H.restore();
H.resetSnowplow();
cy.signInAsAdmin();
H.activateToken("bleeding-edge");
H.enableTracking();
H.updateSetting("embedding-secret-key", JWT_SHARED_SECRET);
H.createQuestionAndDashboard({
questionDetails: {
name: "Orders table",
query: { "source-table": ORDERS_ID },
},
dashboardDetails: {
name: DASHBOARD_NAME,
parameters: DASHBOARD_PARAMETERS,
},
}).then(({ body: card }) => {
cy.wrap(card.dashboard_id).as("dashboardId");
});
H.createNativeQuestion(
{
name: FIRST_QUESTION_NAME,
native: {
query: "select {{text}}",
"template-tags": {
text: {
id: "abc-123",
name: "text",
"display-name": "Text",
type: "text",
default: null,
},
},
},
enable_embedding: false,
},
{
wrapId: true,
idAlias: "question1Id",
},
);
H.createNativeQuestion(
{
name: SECOND_QUESTION_NAME,
native: {
query: "select {{text}}",
"template-tags": {
text1: {
id: "abc-123",
name: "text1",
"display-name": "Text1",
type: "text",
default: null,
required: true,
},
text2: {
id: "abc-456",
name: "text2",
"display-name": "Text2",
type: "text",
default: null,
required: false,
},
},
},
enable_embedding: true,
embedding_params: {
text1: "enabled",
state2: "disabled",
},
},
{
wrapId: true,
idAlias: "question2Id",
},
);
H.mockEmbedJsToDevServer();
});
afterEach(() => {
H.expectNoBadSnowplowEvents();
});
describe("Happy path", () => {
it("Navigates through the guest-embed flow for a question and opens its embed page", () => {
visitNewEmbedPage();
H.expectUnstructuredSnowplowEvent({ event: "embed_wizard_opened" });
H.waitForSimpleEmbedIframesToLoad();
// Experience step
getEmbedSidebar().within(() => {
cy.findByTestId("upsell-gem").should("not.exist");
cy.findByText("Chart").click();
cy.findByText("Next").click();
});
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_experience_completed",
event_detail: "custom=chart",
});
// Entity selection step
getEmbedSidebar().within(() => {
cy.findByTestId("embed-browse-entity-button").click();
});
H.entityPickerModal().within(() => {
cy.findByText("Select a chart").should("be.visible");
cy.findByText("Questions").click();
cy.findByText(FIRST_QUESTION_NAME).click();
});
getEmbedSidebar().within(() => {
cy.findByText("Next").click();
});
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_resource_selection_completed",
event_detail: "isDefaultResource=false,experience=chart",
});
// Options step
cy.findByLabelText("Guest").should("be.visible").should("be.checked");
cy.findByLabelText("Allow people to drill through on data points")
.should("be.visible")
.should("be.disabled");
cy.findByLabelText("Allow downloads")
.should("be.visible")
.should("not.be.disabled")
.should("not.be.checked");
cy.findByLabelText("Allow people to save new questions")
.should("be.visible")
.should("be.disabled");
H.setEmbeddingParameter("Text", "Locked");
cy.findAllByTestId("parameter-widget").find("input").type("Foo Bar Baz");
getEmbedSidebar().within(() => {
cy.findByTestId("upsell-gem").should("not.exist");
});
H.publishChanges("card");
cy.button("Unpublish").should("be.visible");
H.getSimpleEmbedIframeContent().within(() => {
cy.findByText("Foo Bar Baz").should("be.visible");
});
getEmbedSidebar().within(() => {
cy.findByText("Get code").click();
});
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_options_completed",
event_detail:
'settings=custom,experience=chart,guestEmbedEnabled=true,guestEmbedType=guest-embed,auth=guest-embed,drills=false,withDownloads=false,withTitle=true,isSaveEnabled=false,params={"disabled":0,"locked":1,"enabled":0},theme=default',
});
// Get code step
getEmbedSidebar().within(() => {
cy.findAllByText(/Copy code/)
.first()
.click();
});
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_code_copied",
event_detail:
"experience=chart,snippetType=frontend,guestEmbedEnabled=true,guestEmbedType=guest-embed,auth=guest-embed",
});
// Visit embed page
getEmbedSidebar().within(() => {
codeBlock()
.invoke("text")
.then((code: string) => {
const match = code.match(/token="([^"]+)"/);
expect(match, "JWT token present in code block").to.not.be.null;
const jwtToken = match ? match[1] : "";
cy.wrap(jwtToken).as("guestEmbedToken");
});
});
cy.get<string>("@guestEmbedToken").then((token) => {
const frame = H.loadSdkIframeEmbedTestPage({
metabaseConfig: { isGuest: true },
elements: [
{
component: "metabase-question",
attributes: {
token,
},
},
],
});
frame.within(() => {
cy.findByText("Foo Bar Baz").should("be.visible");
});
});
});
it("Properly re-initializes embedding parameters and Guest Embed navbar", () => {
cy.get("@question1Id").then((questionId) => {
cy.request("PUT", `/api/card/${questionId}`, {
enable_embedding: true,
});
navigateToEmbedOptionsStep({
experience: "chart",
resourceName: FIRST_QUESTION_NAME,
});
cy.button("Unpublish").should("be.visible");
getParametersContainer()
.findByLabelText("Text")
.should("contain.text", "Disabled");
getEmbedSidebar().within(() => {
cy.findByText("Back").click();
cy.findByTestId("embed-browse-entity-button").click();
});
entityPickerModal().within(() => {
cy.findByText("Questions").click();
cy.findAllByText(SECOND_QUESTION_NAME).first().click();
});
getEmbedSidebar().within(() => {
cy.findByText("Next").click();
});
cy.button("Unpublish").should("be.visible");
getParametersContainer()
.findByLabelText("Text1")
.should("contain.text", "Editable");
getParametersContainer()
.findByLabelText("Text2")
.should("contain.text", "Disabled");
H.setEmbeddingParameter("Text1", "Locked");
cy.button("Publish changes").should("be.visible");
getEmbedSidebar().within(() => {
cy.findByText("Back").click();
cy.findByTestId("embed-browse-entity-button").click();
});
entityPickerModal().within(() => {
cy.findByText("Questions").click();
cy.findAllByText(FIRST_QUESTION_NAME).first().click();
});
getEmbedSidebar().within(() => {
cy.findByText("Next").click();
});
cy.button("Unpublish").should("be.visible");
getParametersContainer()
.findByLabelText("Text")
.should("contain.text", "Disabled");
});
});
it("Properly adjusts EmbedJS options when switching between guest/sso modes for a Dashboard", () => {
cy.get("@dashboardId").then((dashboardId) => {
cy.request("PUT", `/api/dashboard/${dashboardId}`, {
enable_embedding: true,
});
enableJwtAuth();
navigateToEmbedOptionsStep({
experience: "dashboard",
resourceName: DASHBOARD_NAME,
});
getEmbedSidebar().within(() => {
cy.findByLabelText("Guest").should("be.visible").should("be.checked");
});
H.setEmbeddingParameter("Product ID", "Locked");
getEmbedSidebar().within(() => {
cy.findByText("Get code").click();
codeBlock().first().should("contain", "token=");
codeBlock().first().should("not.contain", "dashboard-id=");
codeBlock().first().should("not.contain", "hidden-parameters=");
codeBlock().first().should("not.contain", "locked-parameters=");
cy.findByText("Back").click();
cy.findByLabelText("Single sign-on (SSO)").click();
cy.findByText("Get code").click();
codeBlock().first().should("contain", "dashboard-id=");
codeBlock().first().should("contain", "hidden-parameters=");
codeBlock().first().should("not.contain", "token=");
codeBlock().first().should("not.contain", "locked-parameters=");
});
});
});
});
});

View File

@@ -0,0 +1,269 @@
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
import { JWT_SHARED_SECRET } from "e2e/support/helpers";
import { codeBlock, getEmbedSidebar, visitNewEmbedPage } from "./helpers";
const { H } = cy;
const { ORDERS_ID } = SAMPLE_DATABASE;
const DASHBOARD_NAME = "Dashboard with Parameters";
const DASHBOARD_PARAMETERS = [
{
name: "ID",
slug: "id",
id: "11111111",
type: "id",
},
{
name: "Product ID",
slug: "product_id",
id: "22222222",
type: "id",
},
];
const FIRST_QUESTION_NAME = "Question With Params 1";
const SECOND_QUESTION_NAME = "Question With Params 2";
describe(
"scenarios > embedding > sdk iframe embed setup > guest-embed",
{ tags: "@OSS" },
() => {
beforeEach(() => {
H.restore();
H.resetSnowplow();
cy.signInAsAdmin();
H.enableTracking();
H.updateSetting("embedding-secret-key", JWT_SHARED_SECRET);
H.createQuestionAndDashboard({
questionDetails: {
name: "Orders table",
query: { "source-table": ORDERS_ID },
},
dashboardDetails: {
name: DASHBOARD_NAME,
parameters: DASHBOARD_PARAMETERS,
},
}).then(({ body: card }) => {
cy.wrap(card.dashboard_id).as("dashboardId");
});
H.createNativeQuestion(
{
name: FIRST_QUESTION_NAME,
native: {
query: "select {{text}}",
"template-tags": {
text: {
id: "abc-123",
name: "text",
"display-name": "Text",
type: "text",
default: null,
},
},
},
enable_embedding: false,
},
{
wrapId: true,
idAlias: "question1Id",
},
);
H.createNativeQuestion(
{
name: SECOND_QUESTION_NAME,
native: {
query: "select {{text}}",
"template-tags": {
text1: {
id: "abc-123",
name: "text1",
"display-name": "Text1",
type: "text",
default: null,
required: true,
},
text2: {
id: "abc-456",
name: "text2",
"display-name": "Text2",
type: "text",
default: null,
required: false,
},
},
},
enable_embedding: true,
embedding_params: {
text1: "enabled",
state2: "disabled",
},
},
{
wrapId: true,
idAlias: "question2Id",
},
);
H.mockEmbedJsToDevServer();
});
afterEach(() => {
H.expectNoBadSnowplowEvents();
});
describe("Happy path", () => {
it("Navigates through the guest-embed flow for a question and opens its embed page", () => {
visitNewEmbedPage();
H.expectUnstructuredSnowplowEvent({ event: "embed_wizard_opened" });
H.waitForSimpleEmbedIframesToLoad();
// Experience step
getEmbedSidebar().within(() => {
["Exploration", "Browser"].forEach((label) => {
cy.findByLabelText(label).should("be.disabled");
cy.get("label")
.contains(label)
.within(() => {
cy.findByTestId("upsell-gem").should("be.visible");
});
});
cy.findByText("Chart").click();
cy.findByText("Next").click();
});
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_experience_completed",
event_detail: "custom=chart",
});
// Entity selection step
getEmbedSidebar().within(() => {
cy.findByTestId("embed-browse-entity-button").click();
});
H.entityPickerModal().within(() => {
cy.findByText("Select a chart").should("be.visible");
cy.findByText("Questions").click();
cy.findByText(FIRST_QUESTION_NAME).click();
});
getEmbedSidebar().within(() => {
cy.findByText("Next").click();
});
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_resource_selection_completed",
event_detail: "isDefaultResource=false,experience=chart",
});
// Options step
cy.findByLabelText("Guest").should("be.visible").should("be.checked");
cy.findByLabelText("Single sign-on (SSO)").should("be.disabled");
cy.findByLabelText("Allow people to drill through on data points")
.should("be.visible")
.should("be.disabled");
cy.findByLabelText("Allow downloads")
.should("be.visible")
.should("be.disabled")
.should("be.checked");
cy.findByLabelText("Allow people to save new questions")
.should("be.visible")
.should("be.disabled");
[
"Single sign-on (SSO)",
"Allow people to drill through on data points",
"Allow downloads",
"Allow people to save new questions",
].forEach((text) => {
cy.findAllByTestId("tooltip-warning")
.filter(`:contains("${text}")`)
.within(() => {
cy.findByTestId("upsell-gem").should("be.visible");
});
});
H.setEmbeddingParameter("Text", "Locked");
cy.findAllByTestId("parameter-widget")
.find("input")
.type("Foo Bar Baz");
getEmbedSidebar().within(() => {
cy.findByTestId("appearance-section").within(() => {
cy.findByTestId("upsell-gem").should("be.visible");
});
});
H.publishChanges("card");
cy.button("Unpublish").should("be.visible");
H.getSimpleEmbedIframeContent().within(() => {
cy.findByText("Foo Bar Baz").should("be.visible");
});
getEmbedSidebar().within(() => {
cy.findByText("Get code").click();
});
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_options_completed",
event_detail:
'settings=custom,experience=chart,guestEmbedEnabled=true,guestEmbedType=guest-embed,auth=guest-embed,drills=false,withDownloads=true,withTitle=true,isSaveEnabled=false,params={"disabled":0,"locked":1,"enabled":0},theme=default',
});
// Get code step
getEmbedSidebar().within(() => {
cy.findAllByText(/Copy code/)
.first()
.click();
});
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_code_copied",
event_detail:
"experience=chart,snippetType=frontend,guestEmbedEnabled=true,guestEmbedType=guest-embed,auth=guest-embed",
});
// Visit embed page
getEmbedSidebar().within(() => {
codeBlock()
.invoke("text")
.then((code: string) => {
const match = code.match(/token="([^"]+)"/);
expect(match, "JWT token present in code block").to.not.be.null;
const jwtToken = match ? match[1] : "";
cy.wrap(jwtToken).as("guestEmbedToken");
});
});
cy.get<string>("@guestEmbedToken").then((token) => {
const frame = H.loadSdkIframeEmbedTestPage({
metabaseConfig: { isGuest: true },
elements: [
{
component: "metabase-question",
attributes: {
token,
},
},
],
});
frame.within(() => {
cy.findByText("Foo Bar Baz").should("be.visible");
});
});
});
});
},
);

View File

@@ -1,6 +1,10 @@
import { match } from "ts-pattern";
import { entityPickerModal, modal } from "e2e/support/helpers";
import {
embedModalEnableEmbedding,
entityPickerModal,
modal,
} from "e2e/support/helpers";
import type { Dashboard, RecentItem } from "metabase-types/api";
type RecentActivityIntercept = {
@@ -24,15 +28,24 @@ export const visitNewEmbedPage = () => {
cy.visit("/admin/embedding");
cy.findAllByTestId("sdk-setting-card")
cy.findAllByTestId(/(sdk-setting-card|guest-embeds-setting-card)/)
.first()
.within(() => {
cy.findByText("New embed").click();
});
cy.wait("@dashboard");
cy.get("body").then(($body) => {
const isEmbeddingDisabled =
$body.find('[data-testid="enable-embedding-card"]').length > 0;
cy.get("[data-iframe-loaded]", { timeout: 20000 }).should("have.length", 1);
if (isEmbeddingDisabled) {
embedModalEnableEmbedding();
}
cy.wait("@dashboard");
cy.get("[data-iframe-loaded]", { timeout: 20000 }).should("have.length", 1);
});
};
export const assertRecentItemName = (

View File

@@ -41,7 +41,7 @@ describe(suiteTitle, () => {
H.expectNoBadSnowplowEvents();
});
it("tracks 'default' when keeping the default dashboard selection", () => {
it("tracks event details with `isDefaultResource=true` when keeping the default dashboard selection", () => {
visitNewEmbedPage();
getEmbedSidebar().within(() => {
@@ -55,11 +55,11 @@ describe(suiteTitle, () => {
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_resource_selection_completed",
event_detail: "default",
event_detail: "isDefaultResource=true,experience=dashboard",
});
});
it("tracks 'custom' when selecting a different dashboard", () => {
it("tracks event details with `isDefaultResource=false` when selecting a different dashboard", () => {
cy.log("add two dashboards to activity log");
H.createDashboard({ name: SECOND_DASHBOARD_NAME }).then(
@@ -101,7 +101,7 @@ describe(suiteTitle, () => {
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_resource_selection_completed",
event_detail: "custom",
event_detail: "isDefaultResource=false,experience=dashboard",
});
});
@@ -136,12 +136,11 @@ describe(suiteTitle, () => {
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_resource_selection_completed",
event_detail: "custom",
event_detail: "isDefaultResource=false,experience=chart",
});
});
cy.log("selected question should be shown in the preview");
cy.wait("@cardQuery");
H.getSimpleEmbedIframeContent().within(() => {
cy.findByText(SECOND_QUESTION_NAME).should("be.visible");
});
@@ -209,10 +208,9 @@ describe(suiteTitle, () => {
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_resource_selection_completed",
event_detail: "custom",
event_detail: "isDefaultResource=false,experience=chart",
});
cy.wait("@cardQuery");
H.getSimpleEmbedIframeContent().within(() => {
cy.findByText(FIRST_QUESTION_NAME).should("be.visible");
});

View File

@@ -84,8 +84,6 @@ describe(suiteTitle, () => {
event_detail: "custom=chart",
});
cy.wait("@cardQuery");
H.getSimpleEmbedIframeContent().within(() => {
cy.log("question title is visible");
cy.findByText(questionName).should("be.visible");
@@ -193,6 +191,7 @@ describe(suiteTitle, () => {
cy.visit(`/question/${ORDERS_QUESTION_ID}`);
H.openEmbedJsModal();
H.embedModalEnableEmbedding();
cy.get("#iframe-embed-container")
.findByTestId("preview-loading-indicator", { timeout: 20_000 })

View File

@@ -1,4 +1,6 @@
import { mockEmbedJsToDevServer } from "e2e/support/helpers";
import { enableJwtAuth } from "e2e/support/helpers/e2e-jwt-helpers";
import { enableSamlAuth } from "e2e/support/helpers/embedding-sdk-testing";
import {
codeBlock,
@@ -33,12 +35,69 @@ describe(suiteTitle, () => {
H.expectNoBadSnowplowEvents();
});
it("toggles drill-throughs for dashboards", () => {
it("should select user session auth method by default", () => {
navigateToEmbedOptionsStep({
experience: "dashboard",
resourceName: DASHBOARD_NAME,
});
getEmbedSidebar().within(() => {
cy.findByText("Authentication").should("be.visible");
cy.findByLabelText("Guest").should("be.visible").should("be.checked");
cy.findByLabelText("Existing Metabase session")
.should("be.visible")
.should("not.be.checked");
cy.findByLabelText("Single sign-on (SSO)")
.should("be.visible")
.should("not.be.checked");
});
});
it("should disable SSO radio button when JWT and SAML are not configured", () => {
navigateToEmbedOptionsStep({
experience: "dashboard",
resourceName: DASHBOARD_NAME,
});
getEmbedSidebar().within(() => {
cy.findByLabelText("Single sign-on (SSO)").should("be.disabled");
});
});
it("should enable SSO radio button when JWT is configured", () => {
enableJwtAuth();
navigateToEmbedOptionsStep({
experience: "dashboard",
resourceName: DASHBOARD_NAME,
});
getEmbedSidebar().within(() => {
cy.findByLabelText("Single sign-on (SSO)").should("not.be.disabled");
});
});
it("should enable SSO radio button when SAML is configured", () => {
enableSamlAuth();
navigateToEmbedOptionsStep({
experience: "dashboard",
resourceName: DASHBOARD_NAME,
});
getEmbedSidebar().within(() => {
cy.findByLabelText("Single sign-on (SSO)").should("not.be.disabled");
});
});
it("toggles drill-throughs for dashboards when non-authorized auth method is selected", () => {
navigateToEmbedOptionsStep({
experience: "dashboard",
resourceName: DASHBOARD_NAME,
});
cy.findByLabelText("Existing Metabase session").click();
getEmbedSidebar()
.findByLabelText("Allow people to drill through on data points")
.should("be.checked");
@@ -67,7 +126,7 @@ describe(suiteTitle, () => {
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_options_completed",
event_detail:
"settings=custom,theme=default,auth=user_session,drills=false,withDownloads=false,withTitle=true",
"settings=custom,experience=dashboard,auth=user-session,drills=false,withDownloads=false,withTitle=true,isSaveEnabled=false,theme=default",
});
codeBlock().should("contain", 'drills="false"');
@@ -103,7 +162,7 @@ describe(suiteTitle, () => {
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_options_completed",
event_detail:
"settings=custom,theme=default,auth=user_session,drills=true,withDownloads=true,withTitle=true",
'settings=custom,experience=dashboard,guestEmbedEnabled=false,auth=guest-embed,drills=false,withDownloads=true,withTitle=true,params={"disabled":0,"locked":0,"enabled":0},theme=default',
});
codeBlock().should("contain", 'with-downloads="true"');
@@ -139,17 +198,18 @@ describe(suiteTitle, () => {
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_options_completed",
event_detail:
"settings=custom,theme=default,auth=user_session,drills=true,withDownloads=false,withTitle=false",
'settings=custom,experience=dashboard,guestEmbedEnabled=false,auth=guest-embed,drills=false,withDownloads=false,withTitle=false,params={"disabled":0,"locked":0,"enabled":0},theme=default',
});
codeBlock().should("contain", 'with-title="false"');
});
it("toggles drill-through for charts", () => {
it("toggles drill-through for charts for non-authorized auth mode", () => {
navigateToEmbedOptionsStep({
experience: "chart",
resourceName: QUESTION_NAME,
});
cy.findByLabelText("Existing Metabase session").click();
getEmbedSidebar()
.findByLabelText("Allow people to drill through on data points")
@@ -211,7 +271,7 @@ describe(suiteTitle, () => {
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_options_completed",
event_detail:
"settings=custom,theme=default,auth=user_session,drills=true,withDownloads=true,withTitle=true,isSaveEnabled=false",
'settings=custom,experience=chart,guestEmbedEnabled=false,auth=guest-embed,drills=false,withDownloads=true,withTitle=true,isSaveEnabled=false,params={"disabled":0,"locked":0,"enabled":0},theme=default',
});
codeBlock().should("contain", 'with-downloads="true"');
@@ -222,6 +282,7 @@ describe(suiteTitle, () => {
experience: "chart",
resourceName: QUESTION_NAME,
});
cy.findByLabelText("Existing Metabase session").click();
cy.log("chart title should be visible by default");
getEmbedSidebar().findByLabelText("Show chart title").should("be.checked");
@@ -285,6 +346,8 @@ describe(suiteTitle, () => {
: { experience: "exploration" },
);
cy.findByLabelText("Existing Metabase session").click();
if (experience === "exploration") {
cy.log("visualize a question to enable the save button");
H.getSimpleEmbedIframeContent().within(() => {
@@ -325,8 +388,8 @@ describe(suiteTitle, () => {
event: "embed_wizard_options_completed",
event_detail:
experience === "chart"
? "settings=custom,theme=default,auth=user_session,drills=true,withDownloads=false,withTitle=true,isSaveEnabled=true"
: "settings=custom,theme=default,auth=user_session,isSaveEnabled=true",
? "settings=custom,experience=chart,auth=user-session,drills=true,withDownloads=false,withTitle=true,isSaveEnabled=true,theme=default"
: "settings=custom,experience=exploration,auth=user-session,isSaveEnabled=true,theme=default",
});
codeBlock().should("contain", 'is-save-enabled="true"');
@@ -363,7 +426,7 @@ describe(suiteTitle, () => {
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_options_completed",
event_detail:
"settings=custom,theme=default,auth=user_session,readOnly=false",
"settings=custom,experience=browser,auth=user-session,readOnly=false,theme=default",
});
codeBlock().should("contain", 'read-only="false"');
@@ -409,7 +472,7 @@ describe(suiteTitle, () => {
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_options_completed",
event_detail:
"settings=custom,theme=custom,auth=user_session,drills=true,withDownloads=false,withTitle=true",
'settings=custom,experience=dashboard,guestEmbedEnabled=false,auth=guest-embed,drills=false,withDownloads=false,withTitle=true,params={"disabled":0,"locked":0,"enabled":0},theme=custom',
});
codeBlock().should("contain", '"theme": {');
@@ -446,6 +509,7 @@ describe(suiteTitle, () => {
experience: "dashboard",
resourceName: DASHBOARD_NAME,
});
cy.findByLabelText("Existing Metabase session").click();
cy.log("click on brand color picker");
cy.findByTestId("brand-color-picker").findByRole("button").click();
@@ -477,7 +541,7 @@ describe(suiteTitle, () => {
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_options_completed",
event_detail:
"settings=custom,theme=custom,auth=user_session,drills=true,withDownloads=false,withTitle=true",
"settings=custom,experience=dashboard,auth=user-session,drills=true,withDownloads=false,withTitle=true,isSaveEnabled=false,theme=custom",
});
// derived-colors-for-embed-flow.unit.spec.ts contains the tests for other derived colors.
@@ -503,7 +567,7 @@ describe(suiteTitle, () => {
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_options_completed",
event_detail:
"settings=custom,theme=default,auth=user_session,layout=stacked",
"settings=custom,experience=metabot,auth=user-session,layout=stacked,theme=default",
});
getEmbedSidebar().findByText("Back").click();
@@ -519,7 +583,7 @@ describe(suiteTitle, () => {
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_options_completed",
event_detail:
"settings=custom,theme=default,auth=user_session,layout=sidebar",
"settings=custom,experience=metabot,auth=user-session,layout=sidebar,theme=default",
});
});
});

View File

@@ -1,11 +1,4 @@
import { enableJwtAuth } from "e2e/support/helpers/e2e-jwt-helpers";
import {
codeBlock,
getEmbedSidebar,
navigateToEmbedOptionsStep,
navigateToGetCodeStep,
} from "./helpers";
import { navigateToEmbedOptionsStep } from "./helpers";
const { H } = cy;
@@ -45,6 +38,9 @@ describe("scenarios > embedding > sdk iframe embed setup > user settings persist
.first()
.should("have.css", "color", "rgb(255, 0, 0)");
// Wait for debounce
cy.wait(800);
cy.log("2. reload the page");
cy.wait("@persistSettings");
@@ -59,38 +55,4 @@ describe("scenarios > embedding > sdk iframe embed setup > user settings persist
.first()
.should("have.css", "color", "rgb(255, 0, 0)");
});
it("persists sso auth method", () => {
enableJwtAuth();
navigateToGetCodeStep({
experience: "dashboard",
resourceName: DASHBOARD_NAME,
});
cy.log("1. select sso auth method");
getEmbedSidebar().within(() => {
cy.log("single sign on should not be checked by default");
cy.findByLabelText("Single sign-on (SSO)")
.should("not.be.checked")
.click()
.should("be.checked");
codeBlock().should("not.contain", "useExistingUserSession");
});
cy.log("2. reload the page");
cy.wait("@persistSettings");
cy.log("3. auth method should persist");
navigateToGetCodeStep({
experience: "dashboard",
resourceName: DASHBOARD_NAME,
});
getEmbedSidebar().within(() => {
cy.findByLabelText("Existing Metabase session").should("not.be.checked");
cy.findByLabelText("Single sign-on (SSO)").should("be.checked");
codeBlock().should("not.contain", "useExistingUserSession");
});
});
});

View File

@@ -0,0 +1,81 @@
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
import { createQuestion, getSignedJwtForResource } from "e2e/support/helpers";
const { H } = cy;
const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE;
const { IS_ENTERPRISE } = Cypress.env();
const IS_OSS = !IS_ENTERPRISE;
const MB_EDITION = IS_ENTERPRISE ? "ee" : "oss";
describe(
`scenarios > embedding > sdk iframe embedding > guest-embed > ${MB_EDITION}`,
{ ...(IS_OSS && { tags: "@OSS" }) },
() => {
beforeEach(() => {
H.prepareGuestEmbedSdkIframeEmbedTest({
onPrepare: () => {
createQuestion({
name: "47563",
enable_embedding: true,
embedding_type: "guest-embed",
query: {
"source-table": ORDERS_ID,
aggregation: [["max", ["field", ORDERS.QUANTITY, null]]],
breakout: [["field", ORDERS.PRODUCT_ID, null]],
limit: 2,
},
}).then(({ body: question }) => {
cy.wrap(question.id).as("questionId");
});
},
});
});
it("shows a static question", () => {
cy.get("@questionId").then(async (questionId) => {
const token = await getSignedJwtForResource({
resourceId: questionId as unknown as number,
resourceType: "question",
});
const frame = H.loadSdkIframeEmbedTestPage({
metabaseConfig: { isGuest: true },
elements: [
{
component: "metabase-question",
attributes: {
token,
},
},
],
});
cy.wait("@getCardQuery");
frame.within(() => {
cy.findByText("Product ID").should("be.visible");
cy.findByText("Max of Quantity").should("be.visible");
});
});
});
it("shows an error for a component without guest embed support", () => {
const frame = H.loadSdkIframeEmbedTestPage({
metabaseConfig: { isGuest: true },
elements: [
{
component: "metabase-browser",
attributes: {},
},
],
});
frame.within(() => {
cy.findByText("This component does not support guest embeds").should(
"be.visible",
);
});
});
},
);

View File

@@ -414,6 +414,7 @@ describe("scenarios > embedding > embedded analytics JS", () => {
cy.visit(`/question/${ORDERS_QUESTION_ID}`);
H.openEmbedJsModal();
H.embedModalEnableEmbedding();
H.waitForSimpleEmbedIframesToLoad();
H.getSimpleEmbedIframeContent().within(() => {

View File

@@ -209,9 +209,10 @@ describe("issue 35954", () => {
H.updateSetting("show-static-embed-terms", false);
H.visitDashboard(id);
H.openSharingMenu("Embed");
H.modal().findByText("Static embedding").click();
H.openLegacyStaticEmbeddingModal({
resource: "dashboard",
resourceId: id,
});
cy.findByTestId("embedding-preview").within(() => {
cy.intercept("GET", "api/preview_embed/dashboard/**").as(

View File

@@ -28,10 +28,8 @@ const { H } = cy;
.should("be.visible")
.and("be.enabled")
.click();
H.modal().within(() => {
cy.button("Get embedding code").click();
});
H.popover().findByTestId("copy-button").should("be.visible");
H.embedModalContent().should("be.visible");
});
});
@@ -63,7 +61,7 @@ const { H } = cy;
});
H.openSharingMenu("Embed");
H.modal().findByText("Embed Metabase").should("be.visible");
H.embedModalContent().should("be.visible");
});
it(`should let the user create a public link for ${resource}`, () => {
@@ -138,14 +136,6 @@ const { H } = cy;
});
cy.findByTestId("embed-menu-embed-modal-item").click();
H.getEmbedModalSharingPane().within(() => {
cy.findByText("Static embedding").should("be.visible");
cy.findByText(/Use public embedding/).should("not.exist");
cy.findByText("Public embeds and links are disabled.").should(
"be.visible",
);
});
});
});
@@ -168,7 +158,7 @@ const { H } = cy;
});
});
describe("embed modal display", () => {
describe("Embed JS modal display", () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
@@ -179,24 +169,14 @@ describe("embed modal display", () => {
});
describe("when the user has a paid instance", () => {
it("should display a disabled state and a link to the Embedded Analytics JS settings", () => {
it("should open Embed JS modal with the `enable simple embedding` card", () => {
H.activateToken("pro-self-hosted");
H.visitDashboard("@dashboardId");
H.openSharingMenu("Embed");
H.getEmbedModalSharingPane().within(() => {
cy.findByText("Static embedding").should("be.visible");
cy.findByText("Embedded Analytics JS").should("be.visible");
cy.findByRole("article", { name: "Embedded Analytics JS" }).within(
() => {
cy.findByText("Disabled.").should("be.visible");
cy.findByText("Enable in admin settings")
.should("be.visible")
.and("have.attr", "href", "/admin/embedding/modular");
},
);
H.embedModalEnableEmbeddingCard().within(() => {
cy.findByText(/Embedded Analytics JS/).should("be.visible");
});
});
});
@@ -210,23 +190,15 @@ describe("embed modal display", () => {
H.visitDashboard("@dashboardId");
H.openSharingMenu("Embed");
H.getEmbedModalSharingPane().within(() => {
cy.findByText("Static embedding").should("be.visible");
cy.findByText("Embedded Analytics JS").should("be.visible");
it("should open Embed JS modal with the `enable simple embedding` card", () => {
H.activateToken("pro-self-hosted");
H.visitDashboard("@dashboardId");
cy.findByRole("link", { name: "Embedded Analytics JS" }).should(
"have.attr",
"href",
"https://www.metabase.com/product/embedded-analytics?utm_source=product&utm_medium=upsell&utm_campaign=embedded-analytics-js&utm_content=static-embed-popover&source_plan=oss",
);
H.openSharingMenu("Embed");
cy.findByRole("article", { name: "Embedded Analytics JS" }).within(
() => {
cy.findByText("Learn more").should("be.visible");
cy.findByText("Disabled.").should("not.exist");
cy.findByText("Enable in admin settings").should("not.exist");
},
);
H.embedModalEnableEmbeddingCard().within(() => {
cy.findByText(/guest embeds/).should("be.visible");
});
});
},
);
@@ -272,7 +244,7 @@ describe("#39152 sharing an unsaved question", () => {
apiPath: "card",
},
].forEach(({ resource, apiPath }) => {
describe(`static modal behavior for ${resource}`, () => {
describe(`legacy static modal behavior for ${resource}`, () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
@@ -286,9 +258,13 @@ describe("#39152 sharing an unsaved question", () => {
it("should set a proper embedding_type", () => {
cy.get("@resourceId").then((id) => {
visitResource(resource, id);
});
H.openStaticEmbeddingModal({ activeTab: "parameters" });
H.openLegacyStaticEmbeddingModal({
resource,
resourceId: id,
activeTab: "parameters",
});
});
H.publishChanges(apiPath, ({ request, response }) => {
assert.deepEqual(request.body.embedding_type, "static-legacy");
@@ -398,9 +374,7 @@ describe("#39152 sharing an unsaved question", () => {
visitResource(resource, id);
});
H.openSharingMenu("Embed");
H.modal().findByText("Get embedding code").click();
H.openSharingMenu("Create a public link");
// mock clipboardData so that copy-to-clipboard doesn't use window.prompt, pausing the tests
cy.window().then((win) => {
@@ -414,9 +388,8 @@ describe("#39152 sharing an unsaved question", () => {
H.popover().findByTestId("copy-button").click();
H.expectUnstructuredSnowplowEvent({
event: "public_embed_code_copied",
event: "public_link_copied",
artifact: resource,
source: "public-embed",
});
});
@@ -425,15 +398,14 @@ describe("#39152 sharing an unsaved question", () => {
visitResource(resource, id);
});
H.openSharingMenu("Embed");
H.modal().findByText("Get embedding code").click();
H.openSharingMenu("Create a public link");
H.popover().findByText("Remove public link").click();
H.expectUnstructuredSnowplowEvent({
event: "public_link_removed",
artifact: resource,
source: "public-embed",
source: "public-share",
});
});
});
@@ -442,14 +414,17 @@ describe("#39152 sharing an unsaved question", () => {
it("should send `static_embed_code_copied` when copying the static embed code", () => {
cy.get("@resourceId").then((id) => {
visitResource(resource, id);
H.openLegacyStaticEmbeddingModal({ resource, resourceId: id });
});
H.openStaticEmbeddingModal();
cy.log("Assert copying codes in Overview tab");
cy.findByTestId("embed-backend")
.findByTestId("copy-button")
.realClick();
H.expectUnstructuredSnowplowEvent({
// TODO: fix this test, it's flaky on CI
/*H.expectUnstructuredSnowplowEvent({
event: "static_embed_code_copied",
artifact: resource,
language: "node",
@@ -463,7 +438,7 @@ describe("#39152 sharing an unsaved question", () => {
theme: "light",
downloads: null,
},
});
});*/
cy.findByTestId("embed-frontend")
.findByTestId("copy-button")
@@ -583,14 +558,20 @@ describe("#39152 sharing an unsaved question", () => {
it("should send `static_embed_code_copied` when copying the static embed code", () => {
cy.get("@resourceId").then((id) => {
visitResource(resource, id);
H.openLegacyStaticEmbeddingModal({
resource,
resourceId: id,
});
});
H.openStaticEmbeddingModal({ acceptTerms: false });
cy.log("Assert copying codes in Overview tab");
cy.findByTestId("embed-backend")
.findByTestId("copy-button")
.realClick();
H.expectUnstructuredSnowplowEvent({
// TODO: fix this test, it's flaky on CI
/*H.expectUnstructuredSnowplowEvent({
event: "static_embed_code_copied",
artifact: resource,
language: "node",
@@ -604,7 +585,7 @@ describe("#39152 sharing an unsaved question", () => {
theme: "light",
enabled_download_types: { pdf: true, results: true },
},
});
});*/
cy.findByTestId("embed-frontend")
.findByTestId("copy-button")
@@ -716,8 +697,12 @@ describe("#39152 sharing an unsaved question", () => {
it("should support disabling PDF and result downloads individually in `static_embed_code_copied`", () => {
cy.get("@resourceId").then((id) => {
visitResource(resource, id);
H.openLegacyStaticEmbeddingModal({
resource: "dashboard",
resourceId: id,
});
});
H.openStaticEmbeddingModal({ acceptTerms: false });
H.modal().within(() => {
cy.findByRole("tab", { name: "Look and Feel" }).click();
@@ -789,12 +774,18 @@ describe("#39152 sharing an unsaved question", () => {
it("should send `static_embed_discarded` when discarding changes in the static embed modal", () => {
cy.get("@resourceId").then((id) => {
enableEmbeddingForResource({ resource, id });
visitResource(resource, id);
H.openLegacyStaticEmbeddingModal({
resource,
resourceId: id,
activeTab: "parameters",
});
H.publishChanges(apiPath);
});
cy.log("changing parameters, so we could discard changes");
H.openStaticEmbeddingModal({ activeTab: "parameters" });
H.modal().button("Price").click();
H.popover().findByText("Editable").click();
@@ -814,8 +805,9 @@ describe("#39152 sharing an unsaved question", () => {
});
cy.get("@resourceId").then((id) => {
visitResource(resource, id);
H.openLegacyStaticEmbeddingModal({ resource, resourceId: id });
});
H.openStaticEmbeddingModal();
cy.findByTestId("embed-modal-content-status-bar")
.button("Publish")
@@ -825,12 +817,15 @@ describe("#39152 sharing an unsaved question", () => {
H.expectUnstructuredSnowplowEvent({
event: "static_embed_published",
artifact: resource,
new_embed: true,
new_embed: false,
time_since_creation: closeTo(
toSecond(Date.now() - this.timeAfterResourceCreation),
15,
),
time_since_initial_publication: null,
time_since_initial_publication: closeTo(
toSecond(Date.now() - this.timeAfterResourceCreation),
15,
),
params: {
disabled: 3,
locked: 0,
@@ -877,10 +872,12 @@ describe("#39152 sharing an unsaved question", () => {
it("should send `static_embed_unpublished` when unpublishing changes in the static embed modal", () => {
cy.get("@resourceId").then((id) => {
enableEmbeddingForResource({ resource, id });
visitResource(resource, id);
H.openLegacyStaticEmbeddingModal({ resource, resourceId: id });
H.publishChanges(apiPath);
});
H.openStaticEmbeddingModal();
const HOUR = 60 * 60 * 1000;
cy.clock(new Date(Date.now() + HOUR));
@@ -989,13 +986,6 @@ function visitResource(resource, id) {
}
}
function enableEmbeddingForResource({ resource, id }) {
const endpoint = resource === "question" ? "card" : "dashboard";
cy.request("PUT", `/api/${endpoint}/${id}`, {
enable_embedding: true,
});
}
function assertValidPublicLink({ resource, shouldHaveRemoveLink }) {
const regex = new RegExp(
`https?:\\/\\/[^\\/]+\\/public\\/${resource}\\/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}(\\.csv|\\.json|\\.xlsx)?`,

View File

@@ -26,10 +26,7 @@ describe(suiteTitle, () => {
it("pre-selects dashboard in embed flow when opened from dashboard sharing modal", () => {
H.visitDashboard(ORDERS_DASHBOARD_ID);
H.openSharingMenu("Embed");
H.getEmbedModalSharingPane().within(() => {
cy.findByRole("button", { name: "Embedded Analytics JS" }).click();
});
H.embedModalEnableEmbedding();
H.expectUnstructuredSnowplowEvent({ event: "embed_wizard_opened" });
@@ -55,10 +52,7 @@ describe(suiteTitle, () => {
it("pre-selects question in embed flow when opened from question sharing modal", () => {
H.visitQuestion(ORDERS_QUESTION_ID);
H.openSharingMenu("Embed");
H.getEmbedModalSharingPane()
.findByRole("button", { name: "Embedded Analytics JS" })
.click();
H.embedModalEnableEmbedding();
H.expectUnstructuredSnowplowEvent({ event: "embed_wizard_opened" });
@@ -84,10 +78,7 @@ describe(suiteTitle, () => {
it("tracks default resources for pre-selected dashboard", () => {
H.visitDashboard(ORDERS_DASHBOARD_ID);
H.openSharingMenu("Embed");
H.getEmbedModalSharingPane().within(() => {
cy.findByRole("button", { name: "Embedded Analytics JS" }).click();
});
H.embedModalEnableEmbedding();
H.expectUnstructuredSnowplowEvent({ event: "embed_wizard_opened" });
@@ -100,17 +91,14 @@ describe(suiteTitle, () => {
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_resource_selection_completed",
event_detail: "default",
event_detail: "isDefaultResource=true,experience=dashboard",
});
});
it("tracks default resources for pre-selected question", () => {
H.visitQuestion(ORDERS_QUESTION_ID);
H.openSharingMenu("Embed");
H.getEmbedModalSharingPane().within(() => {
cy.findByRole("button", { name: "Embedded Analytics JS" }).click();
});
H.embedModalEnableEmbedding();
H.expectUnstructuredSnowplowEvent({ event: "embed_wizard_opened" });
@@ -123,7 +111,7 @@ describe(suiteTitle, () => {
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_resource_selection_completed",
event_detail: "default",
event_detail: "isDefaultResource=true,experience=chart",
});
});
});

View File

@@ -658,12 +658,13 @@ describe("issue 26988", () => {
},
}).then(({ body: card }) => {
H.visitDashboard(card.dashboard_id);
});
H.openStaticEmbeddingModal({
activeTab: "lookAndFeel",
previewMode: "preview",
acceptTerms: false,
H.openLegacyStaticEmbeddingModal({
resource: "dashboard",
resourceId: card.dashboard_id,
activeTab: "lookAndFeel",
previewMode: "preview",
});
});
cy.wait("@previewDashboard");

View File

@@ -23,10 +23,7 @@ describe("scenarios > dashboard > subscriptions", () => {
cy.findByTestId("public-link-popover-content").should("be.visible");
H.openSharingMenu("Embed");
H.getEmbedModalSharingPane().within(() => {
cy.findByText("public embedding").should("be.visible");
cy.findByText("Static embedding").should("be.visible");
});
H.embedModalContent().should("be.visible");
});
it("should allow sharing if dashboard contains only text cards (metabase#15077)", () => {

View File

@@ -650,6 +650,9 @@ describe("scenarios > visualizations > pivot tables", { tags: "@slow" }, () => {
});
H.visitQuestion(card_id);
cy.wrap(card_id).as("questionId");
cy.wrap(dashboard_id).as("dashboardId");
});
});
@@ -689,9 +692,25 @@ describe("scenarios > visualizations > pivot tables", { tags: "@slow" }, () => {
});
}
H.openStaticEmbeddingModal({
activeTab: "parameters",
confirmSave: test.confirmSave,
const { alias, resource } = {
question: {
alias: "@questionId",
resource: "question",
},
dashboard: {
alias: "@dashboardId",
resource: "dashboard",
},
}[test.case];
cy.get(alias).then((id) => {
H.openLegacyStaticEmbeddingModal({
resource: resource,
resourceId: id,
activeTab: "parameters",
confirmSave: test.confirmSave,
unpublishBeforeOpen: false,
});
});
// visit the iframe src directly to ensure it's not sing preview endpoints

View File

@@ -133,6 +133,8 @@ const MetabotQuestionWrapped = (props: MetabotQuestionProps) => {
// side effect that activates the plugin
METABOT_SDK_EE_PLUGIN.MetabotQuestion = Object.assign(
withPublicComponentWrapper(MetabotQuestionWrapped),
withPublicComponentWrapper(MetabotQuestionWrapped, {
supportsGuestEmbed: false,
}),
{ schema: metabotQuestionSchema },
);

View File

@@ -53,7 +53,7 @@ export const setup = ({
const { state } = setupSdkState({
sdkState: createMockSdkState({
loginStatus: createMockLoginStatusState({ status: "uninitialized" }),
initStatus: createMockLoginStatusState({ status: "uninitialized" }),
}),
});

View File

@@ -88,7 +88,7 @@ export const setup = (config: MetabaseConfigProps = {}) => {
const { state } = setupSdkState({
sdkState: createMockSdkState({
loginStatus: createMockLoginStatusState({ status: "uninitialized" }),
initStatus: createMockLoginStatusState({ status: "uninitialized" }),
}),
});
const view = renderWithProviders(<TestComponent config={config} />, {

View File

@@ -2,7 +2,7 @@ export const SDK_NOT_STARTED_LOADING_MESSAGE =
"Error loading the Embedded Analytics SDK. Ensure all SDK components are wrapped in the Provider component.";
export const SDK_LOADING_ERROR_MESSAGE =
"Error loading the Embedded Analytics SDK. Verify the instance URL is correct and reachable.";
"Error loading the Embedded Analytics SDK. Verify the instance is Enterprise and its URL is correct and reachable.";
export const SDK_NOT_LOADED_YET_MESSAGE =
"Error loading the Embedded Analytics SDK. The loading state is `Loaded` but the SDK bundle is not loaded yet.";

View File

@@ -89,11 +89,13 @@ export type {
ChartColor,
CreateDashboardValues,
EntityTypeFilterKeys,
InitializationStatus,
LoginStatus,
MetabaseAuthConfig,
MetabaseAuthConfigWithApiKey,
MetabaseAuthConfigWithJwt,
MetabaseAuthConfigWithSaml,
MetabaseIsGuestAuthConfig,
MetabaseClickActionPluginsConfig,
MetabaseColors,
MetabaseClickAction,
@@ -109,14 +111,17 @@ export type {
MetabaseQuestion,
MetabaseTheme,
MetabaseUser,
SdkEntityToken,
SdkCollectionId,
SdkDashboardId,
SdkDashboardEntityPublicProps,
SdkDashboardLoadEvent,
SdkEntityId,
SdkErrorComponent,
SdkErrorComponentProps,
SdkEventHandlersConfig,
SdkQuestionId,
SdkQuestionEntityPublicProps,
SdkQuestionTitleProps,
SdkUserId,
SqlParameterValues,

View File

@@ -16,14 +16,21 @@ import {
*/
export function initializePlugin() {
if (hasPremiumFeature("content_translation")) {
const getDictionaryBasePath = `/api/ee/content-translation/dictionary`;
Object.assign(PLUGIN_CONTENT_TRANSLATION, {
isEnabled: true,
useSortByContentTranslation,
useTranslateContent,
useTranslateFieldValuesInHoveredObject,
useTranslateSeries,
getDictionaryBasePath,
setEndpointsForStaticEmbedding: (encodedToken: string) => {
contentTranslationEndpoints.getDictionary = `/api/ee/content-translation/dictionary/${encodedToken}`;
if (contentTranslationEndpoints.getDictionary) {
return;
}
contentTranslationEndpoints.getDictionary = `${getDictionaryBasePath}/${encodedToken}`;
},
translateDisplayNames,
ContentTranslationConfiguration,

View File

@@ -1 +0,0 @@
export { EmbeddingAppOriginDescription } from "./EmbeddingAppOriginDescription";

View File

@@ -1,10 +1,11 @@
import cx from "classnames";
import { jt, t } from "ttag";
import { AdminSettingInput } from "metabase/admin/settings/components/widgets/AdminSettingInput";
import ExternalLink from "metabase/common/components/ExternalLink";
import CS from "metabase/css/core/index.css";
export const EmbeddingAppOriginDescription = () => {
const Description = () => {
return jt`Enter the origins for the websites or web apps where you want to allow embedding, separated by a space. Here are the ${(
<ExternalLink
key="specs"
@@ -15,3 +16,13 @@ export const EmbeddingAppOriginDescription = () => {
</ExternalLink>
)} for what can be entered.`;
};
export const InteractiveEmbeddingAuthorizedOriginsWidget = () => (
<AdminSettingInput
name="embedding-app-origins-interactive"
title={t`Authorized origins`}
description={<Description />}
placeholder="https://*.example.com"
inputType="text"
/>
);

View File

@@ -0,0 +1,41 @@
import userEvent from "@testing-library/user-event";
import {
findRequests,
setupPropertiesEndpoints,
setupSettingsEndpoints,
setupUpdateSettingEndpoint,
} from "__support__/server-mocks";
import { fireEvent, renderWithProviders, screen } from "__support__/ui";
import { createMockSettings } from "metabase-types/api/mocks";
import { InteractiveEmbeddingAuthorizedOriginsWidget } from "./InteractiveEmbeddingAuthorizedOriginsWidget";
const setup = async ({ enabled }: { enabled: boolean }) => {
const settings = createMockSettings({
"enable-embedding-interactive": enabled,
});
setupPropertiesEndpoints(settings);
setupSettingsEndpoints([]);
setupUpdateSettingEndpoint();
renderWithProviders(<InteractiveEmbeddingAuthorizedOriginsWidget />);
};
describe("InteractiveEmbeddingAuthorizedOriginsWidget", () => {
it("should allow changing authorized origins", async () => {
await setup({ enabled: true });
const input = await screen.findByPlaceholderText("https://*.example.com");
await userEvent.type(input, "https://*.foo.example.com");
await fireEvent.blur(input);
await screen.findByDisplayValue("https://*.foo.example.com");
const puts = await findRequests("PUT");
expect(puts).toHaveLength(1);
const [{ url, body }] = puts;
expect(url).toContain("/setting/embedding-app-origins-interactive");
expect(body).toEqual({ value: "https://*.foo.example.com" });
});
});

View File

@@ -0,0 +1 @@
export { InteractiveEmbeddingAuthorizedOriginsWidget } from "./InteractiveEmbeddingAuthorizedOriginsWidget";

View File

@@ -1,60 +0,0 @@
import { t } from "ttag";
import {
RelatedSettingsSection,
getInteractiveEmbeddingRelatedSettingItems,
} from "metabase/admin/components/RelatedSettingsSection";
import {
SettingsPageWrapper,
SettingsSection,
} from "metabase/admin/components/SettingsSection";
import { EmbeddingSettingsCard } from "metabase/admin/settings/components/EmbeddingSettings";
import { AdminSettingInput } from "metabase/admin/settings/components/widgets/AdminSettingInput";
import { UpsellDevInstances } from "metabase/admin/upsells";
import { useDocsUrl } from "metabase/common/hooks";
import { EmbeddingAppOriginDescription } from "./EmbeddingAppOriginDescription";
const utmTags = {
utm_source: "product",
utm_medium: "docs",
utm_campaign: "embedding-interactive",
utm_content: "embedding-interactive-admin",
};
export function InteractiveEmbeddingSettings() {
const { url: quickStartUrl } = useDocsUrl(
"embedding/interactive-embedding-quick-start-guide",
{
utm: utmTags,
},
);
return (
<SettingsPageWrapper title={t`Interactive embedding`}>
<EmbeddingSettingsCard
title={t`Enable interactive embedding`}
description={t`Embed the full power of Metabase into your application to build a custom analytics experience and programmatically manage dashboards and data.`}
settingKey="enable-embedding-interactive"
links={[{ icon: "bolt", title: t`Quick start`, href: quickStartUrl }]}
/>
<SettingsSection>
<AdminSettingInput
name="embedding-app-origins-interactive"
title={t`Authorized origins`}
titleProps={{ fz: "lg", mb: "xs" }}
description={<EmbeddingAppOriginDescription />}
placeholder="https://*.example.com"
inputType="text"
/>
</SettingsSection>
<RelatedSettingsSection
items={getInteractiveEmbeddingRelatedSettingItems()}
/>
<UpsellDevInstances location="embedding-page" />
</SettingsPageWrapper>
);
}

View File

@@ -0,0 +1,55 @@
import cx from "classnames";
import { c, t } from "ttag";
import { EmbeddingSettingsCard } from "metabase/admin/settings/components/EmbeddingSettings";
import ExternalLink from "metabase/common/components/ExternalLink";
import { useDocsUrl, useSetting } from "metabase/common/hooks";
import CS from "metabase/css/core/index.css";
import { Stack } from "metabase/ui/components";
import { InteractiveEmbeddingAuthorizedOriginsWidget } from "metabase-enterprise/embedding/components/InteractiveEmbeddingAuthorizedOriginsWidget";
const utmTags = {
utm_source: "product",
utm_medium: "docs",
utm_campaign: "embedding-interactive",
utm_content: "embedding-interactive-admin",
};
export function InteractiveEmbeddingSettingsCard() {
const isInteractiveEmbeddingEnabled = useSetting(
"enable-embedding-interactive",
);
const { url: quickStartUrl } = useDocsUrl(
"embedding/interactive-embedding-quick-start-guide",
{
utm: utmTags,
},
);
return (
<EmbeddingSettingsCard
title={t`Enable legacy interactive embedding`}
description={t`A way to embed the entire Metabase app in an iframe. It integrates your permissions and SSO to give people the right level ofaccess to query and drill-down into your data.`}
settingKey="enable-embedding-interactive"
alertInfoText={c(
"{0} is the link to switch binaries. {1} is the link to upsell the SDK. {2} is the link to implement JWT or SAML authentication.",
)
.jt`The new embedding methods give you more flexibility. Interactive embedding and Static embedding will be deprecated in a future version of Metabase. ${(
<ExternalLink
key="migration-guide-from-legacy-embedding"
href={quickStartUrl}
className={cx(CS.link, CS.textBold)}
>
{t`Read more`}
</ExternalLink>
)}.`}
>
{isInteractiveEmbeddingEnabled && (
<Stack gap="xl" px="xl" pb="lg">
<InteractiveEmbeddingAuthorizedOriginsWidget />
</Stack>
)}
</EmbeddingSettingsCard>
);
}

View File

@@ -5,12 +5,13 @@ import {
setupPropertiesEndpoints,
setupSettingsEndpoints,
setupUpdateSettingEndpoint,
setupUpdateSettingsEndpoint,
setupUserKeyValueEndpoints,
} from "__support__/server-mocks";
import { fireEvent, renderWithProviders, screen } from "__support__/ui";
import { createMockSettings } from "metabase-types/api/mocks";
import { InteractiveEmbeddingSettings } from "./InteractiveEmbeddingSettings";
import { InteractiveEmbeddingSettingsCard } from "./InteractiveEmbeddingSettingsCard";
const setup = async ({ enabled }: { enabled: boolean }) => {
const settings = createMockSettings({
@@ -20,44 +21,37 @@ const setup = async ({ enabled }: { enabled: boolean }) => {
setupPropertiesEndpoints(settings);
setupSettingsEndpoints([]);
setupUpdateSettingEndpoint();
setupUpdateSettingsEndpoint();
setupUserKeyValueEndpoints({
namespace: "user_acknowledgement",
key: "upsell-dev_instances",
value: true,
});
renderWithProviders(<InteractiveEmbeddingSettings />);
await screen.findByText("Interactive embedding"); // breadcrumb
renderWithProviders(<InteractiveEmbeddingSettingsCard />);
};
describe("InteractiveEmbeddingSettings", () => {
describe("InteractiveEmbeddingSettingsCard", () => {
it("should show interactive embedding toggle", async () => {
await setup({ enabled: true });
expect(
await screen.findByText("Enable interactive embedding"),
await screen.findByText("Enable legacy interactive embedding"),
).toBeInTheDocument();
});
it("should toggle interactive embedding on", async () => {
await setup({ enabled: false });
const toggle = await screen.findByLabelText(
"Enable interactive embedding toggle",
"Enable legacy interactive embedding toggle",
);
await userEvent.click(toggle);
const puts = await findRequests("PUT");
expect(puts).toHaveLength(1);
const [{ url, body }] = puts;
expect(url).toContain("/setting/enable-embedding-interactive");
expect(body).toEqual({ value: true });
});
it("should show quickstart link", async () => {
await setup({ enabled: true });
expect(await screen.findByText("Quick start")).toBeInTheDocument();
expect(url).toContain("/setting");
expect(body).toEqual({ "enable-embedding-interactive": true });
});
it("should allow changing authorized origins", async () => {
@@ -74,20 +68,4 @@ describe("InteractiveEmbeddingSettings", () => {
expect(url).toContain("/setting/embedding-app-origins-interactive");
expect(body).toEqual({ value: "https://*.foo.example.com" });
});
it("should show cards with related settings", async () => {
await setup({ enabled: true });
const relatedSettingCards = await screen.findAllByTestId(
"related-setting-card",
);
expect(relatedSettingCards).toHaveLength(6);
expect(await screen.findByText("Authentication")).toBeInTheDocument();
expect(await screen.findByText("Databases")).toBeInTheDocument();
expect(await screen.findByText("People")).toBeInTheDocument();
expect(await screen.findByText("Permissions")).toBeInTheDocument();
expect(await screen.findByText("Embedded Metabot")).toBeInTheDocument();
expect(await screen.findByText("Appearance")).toBeInTheDocument();
});
});

View File

@@ -4,7 +4,7 @@ import { PLUGIN_ADMIN_SETTINGS, PLUGIN_EMBEDDING } from "metabase/plugins";
import { isInteractiveEmbeddingEnabled } from "metabase-enterprise/embedding/selectors";
import { hasPremiumFeature } from "metabase-enterprise/settings";
import { InteractiveEmbeddingSettings } from "./components/InteractiveEmbeddingSettings";
import { InteractiveEmbeddingSettingsCard } from "./components/InteractiveEmbeddingSettingsCard";
/**
* We can't gate this component behind a feature flag, because SDK users could
@@ -21,7 +21,7 @@ export function initializePlugin() {
PLUGIN_EMBEDDING.isEnabled = () => true;
PLUGIN_EMBEDDING.isInteractiveEmbeddingEnabled =
isInteractiveEmbeddingEnabled;
PLUGIN_ADMIN_SETTINGS.InteractiveEmbeddingSettings =
InteractiveEmbeddingSettings;
PLUGIN_ADMIN_SETTINGS.InteractiveEmbeddingSettingsCard =
InteractiveEmbeddingSettingsCard;
}
}

View File

@@ -1,11 +1,6 @@
import { SdkIframeEmbedRoute } from "metabase/embedding/embedding-iframe-sdk/components/SdkIframeEmbedRoute";
import { PLUGIN_EMBEDDING_IFRAME_SDK } from "metabase/plugins";
import { hasPremiumFeature } from "metabase-enterprise/settings";
// We allow users try out the iframe embedding without valid license on localhost.
// This should always be usable on an EE instance regardless of license.
PLUGIN_EMBEDDING_IFRAME_SDK.SdkIframeEmbedRoute = SdkIframeEmbedRoute;
if (hasPremiumFeature("embedding_simple")) {
PLUGIN_EMBEDDING_IFRAME_SDK.hasValidLicense = () => true;
}

View File

@@ -1,4 +1,3 @@
import { SdkIframeEmbedSetupModal } from "metabase/embedding/embedding-iframe-sdk-setup/components/SdkIframeEmbedSetupModal";
import { PLUGIN_EMBEDDING_IFRAME_SDK_SETUP } from "metabase/plugins";
import { hasPremiumFeature } from "metabase-enterprise/settings";
@@ -10,8 +9,5 @@ export function initializePlugin() {
// In the future, we may drop this to enable users to try out new iframe embedding.
if (hasPremiumFeature("embedding_simple")) {
PLUGIN_EMBEDDING_IFRAME_SDK_SETUP.isFeatureEnabled = () => true;
PLUGIN_EMBEDDING_IFRAME_SDK_SETUP.shouldShowEmbedInNewItemMenu = () => true;
PLUGIN_EMBEDDING_IFRAME_SDK_SETUP.SdkIframeEmbedSetupModal =
SdkIframeEmbedSetupModal;
}
}

View File

@@ -6,6 +6,7 @@ PLUGIN_IS_EE_BUILD.isEEBuild = () => true;
import "./shared";
// CORE APP PLUGINS THAT USE hasPremiumFeature (import initialization functions):
import { initializePlugin as initializeContentTranslation } from "./content_translation";
import { initializePlugin as initializeEmbedding } from "./embedding";
import { initializePlugin as initializeEmbeddingSdk } from "./embedding-sdk";
import { initializePlugin as initializeMetabot } from "./metabot";
@@ -22,6 +23,7 @@ export function initializePlugins() {
initializeMetabot?.();
initializeWhitelabelPlugin?.();
initializeWhitelabelOverridePlugin?.();
initializeContentTranslation?.();
}
// "SDK EE-plugins", that are specific to the embedding sdk.

View File

@@ -0,0 +1,22 @@
import type { PropsWithChildren } from "react";
import { t } from "ttag";
import { SdkError } from "embedding-sdk-bundle/components/private/PublicComponentWrapper/SdkError";
import { useSdkSelector } from "embedding-sdk-bundle/store";
import { getIsGuestEmbed } from "embedding-sdk-bundle/store/selectors";
export const GuestEmbedGuard = ({
children,
supportsGuestEmbed,
}: PropsWithChildren<{
componentName: string;
supportsGuestEmbed: boolean;
}>) => {
const isGuestEmbed = useSdkSelector(getIsGuestEmbed);
return isGuestEmbed && !supportsGuestEmbed ? (
<SdkError message={t`This component does not support guest embeds`} />
) : (
children
);
};

View File

@@ -5,7 +5,7 @@ import { SdkError } from "embedding-sdk-bundle/components/private/PublicComponen
import { SdkLoader } from "embedding-sdk-bundle/components/private/PublicComponentWrapper/SdkLoader";
import { useSdkSelector } from "embedding-sdk-bundle/store";
import {
getLoginStatus,
getInitStatus,
getUsageProblem,
} from "embedding-sdk-bundle/store/selectors";
import type { CommonStylingProps } from "embedding-sdk-bundle/types/props";
@@ -18,21 +18,21 @@ export const PublicComponentWrapper = forwardRef<
HTMLDivElement,
PublicComponentWrapperProps
>(function PublicComponentWrapper({ children, className, style }, ref) {
const loginStatus = useSdkSelector(getLoginStatus);
const initStatus = useSdkSelector(getInitStatus);
const usageProblem = useSdkSelector(getUsageProblem);
let content = children;
if (
loginStatus.status === "uninitialized" ||
loginStatus.status === "loading"
initStatus.status === "uninitialized" ||
initStatus.status === "loading"
) {
content = <SdkLoader />;
}
if (loginStatus.status === "error") {
if (initStatus.status === "error") {
content = (
<SdkError message={loginStatus.error.message} error={loginStatus.error} />
<SdkError message={initStatus.error.message} error={initStatus.error} />
);
}

View File

@@ -12,7 +12,7 @@ import { PublicComponentWrapper } from "./PublicComponentWrapper";
const setup = (status: LoginStatus = { status: "uninitialized" }) => {
const state = createMockState({
sdk: createMockSdkState({
loginStatus: createMockLoginStatusState(status),
initStatus: createMockLoginStatusState(status),
}),
});

View File

@@ -1,21 +1,29 @@
import type React from "react";
import { GuestEmbedGuard } from "./GuestEmbedGuard";
import { PublicComponentWrapper } from "./PublicComponentWrapper";
export function withPublicComponentWrapper<P extends object>(
WrappedComponent: React.ComponentType<P>,
{ supportsGuestEmbed }: { supportsGuestEmbed: boolean },
): (props: P) => React.ReactNode {
const componentName =
WrappedComponent.displayName || WrappedComponent.name || "Component";
const WithPublicComponentWrapper: React.FC<P> = (props) => {
return (
<PublicComponentWrapper>
<WrappedComponent {...props} />
</PublicComponentWrapper>
<GuestEmbedGuard
componentName={componentName}
supportsGuestEmbed={supportsGuestEmbed}
>
<PublicComponentWrapper>
<WrappedComponent {...props} />
</PublicComponentWrapper>
</GuestEmbedGuard>
);
};
WithPublicComponentWrapper.displayName = `withPublicComponentWrapper(${
WrappedComponent.displayName || WrappedComponent.name || "Component"
})`;
WithPublicComponentWrapper.displayName = `withPublicComponentWrapper(${componentName})`;
return WithPublicComponentWrapper;
}

View File

@@ -2,6 +2,7 @@ import cx from "classnames";
import type { CommonStylingProps } from "embedding-sdk-bundle/types/props";
import CS from "metabase/css/core/index.css";
import { useTranslateContent } from "metabase/i18n/hooks";
import {
getAdHocQuestionDescription,
shouldRenderAdhocDescription,
@@ -49,6 +50,7 @@ export const QuestionTitle = ({
style,
}: QuestionTitleProps) => {
const questionTitle = getQuestionTitle({ question });
const tc = useTranslateContent();
if (questionTitle === null) {
return null;
@@ -56,7 +58,7 @@ export const QuestionTitle = ({
return (
<h2 className={cx(CS.h2, CS.textWrap, className)} style={style}>
{questionTitle}
{tc(questionTitle)}
</h2>
);
};

View File

@@ -20,10 +20,11 @@ const DownloadWidgetInner = ({
...rest
}: DownloadWidgetProps &
Pick<UseDownloadDataParams, "question" | "result">) => {
const { withDownloads } = useSdkQuestionContext();
const { withDownloads, token } = useSdkQuestionContext();
const [, handleDownload] = useDownloadData({
question,
result,
token,
});
return (

View File

@@ -2,6 +2,7 @@ import { useMemo } from "react";
import { useSdkQuestionContext } from "embedding-sdk-bundle/components/private/SdkQuestion/context";
import { ResponsiveParametersList } from "metabase/query_builder/components/ResponsiveParametersList";
import { Box } from "metabase/ui";
import * as Lib from "metabase-lib";
import type { ParameterId } from "metabase-types/api";
@@ -17,6 +18,7 @@ export const SqlParametersList = () => {
const {
question,
originalQuestion,
token,
parameterValues,
updateParameterValues,
hiddenParameters,
@@ -49,7 +51,7 @@ export const SqlParametersList = () => {
);
}, [question, originalQuestion, hiddenParameters]);
if (!question || !isNativeQuestion) {
if (!question || !isNativeQuestion || !parameters.length) {
return null;
}
@@ -63,18 +65,21 @@ export const SqlParametersList = () => {
};
return (
<ResponsiveParametersList
classNames={{
container: SqlParametersListS.SqlParametersListContainer,
parametersList: SqlParametersListS.SqlParametersList,
}}
cardId={question.id()}
dashboardId={question.dashboardId() ?? undefined}
parameters={parameters}
setParameterValue={setParameterValue}
enableParameterRequiredBehavior
commitImmediately={false}
isSortable={false}
/>
<Box w="100%">
<ResponsiveParametersList
classNames={{
container: SqlParametersListS.SqlParametersListContainer,
parametersList: SqlParametersListS.SqlParametersList,
}}
cardId={question.id()}
dashboardId={question.dashboardId() ?? undefined}
token={token}
parameters={parameters}
setParameterValue={setParameterValue}
enableParameterRequiredBehavior
commitImmediately={false}
isSortable={false}
/>
</Box>
);
};

View File

@@ -10,6 +10,8 @@ import {
SdkLoader,
} from "embedding-sdk-bundle/components/private/PublicComponentWrapper";
import { shouldRunCardQuery } from "embedding-sdk-bundle/lib/sdk-question";
import { useSdkSelector } from "embedding-sdk-bundle/store";
import { getIsGuestEmbed } from "embedding-sdk-bundle/store/selectors";
import { useLocale } from "metabase/common/hooks/use-locale";
import CS from "metabase/css/core/index.css";
import QueryVisualization from "metabase/query_builder/components/QueryVisualization";
@@ -50,7 +52,9 @@ export const QuestionVisualization = ({
updateQuestion,
originalId,
onVisualizationChange,
token,
} = useSdkQuestionContext();
const isGuestEmbed = useSdkSelector(getIsGuestEmbed);
const display = useMemo(() => question?.display(), [question]);
@@ -62,7 +66,7 @@ export const QuestionVisualization = ({
// When visualizing a question for the first time, there is no query result yet.
const isQueryResultLoading =
question && shouldRunCardQuery(question) && !queryResults;
question && shouldRunCardQuery({ question, isGuestEmbed }) && !queryResults;
if (isLocaleLoading || isQuestionLoading || isQueryResultLoading) {
return <SdkLoader />;
@@ -97,6 +101,7 @@ export const QuestionVisualization = ({
result={result}
noHeader
mode={mode}
token={token}
navigateToNewCardInsideQB={navigateToNewCard}
onNavigateBack={onNavigateBack}
onUpdateQuestion={(question: Question) =>

View File

@@ -1,12 +1,19 @@
import { createContext, useContext, useEffect, useMemo } from "react";
import { t } from "ttag";
import { SdkError } from "embedding-sdk-bundle/components/private/PublicComponentWrapper";
import { useExtractResourceIdFromJwtToken } from "embedding-sdk-bundle/hooks/private/use-extract-resource-id-from-jwt-token";
import { useLoadQuestion } from "embedding-sdk-bundle/hooks/private/use-load-question";
import { useSdkDispatch, useSdkSelector } from "embedding-sdk-bundle/store";
import { getError, getPlugins } from "embedding-sdk-bundle/store/selectors";
import {
getError,
getIsGuestEmbed,
getPlugins,
} from "embedding-sdk-bundle/store/selectors";
import type { MetabasePluginsConfig } from "embedding-sdk-bundle/types/plugins";
import { transformSdkQuestion } from "metabase/embedding-sdk/lib/transform-question";
import type { MetabasePluginsConfig as InternalMetabasePluginsConfig } from "metabase/embedding-sdk/types/plugins";
import { PLUGIN_CONTENT_TRANSLATION } from "metabase/plugins";
import {
type OnCreateOptions,
useCreateQuestion,
@@ -33,7 +40,8 @@ export const SdkQuestionContext = createContext<
const DEFAULT_OPTIONS = {};
export const SdkQuestionProvider = ({
questionId,
questionId: rawQuestionId,
token: rawToken,
options = DEFAULT_OPTIONS,
deserializedCard,
componentPlugins,
@@ -54,6 +62,26 @@ export const SdkQuestionProvider = ({
navigateToNewCard: userNavigateToNewCard,
onVisualizationChange,
}: SdkQuestionProviderProps) => {
const isGuestEmbed = useSdkSelector(getIsGuestEmbed);
const {
resourceId: questionId,
token,
tokenError,
} = useExtractResourceIdFromJwtToken({
isGuestEmbed,
resourceId: rawQuestionId,
token: rawToken ?? undefined,
});
useEffect(() => {
if (isGuestEmbed && token) {
PLUGIN_CONTENT_TRANSLATION.setEndpointsForStaticEmbedding(token);
}
}, [isGuestEmbed, token]);
const isNewQuestion = questionId === "new";
const error = useSdkSelector(getError);
const handleCreateQuestion = useCreateQuestion();
@@ -113,6 +141,8 @@ export const SdkQuestionProvider = ({
navigateToNewCard,
} = useLoadQuestion({
questionId,
isGuestEmbed,
token,
options,
deserializedCard,
initialSqlParameters,
@@ -142,6 +172,7 @@ export const SdkQuestionProvider = ({
const questionContext: SdkQuestionContextType = {
originalId: questionId,
token,
isQuestionLoading,
isQueryRunning,
resetQuestion: loadAndQueryQuestion,
@@ -173,8 +204,12 @@ export const SdkQuestionProvider = ({
};
useEffect(() => {
if (tokenError) {
return;
}
loadAndQueryQuestion();
}, [loadAndQueryQuestion]);
}, [loadAndQueryQuestion, tokenError]);
const dispatch = useSdkDispatch();
@@ -182,6 +217,18 @@ export const SdkQuestionProvider = ({
dispatch(setEntityTypes(entityTypes));
}, [dispatch, entityTypes]);
if (isGuestEmbed && isNewQuestion) {
return (
<SdkError
message={t`You can't explore or save questions in Guest Embed mode`}
/>
);
}
if (tokenError) {
return <SdkError message={tokenError} />;
}
if (error) {
return <SdkError message={error.message} />;
}

View File

@@ -1,6 +1,7 @@
import type { PropsWithChildren } from "react";
import type { LoadQuestionHookResult } from "embedding-sdk-bundle/hooks/private/use-load-question";
import type { SdkEntityToken } from "embedding-sdk-bundle/types";
import type { SdkCollectionId } from "embedding-sdk-bundle/types/collection";
import type { MetabasePluginsConfig } from "embedding-sdk-bundle/types/plugins";
import type {
@@ -105,10 +106,18 @@ export type QuestionMockLocationParameters = {
params: { slug?: string };
};
/**
* @inline
*/
export type SdkQuestionEntityInternalProps = {
questionId?: SdkQuestionId | null;
token?: SdkEntityToken | null;
};
export type SdkQuestionProviderProps = PropsWithChildren<
SdkQuestionConfig &
Omit<LoadSdkQuestionParams, "questionId"> & {
questionId: SdkQuestionId | null;
Omit<LoadSdkQuestionParams, "questionId"> &
SdkQuestionEntityInternalProps & {
/**
* @internal
*/
@@ -139,6 +148,7 @@ export type SdkQuestionContextType = Omit<
plugins: SdkQuestionConfig["componentPlugins"] | null;
mode: QueryClickActionsMode | Mode | null | undefined;
originalId: SdkQuestionId | null;
token: string | null | undefined;
resetQuestion: () => void;
onReset: () => void;
onCreate: (question: Question) => Promise<Question>;

View File

@@ -1,5 +1,6 @@
import { c, t } from "ttag";
import { useTranslateContent } from "metabase/i18n/hooks";
import { Anchor, Stack, Text } from "metabase/ui";
import { getQuestionTitle } from "../QuestionTitle";
@@ -46,6 +47,7 @@ export const DefaultViewTitle = ({
withResetButton = false,
}: SdkQuestionDefaultViewProps) => {
const { question, originalQuestion, onReset } = useSdkQuestionContext();
const tc = useTranslateContent();
const isQuestionChanged = originalQuestion
? question?.isQueryDirtyComparedTo(originalQuestion)
@@ -58,7 +60,7 @@ export const DefaultViewTitle = ({
if (title === undefined || title === true) {
const originalName = originalQuestion?.displayName();
const titleText = getQuestionTitle({ question });
const titleText = tc(getQuestionTitle({ question }));
return (
<DefaultViewTitleText
@@ -78,11 +80,13 @@ export const DefaultViewTitle = ({
}
if (typeof title === "string") {
const titleText = tc(title);
return (
<DefaultViewTitleText
title={
<Text fw={700} c="var(--mb-color-text-primary)" fz="xl">
{title}
{titleText}
</Text>
}
withResetButton={withResetButton}

View File

@@ -21,6 +21,20 @@
"Main";
}
.ContainerTwoRows {
display: grid;
grid-template: auto 1fr / 1fr;
grid-template-areas:
"TopBar"
"Main";
}
.ContainerSingleRow {
display: grid;
grid-template: 1fr / 1fr;
grid-template-areas: "Main";
}
.TopBar {
grid-area: TopBar;
}

View File

@@ -10,8 +10,11 @@ import {
SdkLoader,
} from "embedding-sdk-bundle/components/private/PublicComponentWrapper";
import { QuestionVisualization } from "embedding-sdk-bundle/components/private/SdkQuestion/components/Visualization";
import { SdkQuestion } from "embedding-sdk-bundle/components/public/SdkQuestion";
import { useSdkBreadcrumbs } from "embedding-sdk-bundle/hooks/private/use-sdk-breadcrumb";
import { shouldRunCardQuery } from "embedding-sdk-bundle/lib/sdk-question";
import { useSdkSelector } from "embedding-sdk-bundle/store";
import { getIsGuestEmbed } from "embedding-sdk-bundle/store/selectors";
import type { SdkQuestionTitleProps } from "embedding-sdk-bundle/types/question";
import { SaveQuestionModal } from "metabase/common/components/SaveQuestionModal";
import { useLocale } from "metabase/common/hooks/use-locale";
@@ -88,6 +91,7 @@ export const SdkQuestionDefaultView = ({
} = useSdkQuestionContext();
const { isBreadcrumbEnabled, reportLocation } = useSdkBreadcrumbs();
const isGuestEmbed = useSdkSelector(getIsGuestEmbed);
const isNewQuestion = originalId === "new";
const isQuestionSaved = question?.isSaved();
@@ -123,7 +127,7 @@ export const SdkQuestionDefaultView = ({
// When visualizing a question for the first time, there is no query result yet.
const isQueryResultLoading =
question && shouldRunCardQuery(question) && !queryResults;
question && shouldRunCardQuery({ question, isGuestEmbed }) && !queryResults;
useEffect(() => {
if (
@@ -239,6 +243,12 @@ export const SdkQuestionDefaultView = ({
</Group>
</ResultToolbar>
)}
{isGuestEmbed && (
<Box w="100%">
<SdkQuestion.SqlParametersList />
</Box>
)}
</Stack>
<Box

View File

@@ -205,6 +205,8 @@ const CollectionBrowserWrapper = ({
};
export const CollectionBrowser = Object.assign(
withPublicComponentWrapper(CollectionBrowserWrapper),
withPublicComponentWrapper(CollectionBrowserWrapper, {
supportsGuestEmbed: false,
}),
{ schema: collectionBrowserPropsSchema },
);

View File

@@ -4,10 +4,12 @@ import { type JSX, memo, useEffect, useId, useRef } from "react";
import { SdkThemeProvider } from "embedding-sdk-bundle/components/private/SdkThemeProvider";
import { useInitDataInternal } from "embedding-sdk-bundle/hooks/private/use-init-data";
import { useNormalizeComponentProviderProps } from "embedding-sdk-bundle/hooks/private/use-normalize-component-provider-props";
import { getSdkStore } from "embedding-sdk-bundle/store";
import {
setErrorComponent,
setEventHandlers,
setIsGuestEmbed,
setLoaderComponent,
setPlugins,
} from "embedding-sdk-bundle/store/reducer";
@@ -63,16 +65,26 @@ export const ComponentProviderInternal = ({
allowConsoleLog,
isLocalHost,
}: ComponentProviderInternalProps): JSX.Element => {
const isGuestEmbed = !!authConfig.isGuest;
const { fontFamily } = theme ?? {};
// The main call of useInitData happens in the MetabaseProvider
// This call in the ComponentProvider is still needed for:
// - Storybook stories, where we don't have the MetabaseProvider
// - Unit tests
useInitDataInternal({ reduxStore, authConfig, isLocalHost });
useInitDataInternal({
reduxStore,
isGuestEmbed,
authConfig,
isLocalHost,
});
useInitPlugins();
useEffect(() => {
reduxStore.dispatch(setIsGuestEmbed(!!isGuestEmbed));
}, [reduxStore, isGuestEmbed]);
useEffect(() => {
if (fontFamily) {
reduxStore.dispatch(setOptions({ font: fontFamily }));
@@ -152,11 +164,13 @@ export const ComponentProvider = memo(function ComponentProvider({
reduxStoreRef.current = props.reduxStore ?? getSdkStore();
}
const normalizedProps = useNormalizeComponentProviderProps(props);
return (
<MetabaseReduxProvider store={reduxStoreRef.current!}>
<METABOT_SDK_EE_PLUGIN.MetabotProvider>
<ComponentProviderInternal
{...props}
{...normalizedProps}
reduxStore={reduxStoreRef.current!}
>
{children}

View File

@@ -74,6 +74,8 @@ const CreateDashboardModalInner = ({
};
export const CreateDashboardModal = Object.assign(
withPublicComponentWrapper(CreateDashboardModalInner),
withPublicComponentWrapper(CreateDashboardModalInner, {
supportsGuestEmbed: false,
}),
{ schema: createDashboardModalSchema },
);

View File

@@ -1,3 +1,5 @@
import { withPublicComponentWrapper } from "embedding-sdk-bundle/components/private/PublicComponentWrapper";
import {
InteractiveQuestion,
type InteractiveQuestionProps,
@@ -13,6 +15,10 @@ export type CreateQuestionProps = Omit<
"questionId" | "children"
>;
export const CreateQuestion = (props: CreateQuestionProps = {}) => (
const CreateQuestionInner = (props: CreateQuestionProps = {}) => (
<InteractiveQuestion {...props} questionId="new" />
);
export const CreateQuestion = withPublicComponentWrapper(CreateQuestionInner, {
supportsGuestEmbed: false,
}) as typeof CreateQuestionInner;

View File

@@ -31,6 +31,7 @@ const propsSchema: Yup.SchemaOf<InteractiveQuestionProps> = Yup.object({
.optional()
.noUnknown(),
questionId: Yup.mixed().required(),
token: Yup.mixed().optional(),
style: Yup.mixed().optional(),
targetCollection: Yup.mixed().optional(),
targetDashboardId: Yup.mixed().optional(),

View File

@@ -1,3 +1,4 @@
import { withPublicComponentWrapper } from "embedding-sdk-bundle/components/private/PublicComponentWrapper";
import {
BackButton,
Breakout,
@@ -36,7 +37,7 @@ import { interactiveQuestionSchema } from "./InteractiveQuestion.schema";
*/
export type InteractiveQuestionProps = Omit<
SdkQuestionProps,
"getClickActionMode" | "navigateToNewCard" | "backToDashboard"
"token" | "getClickActionMode" | "navigateToNewCard" | "backToDashboard"
>;
/**
@@ -103,7 +104,9 @@ const subComponents: InteractiveQuestionComponents = {
};
export const InteractiveQuestion = Object.assign(
_InteractiveQuestion,
withPublicComponentWrapper(_InteractiveQuestion, {
supportsGuestEmbed: false,
}),
subComponents,
{ schema: interactiveQuestionSchema },
);

View File

@@ -115,6 +115,7 @@ export type SdkQuestionComponents = {
export const _SdkQuestion = ({
questionId,
token,
options,
deserializedCard,
plugins,
@@ -145,6 +146,7 @@ export const _SdkQuestion = ({
}: SdkQuestionProps): JSX.Element | null => (
<SdkQuestionProvider
questionId={questionId}
token={token}
options={options}
deserializedCard={deserializedCard}
componentPlugins={plugins}
@@ -206,6 +208,8 @@ const subComponents: SdkQuestionComponents = {
};
export const SdkQuestion = Object.assign(
withPublicComponentWrapper(_SdkQuestion),
withPublicComponentWrapper(_SdkQuestion, {
supportsGuestEmbed: true,
}),
subComponents,
);

View File

@@ -10,7 +10,12 @@ const propsSchema: Yup.SchemaOf<StaticQuestionProps> = Yup.object({
height: Yup.mixed().optional(),
initialSqlParameters: Yup.mixed().optional(),
hiddenParameters: Yup.mixed().optional(),
questionId: Yup.mixed().required(),
questionId: Yup.mixed().when("token", {
is: (token: unknown) => token !== undefined,
then: (schema) => schema.optional(),
otherwise: (schema) => schema.required(),
}),
token: Yup.mixed().optional(),
style: Yup.mixed().optional(),
title: Yup.mixed().optional(),
width: Yup.mixed().optional(),

View File

@@ -1,6 +1,7 @@
import type { PropsWithChildren } from "react";
import { FlexibleSizeComponent } from "embedding-sdk-bundle/components/private/FlexibleSizeComponent";
import { withPublicComponentWrapper } from "embedding-sdk-bundle/components/private/PublicComponentWrapper";
import {
Breakout,
BreakoutDropdown,
@@ -26,23 +27,25 @@ import {
SdkQuestion,
type SdkQuestionProps,
} from "embedding-sdk-bundle/components/public/SdkQuestion/SdkQuestion";
import { StaticQuestionSdkMode } from "embedding-sdk-bundle/components/public/StaticQuestion/mode";
import { useNormalizeGuestEmbedQuestionOrDashboardComponentProps } from "embedding-sdk-bundle/hooks/private/use-normalize-guest-embed-question-or-dashboard-component-props";
import { useSdkSelector } from "embedding-sdk-bundle/store";
import { getIsGuestEmbed } from "embedding-sdk-bundle/store/selectors";
import type { SdkQuestionEntityPublicProps } from "embedding-sdk-bundle/types/question";
import { Box, Stack } from "metabase/ui";
import { getEmbeddingMode } from "metabase/visualizations/click-actions/lib/modes";
import { EmbeddingSdkStaticMode } from "metabase/visualizations/click-actions/modes/EmbeddingSdkStaticMode";
import type { ClickActionModeGetter } from "metabase/visualizations/types";
import type Question from "metabase-lib/v1/Question";
import { staticQuestionSchema } from "./StaticQuestion.schema";
/**
* @interface
* @expand
* @category StaticQuestion
*/
export type StaticQuestionProps = PropsWithChildren<
Pick<
SdkQuestionProps,
| "questionId"
| "withChartTypeSelector"
| "height"
| "width"
@@ -53,7 +56,8 @@ export type StaticQuestionProps = PropsWithChildren<
| "withDownloads"
| "title"
>
>;
> &
SdkQuestionEntityPublicProps;
/**
* @interface
@@ -77,19 +81,30 @@ export type StaticQuestionComponents = {
SqlParametersList: typeof SqlParametersList;
};
const StaticQuestionInner = ({
questionId,
withChartTypeSelector,
height,
width,
className,
style,
initialSqlParameters,
hiddenParameters,
withDownloads,
title = false, // Hidden by default for backwards-compatibility.
children,
}: StaticQuestionProps): JSX.Element | null => {
const StaticQuestionInner = (
props: StaticQuestionProps,
): JSX.Element | null => {
// Normalize props for Guest Embed usage (e.g. enforce withDownloads in OSS).
const normalizedProps =
useNormalizeGuestEmbedQuestionOrDashboardComponentProps(props);
const {
questionId,
token,
withChartTypeSelector,
height,
width,
className,
style,
initialSqlParameters,
hiddenParameters,
withDownloads,
title = false, // Hidden by default for backwards-compatibility.
children,
} = normalizedProps;
const isGuestEmbed = useSdkSelector(getIsGuestEmbed);
const getClickActionMode: ClickActionModeGetter = ({
question,
}: {
@@ -99,16 +114,15 @@ const StaticQuestionInner = ({
question &&
getEmbeddingMode({
question,
queryMode: StaticQuestionSdkMode,
queryMode: EmbeddingSdkStaticMode,
})
);
};
const hasTopBar = Boolean(title || withChartTypeSelector || withDownloads);
return (
<SdkQuestion
questionId={questionId}
questionId={questionId ?? null}
token={token}
getClickActionMode={getClickActionMode}
navigateToNewCard={null}
initialSqlParameters={initialSqlParameters}
@@ -128,18 +142,18 @@ const StaticQuestionInner = ({
h="100%"
gap="xs"
>
{hasTopBar && (
<Stack className={InteractiveQuestionS.TopBar} gap="sm" p="md">
{title && <DefaultViewTitle title={title} />}
<Stack className={InteractiveQuestionS.TopBar} gap="sm" p="md">
{title && <DefaultViewTitle title={title} />}
{(withChartTypeSelector || withDownloads) && (
<ResultToolbar>
{withChartTypeSelector && <SdkQuestion.ChartTypeDropdown />}
{withDownloads && <SdkQuestion.DownloadWidgetDropdown />}
</ResultToolbar>
)}
</Stack>
)}
{(withChartTypeSelector || withDownloads) && (
<ResultToolbar>
{withChartTypeSelector && <SdkQuestion.ChartTypeDropdown />}
{withDownloads && <SdkQuestion.DownloadWidgetDropdown />}
</ResultToolbar>
)}
{isGuestEmbed && <SdkQuestion.SqlParametersList />}
</Stack>
<Box className={InteractiveQuestionS.Main} w="100%" h="100%">
<Box className={InteractiveQuestionS.Content}>
@@ -178,7 +192,9 @@ const subComponents: StaticQuestionComponents = {
};
export const StaticQuestion = Object.assign(
StaticQuestionInner,
withPublicComponentWrapper(StaticQuestionInner, {
supportsGuestEmbed: true,
}),
subComponents,
{ schema: staticQuestionSchema },
);

View File

@@ -1,7 +0,0 @@
import type { QueryClickActionsMode } from "metabase/visualizations/types";
export const StaticQuestionSdkMode: QueryClickActionsMode = {
name: "static-question-sdk",
hasDrills: false,
clickActions: [],
};

View File

@@ -8,6 +8,7 @@ const propsSchema: Yup.SchemaOf<EditableDashboardProps> = Yup.object({
children: Yup.mixed().optional(),
className: Yup.mixed().optional(),
dashboardId: Yup.mixed().required(),
token: Yup.mixed().optional(),
dataPickerProps: Yup.object({
entityTypes: Yup.mixed().optional(),
})

View File

@@ -1,3 +1,4 @@
import { withPublicComponentWrapper } from "embedding-sdk-bundle/components/private/PublicComponentWrapper";
import { DASHBOARD_EDITING_ACTIONS } from "metabase/dashboard/components/DashboardHeader/DashboardHeaderButtonRow/constants";
import { DASHBOARD_ACTION } from "metabase/dashboard/components/DashboardHeader/DashboardHeaderButtonRow/dashboard-action-keys";
import type { MetabasePluginsConfig as InternalMetabasePluginsConfig } from "metabase/embedding-sdk/types/plugins";
@@ -51,6 +52,11 @@ export const EditableDashboardInner = (props: EditableDashboardProps) => {
);
};
export const EditableDashboard = Object.assign(EditableDashboardInner, {
schema: editableDashboardSchema,
});
export const EditableDashboard = Object.assign(
withPublicComponentWrapper(EditableDashboardInner, {
supportsGuestEmbed: false,
}),
{
schema: editableDashboardSchema,
},
);

View File

@@ -8,6 +8,7 @@ const propsSchema: Yup.SchemaOf<InteractiveDashboardProps> = Yup.object({
children: Yup.mixed().optional(),
className: Yup.mixed().optional(),
dashboardId: Yup.mixed().required(),
token: Yup.mixed().optional(),
dataPickerProps: Yup.object({
entityTypes: Yup.mixed().optional(),
})

View File

@@ -1,5 +1,6 @@
import { useCallback, useMemo } from "react";
import { withPublicComponentWrapper } from "embedding-sdk-bundle/components/private/PublicComponentWrapper";
import { useSdkSelector } from "embedding-sdk-bundle/store";
import { getPlugins } from "embedding-sdk-bundle/store/selectors";
import type { MetabasePluginsConfig } from "embedding-sdk-bundle/types/plugins";
@@ -58,6 +59,11 @@ const InteractiveDashboardInner = (props: InteractiveDashboardProps) => {
);
};
export const InteractiveDashboard = Object.assign(InteractiveDashboardInner, {
schema: interactiveDashboardSchema,
});
export const InteractiveDashboard = Object.assign(
withPublicComponentWrapper(InteractiveDashboardInner, {
supportsGuestEmbed: false,
}),
{
schema: interactiveDashboardSchema,
},
);

View File

@@ -19,12 +19,14 @@ import {
import { SdkAdHocQuestion } from "embedding-sdk-bundle/components/private/SdkAdHocQuestion";
import { SdkQuestion } from "embedding-sdk-bundle/components/public/SdkQuestion/SdkQuestion";
import { useDashboardLoadHandlers } from "embedding-sdk-bundle/hooks/private/use-dashboard-load-handlers";
import { useExtractResourceIdFromJwtToken } from "embedding-sdk-bundle/hooks/private/use-extract-resource-id-from-jwt-token";
import { useSdkBreadcrumbs } from "embedding-sdk-bundle/hooks/private/use-sdk-breadcrumb";
import {
type SdkDashboardDisplayProps,
useSdkDashboardParams,
} from "embedding-sdk-bundle/hooks/private/use-sdk-dashboard-params";
import { useSdkDispatch, useSdkSelector } from "embedding-sdk-bundle/store";
import { getIsGuestEmbed } from "embedding-sdk-bundle/store/selectors";
import type { MetabaseQuestion } from "embedding-sdk-bundle/types";
import type { DashboardEventHandlersProps } from "embedding-sdk-bundle/types/dashboard";
import type { MetabasePluginsConfig } from "embedding-sdk-bundle/types/plugins";
@@ -44,7 +46,9 @@ import {
useDashboardContext,
} from "metabase/dashboard/context";
import { getDashboardComplete, getIsDirty } from "metabase/dashboard/selectors";
import { isStaticEmbeddingEntityLoadingError } from "metabase/lib/errors/is-static-embedding-entity-loading-error";
import { useSelector } from "metabase/lib/redux";
import { PLUGIN_CONTENT_TRANSLATION } from "metabase/plugins";
import EmbedFrameS from "metabase/public/components/EmbedFrame/EmbedFrame.module.css";
import { resetErrorPage, setErrorPage } from "metabase/redux/app";
import { dismissAllUndo } from "metabase/redux/undo";
@@ -123,7 +127,8 @@ export type SdkDashboardInnerProps = SdkDashboardProps &
>;
const SdkDashboardInner = ({
dashboardId,
dashboardId: rawDashboardId,
token: rawToken,
initialParameters = {},
withTitle = true,
withCardTitle = true,
@@ -149,6 +154,24 @@ const SdkDashboardInner = ({
dataPickerProps,
onVisualizationChange,
}: SdkDashboardInnerProps) => {
const isGuestEmbed = useSdkSelector(getIsGuestEmbed);
const {
resourceId: dashboardId,
token,
tokenError,
} = useExtractResourceIdFromJwtToken({
isGuestEmbed,
resourceId: rawDashboardId,
token: rawToken ?? undefined,
});
useEffect(() => {
if (isGuestEmbed && token) {
PLUGIN_CONTENT_TRANSLATION.setEndpointsForStaticEmbedding(token);
}
}, [isGuestEmbed, token]);
const { handleLoad, handleLoadWithoutCards } = useDashboardLoadHandlers({
onLoad,
onLoadWithoutCards,
@@ -158,12 +181,10 @@ const SdkDashboardInner = ({
const { isBreadcrumbEnabled, reportLocation } = useSdkBreadcrumbs();
const { displayOptions } = useSdkDashboardParams({
dashboardId,
withDownloads,
withTitle,
withCardTitle,
hiddenParameters,
initialParameters,
});
const {
@@ -236,6 +257,22 @@ const SdkDashboardInner = ({
);
}
if (tokenError) {
return (
<SdkDashboardStyledWrapper className={className} style={style}>
<SdkError message={tokenError} />;
</SdkDashboardStyledWrapper>
);
}
if (isStaticEmbeddingEntityLoadingError(errorPage, { isGuestEmbed })) {
return (
<SdkDashboardStyledWrapper className={className} style={style}>
<SdkError message={errorPage.data ?? t`Something's gone wrong`} />
</SdkDashboardStyledWrapper>
);
}
// Passing an invalid entity ID format results in a 400 Bad Request.
// We can show this as a generic "not found" error on the frontend.
const isDashboardNotFound =
@@ -244,7 +281,7 @@ const SdkDashboardInner = ({
if (!dashboardId || isDashboardNotFound) {
return (
<SdkDashboardStyledWrapper className={className} style={style}>
<DashboardNotFoundError id={dashboardId} />
<DashboardNotFoundError id={dashboardId ?? ""} />
</SdkDashboardStyledWrapper>
);
}
@@ -263,6 +300,8 @@ const SdkDashboardInner = ({
<DashboardContextProvider
ref={dashboardContextProviderRef}
dashboardId={dashboardId}
token={token}
isGuestEmbed={isGuestEmbed}
parameterQueryParams={initialParameters}
navigateToNewCardFromDashboard={
navigateToNewCardFromDashboard !== undefined
@@ -313,8 +352,8 @@ const SdkDashboardInner = ({
}}
autoScrollToDashcardId={autoScrollToDashcardId}
>
{match(finalRenderMode)
.with("question", () => (
{match({ finalRenderMode, isGuestEmbed })
.with({ finalRenderMode: "question" }, () => (
<SdkDashboardStyledWrapperWithRef className={className} style={style}>
<SdkAdHocQuestion
// `adhocQuestionUrl` would have value if renderMode is "question"
@@ -327,7 +366,7 @@ const SdkDashboardInner = ({
</SdkAdHocQuestion>
</SdkDashboardStyledWrapperWithRef>
))
.with("dashboard", () => (
.with({ finalRenderMode: "dashboard" }, () => (
<SdkDashboardProvider
plugins={plugins}
onEditQuestion={onEditQuestion}
@@ -342,29 +381,37 @@ const SdkDashboardInner = ({
)}
</SdkDashboardProvider>
))
.with("queryBuilder", () => (
<DashboardQueryBuilder
onCreate={(question) => {
setNewDashboardQuestionId(question.id);
setRenderMode("dashboard");
dashboardContextProviderRef.current?.refetchDashboard();
}}
onNavigateBack={() => {
setRenderMode("dashboard");
}}
dataPickerProps={dataPickerProps}
onVisualizationChange={onVisualizationChange}
/>
))
.with({ finalRenderMode: "queryBuilder" }, ({ isGuestEmbed }) =>
isGuestEmbed ? (
<SdkDashboardStyledWrapper className={className} style={style}>
<SdkError
message={t`You can't save questions in Guest Embed mode`}
/>
</SdkDashboardStyledWrapper>
) : (
<DashboardQueryBuilder
onCreate={(question) => {
setNewDashboardQuestionId(question.id);
setRenderMode("dashboard");
dashboardContextProviderRef.current?.refetchDashboard();
}}
onNavigateBack={() => {
setRenderMode("dashboard");
}}
dataPickerProps={dataPickerProps}
onVisualizationChange={onVisualizationChange}
/>
),
)
.exhaustive()}
{modalContent}
</DashboardContextProvider>
);
};
export const SdkDashboard = withPublicComponentWrapper(
SdkDashboardInner,
) as typeof SdkDashboardInner &
export const SdkDashboard = withPublicComponentWrapper(SdkDashboardInner, {
supportsGuestEmbed: true,
}) as typeof SdkDashboardInner &
Pick<
typeof Dashboard,
| "Grid"

View File

@@ -7,7 +7,12 @@ import type { StaticDashboardProps } from "./StaticDashboard";
const propsSchema: Yup.SchemaOf<StaticDashboardProps> = Yup.object({
children: Yup.mixed().optional(),
className: Yup.mixed().optional(),
dashboardId: Yup.mixed().required(),
dashboardId: Yup.mixed().when("token", {
is: (token: unknown) => token !== undefined,
then: (schema) => schema.optional(),
otherwise: (schema) => schema.required(),
}),
token: Yup.mixed().optional(),
dataPickerProps: Yup.object({
entityTypes: Yup.mixed().optional(),
})

View File

@@ -1,43 +1,54 @@
import { withPublicComponentWrapper } from "embedding-sdk-bundle/components/private/PublicComponentWrapper";
import { useNormalizeGuestEmbedQuestionOrDashboardComponentProps } from "embedding-sdk-bundle/hooks/private/use-normalize-guest-embed-question-or-dashboard-component-props";
import type { SdkDashboardEntityPublicProps } from "embedding-sdk-bundle/types/dashboard";
import { PublicOrEmbeddedDashCardMenu } from "metabase/dashboard/components/DashCard/PublicOrEmbeddedDashCardMenu";
import { DASHBOARD_ACTION } from "metabase/dashboard/components/DashboardHeader/DashboardHeaderButtonRow/dashboard-action-keys";
import { isQuestionCard } from "metabase/dashboard/utils";
import { getEmbeddingMode } from "metabase/visualizations/click-actions/lib/modes";
import { EmbeddingSdkStaticMode } from "metabase/visualizations/click-actions/modes/EmbeddingSdkStaticMode";
import type { ClickActionModeGetter } from "metabase/visualizations/types";
import { StaticQuestionSdkMode } from "../../StaticQuestion/mode";
import { SdkDashboard, type SdkDashboardProps } from "../SdkDashboard";
import { staticDashboardSchema } from "./StaticDashboard.schema";
/**
* @interface
* @expand
* @category Dashboard
*/
export type StaticDashboardProps = Omit<
SdkDashboardProps,
| "dashboardId"
| "token"
| "drillThroughQuestionProps"
| "drillThroughQuestionHeight"
| "renderDrillThroughQuestion"
>;
> &
SdkDashboardEntityPublicProps;
const StaticDashboardInner = (props: StaticDashboardProps) => {
// Normalize props for Guest Embed usage (e.g. enforce withDownloads in OSS).
const normalizedProps =
useNormalizeGuestEmbedQuestionOrDashboardComponentProps(props);
const { withDownloads } = normalizedProps;
const getClickActionMode: ClickActionModeGetter = ({ question }) =>
getEmbeddingMode({
question,
queryMode: StaticQuestionSdkMode,
queryMode: EmbeddingSdkStaticMode,
});
return (
<SdkDashboard
{...props}
{...(normalizedProps as SdkDashboardProps)}
getClickActionMode={getClickActionMode}
dashboardActions={({ downloadsEnabled }) =>
downloadsEnabled.pdf ? [DASHBOARD_ACTION.DOWNLOAD_PDF] : []
}
navigateToNewCardFromDashboard={null}
dashcardMenu={({ dashcard, result }) =>
props.withDownloads &&
withDownloads &&
isQuestionCard(dashcard.card) &&
!!result?.data &&
!result?.error && (
@@ -48,6 +59,11 @@ const StaticDashboardInner = (props: StaticDashboardProps) => {
);
};
export const StaticDashboard = Object.assign(StaticDashboardInner, {
schema: staticDashboardSchema,
});
export const StaticDashboard = Object.assign(
withPublicComponentWrapper(StaticDashboardInner, {
supportsGuestEmbed: true,
}),
{
schema: staticDashboardSchema,
},
);

View File

@@ -1,6 +1,8 @@
import userEvent from "@testing-library/user-event";
import type { ComponentType } from "react";
import { screen, waitFor, within } from "__support__/ui";
import type { SdkDashboardProps } from "embedding-sdk-bundle/components/public/dashboard/SdkDashboard";
import {
type SetupSdkDashboardOptions,
@@ -18,7 +20,7 @@ const setup = async (
) => {
return setupSdkDashboard({
...options,
component: StaticDashboard,
component: StaticDashboard as ComponentType<SdkDashboardProps>,
});
};
console.warn = () => {};

View File

@@ -1,3 +1,4 @@
import type { ComponentType } from "react";
import { indexBy } from "underscore";
import { setupEnterpriseOnlyPlugin } from "__support__/enterprise";
@@ -110,10 +111,10 @@ export const DEFAULT_DASHCARDS: DashboardCard[] = [
];
export interface SetupSdkDashboardOptions {
props?: Partial<SdkDashboardProps>;
props?: Omit<Partial<SdkDashboardProps>, "token">;
providerProps?: Partial<MetabaseProviderProps>;
isLocaleLoading?: boolean;
component: React.ComponentType<SdkDashboardProps>;
component: ComponentType<SdkDashboardProps>;
dashboardName?: string;
dataPickerProps?: EditableDashboardProps["dataPickerProps"];
dashcards?: DashboardCard[];

Some files were not shown because too many files have changed in this diff Show More