407 lines
11 KiB
JavaScript
407 lines
11 KiB
JavaScript
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";
|
|
|
|
/**
|
|
* @typedef {object} QuestionResource
|
|
* @property {number} question - ID of a question we are embedding
|
|
*
|
|
* @typedef {object} DashboardResource
|
|
* @property {number} dashboard - ID of a dashboard we are embedding
|
|
*
|
|
* @typedef {object} EmbedPayload
|
|
* @property {(QuestionResource|DashboardResource)} resource
|
|
* {@link QuestionResource} or {@link DashboardResource}
|
|
* @property {object} params
|
|
*
|
|
* @typedef {import("metabase/public/lib/types").EmbeddingAdditionalHashOptions} EmbeddingAdditionalHashOptions
|
|
*
|
|
* @typedef {EmbeddingAdditionalHashOptions['hide_parameters']} HiddenFilters
|
|
*
|
|
* @typedef {object} PageStyle
|
|
* @property {boolean} [bordered]
|
|
* @property {boolean} [titled]
|
|
* @property {boolean} [downloads] - EE/PRO only feature to disable downloads
|
|
*/
|
|
|
|
/**
|
|
* Programmatically generate token and visit the embedded page for a question or a dashboard
|
|
*
|
|
* @param {EmbedPayload} payload - The {@link EmbedPayload} we pass to this function
|
|
* @param {object} [options]
|
|
* @param {object} [options.setFilters]
|
|
* @param {PageStyle} [options.pageStyle]
|
|
* @param {object} [options.additionalHashOptions]
|
|
* @param {string} [options.additionalHashOptions.locale]
|
|
* @param {string[]} [options.additionalHashOptions.hideFilters]
|
|
* @param {(window: Window) => void} [options.onBeforeLoad]
|
|
* @param {object} [options.qs]
|
|
*
|
|
* @example
|
|
* visitEmbeddedPage(payload, {
|
|
* setFilters: {id: 92, source: "Organic"},
|
|
* pageStyle: {titled: true},
|
|
* hideFilters: ["id", "source"]
|
|
* });
|
|
*/
|
|
export function visitEmbeddedPage(payload, options) {
|
|
getEmbeddedPageUrl(payload, options).then((urlOptions) => {
|
|
// Always visit embedded page logged out
|
|
cy.signOut();
|
|
|
|
cy.visit(urlOptions);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Programmatically generate token for the embedded page for a question or a dashboard
|
|
*
|
|
* @param {EmbedPayload} payload - The {@link EmbedPayload} we pass to this function
|
|
* @param {object} options
|
|
* @param {object} [options.setFilters]
|
|
* @param {PageStyle} options.pageStyle
|
|
* @param {object} options.additionalHashOptions
|
|
* @param {string} [options.additionalHashOptions.locale]
|
|
* @param {string[]} [options.additionalHashOptions.hideFilters]
|
|
* @param {object} [options.qs]
|
|
*
|
|
* @example
|
|
* getEmbeddedPageUrl(payload, {
|
|
* setFilters: {id: 92, source: "Organic"},
|
|
* pageStyle: {titled: true},
|
|
* hideFilters: ["id", "source"]
|
|
* });
|
|
*/
|
|
export function getEmbeddedPageUrl(
|
|
payload,
|
|
{
|
|
setFilters = {},
|
|
additionalHashOptions: { hideFilters = [], locale, font } = {},
|
|
pageStyle = {},
|
|
onBeforeLoad,
|
|
qs,
|
|
} = {},
|
|
) {
|
|
const jwtSignLocation = "e2e/support/external/e2e-jwt-sign.js";
|
|
|
|
const payloadWithExpiration = {
|
|
...payload,
|
|
exp: Math.round(Date.now() / 1000) + 10 * 60, // 10 minute expiration
|
|
};
|
|
|
|
const stringifiedPayload = JSON.stringify(payloadWithExpiration);
|
|
const signTransaction = `node ${jwtSignLocation} '${stringifiedPayload}' ${METABASE_SECRET_KEY}`;
|
|
|
|
return cy.exec(signTransaction).then(({ stdout: tokenizedQuery }) => {
|
|
const embeddableObject = getEmbeddableObject(payload);
|
|
const hiddenFilters = getHiddenFilters(hideFilters);
|
|
const urlRoot = `/embed/${embeddableObject}/${tokenizedQuery}`;
|
|
const urlHash = getHash(
|
|
{
|
|
...pageStyle,
|
|
...(locale && { locale }),
|
|
...(font && { font }),
|
|
},
|
|
hiddenFilters,
|
|
);
|
|
|
|
return {
|
|
url: urlRoot,
|
|
qs: { ...setFilters, ...qs },
|
|
onBeforeLoad: (window) => {
|
|
onBeforeLoad?.(window);
|
|
if (urlHash) {
|
|
window.location.hash = urlHash;
|
|
}
|
|
},
|
|
};
|
|
});
|
|
|
|
/**
|
|
* Construct a hidden filters object from the list of filters we want to hide
|
|
*
|
|
* @param {string[]} filters
|
|
* @returns {HiddenFilters}
|
|
*/
|
|
function getHiddenFilters(filters) {
|
|
const params = filters.join(",");
|
|
return filters.length > 0 ? { hide_parameters: params } : {};
|
|
}
|
|
|
|
/**
|
|
* Get the URL hash from the page style and/or hidden filters parameters
|
|
*
|
|
* @param {PageStyle & EmbeddingAdditionalHashOptions['locale']} hashOptions
|
|
* @param {HiddenFilters} hiddenFilters
|
|
*
|
|
* @returns string
|
|
*/
|
|
function getHash(hashOptions, hiddenFilters) {
|
|
return new URLSearchParams({ ...hashOptions, ...hiddenFilters }).toString();
|
|
}
|
|
|
|
/**
|
|
* Extract the embeddable object type from the payload
|
|
*
|
|
* @param {EmbedPayload} payload - See {@link EmbedPayload}
|
|
* @returns ("question"|"dashboard")
|
|
*/
|
|
function getEmbeddableObject(payload) {
|
|
return Object.keys(payload.resource)[0];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Grab an iframe `src` via UI and open it,
|
|
* but make sure user is signed out.
|
|
*/
|
|
export function visitIframe() {
|
|
getIframeUrl().then((iframeUrl) => {
|
|
cy.signOut();
|
|
cy.visit(iframeUrl);
|
|
});
|
|
}
|
|
|
|
function getIframeUrl() {
|
|
modal().findByText("Preview").click();
|
|
|
|
return cy.document().then((doc) => {
|
|
const iframe = doc.querySelector("iframe");
|
|
|
|
return iframe.src;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get page iframe body wrapped in `cy` helper
|
|
* @param {string} [selector]
|
|
*/
|
|
export function getIframeBody(selector = "iframe") {
|
|
cy.frameLoaded(selector);
|
|
cy.wait(1); // unclear why, but the tests are flaky without this
|
|
return cy.iframe(selector);
|
|
}
|
|
|
|
export function getEmbedModalSharingPane() {
|
|
return cy.findByTestId("sharing-pane-container");
|
|
}
|
|
|
|
/**
|
|
* 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.unpublishBeforeOpen] - either unpublish entity before legacy modal is opened or not
|
|
*/
|
|
export function openLegacyStaticEmbeddingModal({
|
|
resource,
|
|
resourceId,
|
|
activeTab,
|
|
previewMode,
|
|
unpublishBeforeOpen = true,
|
|
} = {}) {
|
|
const apiPath = resource === "question" ? "card" : "dashboard";
|
|
|
|
cy.request("PUT", `/api/${apiPath}/${resourceId}`, {
|
|
enable_embedding: true,
|
|
embedding_type: "static-legacy",
|
|
});
|
|
|
|
openSharingMenu("Embed");
|
|
|
|
embedModalContent().should("exist");
|
|
|
|
cy.get("body").then(($body) => {
|
|
const isEmbeddingDisabled =
|
|
$body.find('[data-testid="enable-embedding-card"]').length > 0;
|
|
|
|
if (isEmbeddingDisabled) {
|
|
embedModalEnableEmbedding();
|
|
}
|
|
|
|
embedModalContent().within(() => {
|
|
legacyStaticEmbeddingButton().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();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
export function closeStaticEmbeddingModal() {
|
|
modal().icon("close").click();
|
|
}
|
|
|
|
/**
|
|
* Publish a static dashboard or question
|
|
* @param {"card" | "dashboard"} apiPath
|
|
* @param [callback]
|
|
*/
|
|
export function publishChanges(apiPath, callback) {
|
|
cy.intercept("PUT", `/api/${apiPath}/*`).as("publishChanges");
|
|
|
|
cy.button(/^(Publish|Publish changes)$/).click();
|
|
|
|
// TODO this could be simplified when we send one publish request instead of two
|
|
cy.wait(["@publishChanges", "@publishChanges"]).then((xhrs) => {
|
|
// Unfortunately, the order of requests is not always the same.
|
|
// Therefore, we must first get the one that has the `embedding_params` and then assert on it.
|
|
const targetXhr = xhrs.find(({ request }) =>
|
|
Object.keys(request.body).includes("embedding_params"),
|
|
);
|
|
callback?.(targetXhr);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Unpublish a static dashboard or question
|
|
* @param {"card" | "dashboard"} apiPath
|
|
* @param callback
|
|
*/
|
|
export function unpublishChanges(apiPath, callback) {
|
|
cy.intercept("PUT", `/api/${apiPath}/*`, (req) => {
|
|
const body = req.body;
|
|
|
|
if (body?.enable_embedding === false) {
|
|
req.alias = "unpublishChanges";
|
|
}
|
|
});
|
|
|
|
cy.button("Unpublish").click();
|
|
|
|
cy.wait("@unpublishChanges").then((xhr) => {
|
|
callback?.(xhr);
|
|
});
|
|
}
|
|
|
|
export function getParametersContainer() {
|
|
return cy.findByLabelText("Configuring parameters");
|
|
}
|
|
|
|
export function setEmbeddingParameter(name, value) {
|
|
getParametersContainer().findByLabelText(name).click();
|
|
popover().contains(value).click();
|
|
}
|
|
|
|
export function assertEmbeddingParameter(name, value) {
|
|
getParametersContainer().findByLabelText(name).should("have.text", value);
|
|
}
|
|
|
|
// @param {("card"|"dashboard")} resourceType - The type of resource we are sharing
|
|
export function openNewPublicLinkDropdown(resourceType) {
|
|
cy.intercept("POST", `/api/${resourceType}/*/public_link`).as(
|
|
"sharingEnabled",
|
|
);
|
|
|
|
openSharingMenu(/public link/i);
|
|
|
|
cy.wait("@sharingEnabled").then(
|
|
({
|
|
response: {
|
|
body: { uuid },
|
|
},
|
|
}) => {
|
|
cy.wrap(uuid).as("uuid");
|
|
},
|
|
);
|
|
}
|
|
|
|
export function createPublicQuestionLink(questionId) {
|
|
return cy.request("POST", `/api/card/${questionId}/public_link`, {});
|
|
}
|
|
|
|
export function createPublicDashboardLink(dashboardId) {
|
|
return cy.request("POST", `/api/dashboard/${dashboardId}/public_link`, {});
|
|
}
|
|
|
|
export function createPublicDocumentLink(documentId) {
|
|
return cy.request("POST", `/api/ee/document/${documentId}/public-link`, {});
|
|
}
|
|
|
|
/**
|
|
* @param {Object} options
|
|
* @param {string} options.url
|
|
* @param {import("metabase-types/store").InteractiveEmbeddingOptions} options.qs
|
|
* @param {Function} [options.onBeforeLoad]
|
|
*/
|
|
export const visitFullAppEmbeddingUrl = ({ url, qs, onBeforeLoad }) => {
|
|
cy.visit({
|
|
url,
|
|
qs,
|
|
onBeforeLoad(window) {
|
|
// cypress runs all tests in an iframe and the app uses this property to avoid embedding mode for all tests
|
|
// by removing the property the app would work in embedding mode
|
|
window.Cypress = undefined;
|
|
onBeforeLoad?.(window);
|
|
},
|
|
});
|
|
};
|
|
|
|
/**
|
|
* @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);
|
|
};
|