Skip to content
Snippets Groups Projects
Unverified Commit d43863a2 authored by Oisin Coveney's avatar Oisin Coveney Committed by GitHub
Browse files

Add snowplow events for embedding setup flow (#37617)

* Add snowplow events for embedding setup flow

* Fix dashboard model tests

* Fix dashboard tests

* Fix card tests

* Fix dashboard API tests

* Fix type error

* Implement analytics 2/7

1. public_link_copied
2. public_link_removed

* Fix snowplow schema enum

Removing null as we don't seem to put `null` in `enum`

* Complete the embed_flow snowplow schema

* Modify `initial_published_at` to not be overridden

* Implement analytics 4/7

1. public_link_copied
2. public_link_removed
3. public_embed_code_copied
4. static_embed_published
5. static_embed_unpublished

* Prevent accidental ESLint log in Cypress

* Differentiate `EmbeddingParametersSettings` from `EmbeddingParametersValues`

We were mixing the type before, as the former one are the type of the
setting values, not the actual values for the parameters.

* Fix dashboard and card types

* Fix `params` count for `static_embed_published` event

* fixup! Prevent accidental ESLint log in Cypress

* Fix ESlint from the result of upgrading ESLint package

* Fix Snowplow schema enum to include null

* Fully implement `static_embed_code_copied`

* Finish embed_flow snowplow :tada:



* Fix unit tests because markup change

* Fix E2E tests

* Fix E2E CI failure

Apparently using `.realClick()` on copy button could fail locally,
but pass on CI and vice-versa. I couldn't replicate this all the time.

I'm not sure why it would fail locally.

* Fix copy to clipboard not working on CI

* Improve test readability

* Remove extra test-id

* Fix refactor `*CodeOptionId`

* Restrict Snowplow schema

* Revert unnecessary change

* Apply Cal's suggestions for BE improvements

Co-authored-by: default avatarCal Herries <39073188+calherries@users.noreply.github.com>

* Update src/metabase/util/embed.clj

Co-authored-by: default avatarCal Herries <39073188+calherries@users.noreply.github.com>

* Fix backend linter error, hopefully.

---------

Co-authored-by: default avatarMahatthana Nomsawadi <mahatthana.n@gmail.com>
Co-authored-by: default avatarMahatthana (Kelvin) Nomsawadi <me@bboykelvin.dev>
Co-authored-by: default avatarCal Herries <39073188+calherries@users.noreply.github.com>
Co-authored-by: default avatarNicolò Pretto <info@npretto.com>
parent 48119c60
No related branches found
No related tags found
No related merge requests found
Showing
with 719 additions and 34 deletions
......@@ -29,11 +29,58 @@ export const expectGoodSnowplowEvent = (eventData, count = 1) => {
"micro/good",
({ body }) =>
body.filter(snowplowEvent =>
_.isMatch(snowplowEvent?.event?.unstruct_event?.data?.data, eventData),
isDeepMatch(
snowplowEvent?.event?.unstruct_event?.data?.data,
eventData,
),
).length === count,
).should("be.ok");
};
export function isDeepMatch(objectOrValue, partialObjectOrValue) {
if (isMatcher(partialObjectOrValue)) {
return partialObjectOrValue(objectOrValue);
}
const bothAreNotObjects =
// Check null because typeof null === "object"
objectOrValue == null ||
partialObjectOrValue == null ||
typeof objectOrValue !== "object" ||
typeof partialObjectOrValue !== "object";
// Exit condition when calling recursively
if (bothAreNotObjects) {
return objectOrValue === partialObjectOrValue;
}
for (const [key, value] of Object.entries(partialObjectOrValue)) {
if (Array.isArray(value)) {
if (!isArrayDeepMatch(objectOrValue[key], value)) {
return false;
}
} else if (!isDeepMatch(objectOrValue[key], value)) {
return false;
}
}
return true;
}
function isMatcher(value) {
return typeof value === "function";
}
function isArrayDeepMatch(array, partialArray) {
for (const index in partialArray) {
if (!isDeepMatch(array[index], partialArray[index])) {
return false;
}
}
return true;
}
export const expectGoodSnowplowEvents = count => {
retrySnowplowRequest("micro/good", ({ body }) => body.length >= count)
.its("body")
......
......@@ -7,6 +7,11 @@ export function popover() {
return cy.get(POPOVER_ELEMENT);
}
export function mantinePopover() {
const MANTINE_POPOVER = "[data-popover=mantine-popover]";
return cy.get(MANTINE_POPOVER).should("be.visible");
}
const HOVERCARD_ELEMENT = ".emotion-HoverCard-dropdown[role='dialog']";
export function hovercard() {
......
......@@ -109,7 +109,7 @@ describeEE("scenarios > embedding > questions > downloads", () => {
});
cy.log("Disable downloads");
cy.findByLabelText("Enable users to download data from this embed")
cy.findByLabelText("Download data")
.as("allow-download-toggle")
.should("be.checked");
......
......@@ -155,7 +155,9 @@ describe("scenarios > embedding > smoke tests", { tags: "@OSS" }, () => {
cy.findByRole("tab", { name: "Appearance" }).click();
cy.findByText("Background");
cy.findByText("Dashboard title");
cy.findByText(
object === "dashboard" ? "Dashboard title" : "Question title",
);
cy.findByText("Border");
cy.findByText(
(_, element) =>
......
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
import {
createPublicDashboardLink,
createPublicQuestionLink,
describeEE,
describeWithSnowplow,
enableTracking,
expectGoodSnowplowEvent,
expectNoBadSnowplowEvents,
getEmbedModalSharingPane,
mantinePopover,
modal,
openEmbedModalFromMenu,
openPublicLinkPopoverFromMenu,
openStaticEmbeddingModal,
popover,
resetSnowplow,
restore,
setTokenFeatures,
visitDashboard,
visitQuestion,
} from "e2e/support/helpers";
const { PRODUCTS_ID } = SAMPLE_DATABASE;
["dashboard", "card"].forEach(resource => {
["dashboard", "question"].forEach(resource => {
describe(`embed modal behavior for ${resource}s`, () => {
beforeEach(() => {
restore();
......@@ -250,6 +256,469 @@ describe("embed modal display", () => {
});
});
["dashboard", "question"].forEach(resource => {
describeWithSnowplow(`public ${resource} sharing snowplow events`, () => {
beforeEach(() => {
restore();
resetSnowplow();
cy.signInAsAdmin();
enableTracking();
createResource(resource).then(({ body }) => {
cy.wrap(body).as("resource");
cy.wrap(body.id).as("resourceId");
});
});
afterEach(() => {
expectNoBadSnowplowEvents();
});
describe(`when embedding ${resource}`, () => {
describe("when interacting with public link popover", () => {
it("should send `public_link_copied` event when copying public link", () => {
cy.get("@resourceId").then(id => {
visitResource(resource, id);
});
openPublicLinkPopoverFromMenu();
cy.findByTestId("copy-button").realClick();
if (resource === "dashboard") {
expectGoodSnowplowEvent({
event: "public_link_copied",
artifact: "dashboard",
format: null,
});
}
if (resource === "question") {
expectGoodSnowplowEvent({
event: "public_link_copied",
artifact: "question",
format: "html",
});
mantinePopover().findByText("csv").click();
cy.findByTestId("copy-button").realClick();
expectGoodSnowplowEvent({
event: "public_link_copied",
artifact: "question",
format: "csv",
});
mantinePopover().findByText("xlsx").click();
cy.findByTestId("copy-button").realClick();
expectGoodSnowplowEvent({
event: "public_link_copied",
artifact: "question",
format: "xlsx",
});
mantinePopover().findByText("json").click();
cy.findByTestId("copy-button").realClick();
expectGoodSnowplowEvent({
event: "public_link_copied",
artifact: "question",
format: "json",
});
}
});
it("should send `public_link_removed` when removing the public link", () => {
cy.get("@resourceId").then(id => {
visitResource(resource, id);
});
openPublicLinkPopoverFromMenu();
mantinePopover().button("Remove public link").click();
expectGoodSnowplowEvent({
event: "public_link_removed",
artifact: resource,
source: "public-share",
});
});
});
describe("when interacting with public embedding", () => {
it("should send `public_embed_code_copied` event when copying the public embed iframe", () => {
cy.get("@resourceId").then(id => {
visitResource(resource, id);
});
openEmbedModalFromMenu();
cy.findByTestId("sharing-pane-public-embed-button").within(() => {
cy.findByText("Get an embed link").click();
cy.findByTestId("copy-button").realClick();
});
expectGoodSnowplowEvent({
event: "public_embed_code_copied",
artifact: resource,
source: "public-embed",
});
});
it("should send `public_link_removed` event when removing the public embed", () => {
cy.get("@resourceId").then(id => {
visitResource(resource, id);
});
openEmbedModalFromMenu();
cy.findByTestId("sharing-pane-public-embed-button").within(() => {
cy.findByText("Get an embed link").click();
cy.button("Remove public URL").click();
});
expectGoodSnowplowEvent({
event: "public_link_removed",
artifact: resource,
source: "public-embed",
});
});
});
describe("when interacting with static embedding", () => {
it("should send `static_embed_code_copied` when copying the static embed code", () => {
cy.get("@resourceId").then(id => {
visitResource(resource, id);
});
openStaticEmbeddingModal();
cy.log("Assert copying codes in Overview tab");
cy.findByTestId("embed-backend")
.findByTestId("copy-button")
.realClick();
expectGoodSnowplowEvent({
event: "static_embed_code_copied",
artifact: resource,
language: "node",
location: "code_overview",
code: "backend",
appearance: {
bordered: true,
titled: true,
font: "instance",
theme: "light",
hide_download_button: null,
},
});
cy.findByTestId("embed-frontend")
.findByTestId("copy-button")
.realClick();
expectGoodSnowplowEvent({
event: "static_embed_code_copied",
artifact: resource,
language: "pug",
location: "code_overview",
code: "view",
appearance: {
bordered: true,
titled: true,
font: "instance",
theme: "light",
hide_download_button: null,
},
});
cy.log("Assert copying code in Parameters tab");
modal().within(() => {
cy.findByRole("tab", { name: "Parameters" }).click();
cy.findByText("Node.js").click();
});
popover().findByText("Ruby").click();
cy.findByTestId("embed-backend")
.findByTestId("copy-button")
.realClick();
expectGoodSnowplowEvent({
event: "static_embed_code_copied",
artifact: resource,
language: "ruby",
location: "code_params",
code: "backend",
appearance: {
bordered: true,
titled: true,
font: "instance",
theme: "light",
hide_download_button: null,
},
});
cy.log("Assert copying code in Appearance tab");
modal().within(() => {
cy.findByRole("tab", { name: "Appearance" }).click();
cy.findByText("Ruby").click();
});
popover().findByText("Python").click();
modal().within(() => {
cy.findByLabelText("Dark").click({ force: true });
if (resource === "dashboard") {
cy.findByLabelText("Dashboard title").click({ force: true });
}
if (resource === "question") {
cy.findByLabelText("Question title").click({ force: true });
}
cy.findByLabelText("Border").click({ force: true });
});
cy.findByTestId("embed-backend")
.findByTestId("copy-button")
.realClick();
expectGoodSnowplowEvent({
event: "static_embed_code_copied",
artifact: resource,
language: "python",
location: "code_appearance",
code: "backend",
appearance: {
bordered: false,
titled: false,
font: "instance",
theme: "night",
hide_download_button: null,
},
});
});
describeEE("Pro/EE instances", () => {
beforeEach(() => {
setTokenFeatures("all");
});
it("should send `static_embed_code_copied` when copying the static embed code", () => {
cy.get("@resourceId").then(id => {
visitResource(resource, id);
});
openStaticEmbeddingModal({ acceptTerms: false });
cy.log("Assert copying codes in Overview tab");
cy.findByTestId("embed-backend")
.findByTestId("copy-button")
.realClick();
expectGoodSnowplowEvent({
event: "static_embed_code_copied",
artifact: resource,
language: "node",
location: "code_overview",
code: "backend",
appearance: {
bordered: true,
titled: true,
font: "instance",
theme: "light",
hide_download_button: resource === "question" ? false : null,
},
});
cy.findByTestId("embed-frontend")
.findByTestId("copy-button")
.realClick();
expectGoodSnowplowEvent({
event: "static_embed_code_copied",
artifact: resource,
language: "pug",
location: "code_overview",
code: "view",
appearance: {
bordered: true,
titled: true,
font: "instance",
theme: "light",
hide_download_button: resource === "question" ? false : null,
},
});
cy.log("Assert copying code in Parameters tab");
modal().within(() => {
cy.findByRole("tab", { name: "Parameters" }).click();
cy.findByText("Node.js").click();
});
popover().findByText("Ruby").click();
cy.findByTestId("embed-backend")
.findByTestId("copy-button")
.realClick();
expectGoodSnowplowEvent({
event: "static_embed_code_copied",
artifact: resource,
language: "ruby",
location: "code_params",
code: "backend",
appearance: {
bordered: true,
titled: true,
font: "instance",
theme: "light",
hide_download_button: resource === "question" ? false : null,
},
});
cy.log("Assert copying code in Appearance tab");
modal().within(() => {
cy.findByRole("tab", { name: "Appearance" }).click();
cy.findByText("Ruby").click();
});
popover().findByText("Python").click();
modal().within(() => {
cy.findByLabelText("Dark").click({ force: true });
if (resource === "dashboard") {
cy.findByLabelText("Dashboard title").click({ force: true });
}
if (resource === "question") {
cy.findByLabelText("Question title").click({ force: true });
}
cy.findByLabelText("Border").click({ force: true });
cy.findByLabelText("Font").click();
});
popover().findByText("Oswald").click();
if (resource === "question") {
modal().findByLabelText("Download data").click({ force: true });
}
cy.findByTestId("embed-backend")
.findByTestId("copy-button")
.realClick();
expectGoodSnowplowEvent({
event: "static_embed_code_copied",
artifact: resource,
language: "python",
location: "code_appearance",
code: "backend",
appearance: {
bordered: false,
titled: false,
font: "custom",
theme: "night",
hide_download_button: resource === "question" ? true : null,
},
});
});
});
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);
});
cy.log("changing parameters, so we could discard changes");
openStaticEmbeddingModal({ activeTab: "parameters" });
modal().button("Price").click();
popover().findByText("Editable").click();
cy.findByTestId("embed-modal-content-status-bar").within(() => {
cy.findByText("Discard changes").click();
});
expectGoodSnowplowEvent({
event: "static_embed_discarded",
artifact: resource,
});
});
it("should send `static_embed_published` when publishing changes in the static embed modal", () => {
cy.then(function () {
this.timeAfterResourceCreation = Date.now();
});
cy.get("@resourceId").then(id => {
visitResource(resource, id);
});
openStaticEmbeddingModal();
cy.findByTestId("embed-modal-content-status-bar")
.button("Publish")
.click();
cy.then(function () {
expectGoodSnowplowEvent({
event: "static_embed_published",
artifact: resource,
new_embed: true,
time_since_creation: closeTo(
toSecond(Date.now() - this.timeAfterResourceCreation),
1,
),
time_since_initial_publication: null,
params: {
disabled: 3,
locked: 0,
enabled: 0,
},
});
});
cy.log("Assert `time_since_initial_publication` and `params`");
cy.findByTestId("embed-modal-content-status-bar")
.button("Unpublish")
.click();
modal().findByRole("tab", { name: "Parameters" }).click();
modal().button("Price").click();
popover().findByText("Editable").click();
modal().button("Category").click();
popover().findByText("Locked").click();
cy.then(function () {
const HOUR = 60 * 60 * 1000;
const timeAfterPublication = Date.now() + HOUR;
cy.log("Mocks the clock to 1 hour later");
cy.clock(new Date(timeAfterPublication));
cy.findByTestId("embed-modal-content-status-bar")
.button("Publish")
.click();
expectGoodSnowplowEvent({
event: "static_embed_published",
artifact: resource,
new_embed: false,
time_since_creation: closeTo(toSecond(HOUR), 10),
time_since_initial_publication: closeTo(toSecond(HOUR), 10),
params: {
disabled: 1,
locked: 1,
enabled: 1,
},
});
});
});
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);
});
openStaticEmbeddingModal();
const HOUR = 60 * 60 * 1000;
cy.clock(new Date(Date.now() + HOUR));
cy.findByTestId("embed-modal-content-status-bar").within(() => {
cy.findByText("Unpublish").click();
});
expectGoodSnowplowEvent({
event: "static_embed_unpublished",
artifact: resource,
time_since_creation: closeTo(toSecond(HOUR), 10),
time_since_initial_publication: closeTo(toSecond(HOUR), 10),
});
});
});
});
});
});
function toSecond(milliseconds) {
return Math.round(milliseconds / 1000);
}
function expectDisabledButtonWithTooltipLabel(tooltipLabel) {
cy.findByTestId("dashboard-embed-button").should("be.disabled");
cy.findByTestId("dashboard-embed-button").realHover();
......@@ -257,21 +726,73 @@ function expectDisabledButtonWithTooltipLabel(tooltipLabel) {
}
function createResource(resource) {
if (resource === "card") {
return cy.createQuestion({
if (resource === "question") {
return cy.createNativeQuestion({
name: "Question",
query: { "source-table": PRODUCTS_ID },
limit: 1,
native: {
query: `
SELECT *
FROM PRODUCTS
WHERE true
[[AND created_at > {{created_at}}]]
[[AND price > {{price}}]]
[[AND category = {{category}}]]`,
"template-tags": {
date: {
type: "date",
name: "created_at",
id: "b2517f32-d2e2-4f42-ab79-c91e07e820a0",
"display-name": "Created At",
},
price: {
type: "number",
name: "price",
id: "879d1597-e673-414c-a96f-ff5887359834",
"display-name": "Price",
},
category: {
type: "text",
name: "category",
id: "1f741a9a-a95e-4ac6-b584-5101e7cf77e1",
"display-name": "Category",
},
},
},
limit: 10,
});
}
if (resource === "dashboard") {
return cy.createDashboard({ name: "Dashboard" });
const dateFilter = {
id: "1",
name: "Created At",
slug: "created_at",
type: "date/month-year",
};
const numberFilter = {
id: "2",
name: "Price",
slug: "price",
type: "number/=",
};
const textFilter = {
id: "3",
name: "Category",
slug: "category",
type: "string/contains",
};
return cy.createDashboard({
name: "Dashboard",
parameters: [dateFilter, numberFilter, textFilter],
});
}
}
function createPublicResourceLink(resource, id) {
if (resource === "card") {
if (resource === "question") {
return createPublicQuestionLink(id);
}
if (resource === "dashboard") {
......@@ -280,7 +801,7 @@ function createPublicResourceLink(resource, id) {
}
function visitResource(resource, id) {
if (resource === "card") {
if (resource === "question") {
visitQuestion(id);
}
......@@ -288,3 +809,16 @@ function visitResource(resource, id) {
visitDashboard(id);
}
}
function enableEmbeddingForResource({ resource, id }) {
const endpoint = resource === "question" ? "card" : "dashboard";
cy.request("PUT", `/api/${endpoint}/${id}`, {
enable_embedding: true,
});
}
function closeTo(value, offset) {
return comparedValue => {
return Math.abs(comparedValue - value) <= offset;
};
}
......@@ -12,6 +12,8 @@ export type CardType = "model" | "question";
export interface Card<Q extends DatasetQuery = DatasetQuery>
extends UnsavedCard<Q> {
id: CardId;
created_at: string;
updated_at: string;
name: string;
description: string | null;
/**
......@@ -24,6 +26,7 @@ export interface Card<Q extends DatasetQuery = DatasetQuery>
/* Indicates whether static embedding for this card has been published */
enable_embedding: boolean;
can_write: boolean;
initially_published_at: string | null;
database_id?: DatabaseId;
collection?: Collection | null;
......
......@@ -23,6 +23,8 @@ export type DashboardCard =
export interface Dashboard {
id: DashboardId;
created_at: string;
updated_at: string;
collection?: Collection | null;
collection_id: number | null;
name: string;
......@@ -44,8 +46,9 @@ export interface Dashboard {
auto_apply_filters: boolean;
archived: boolean;
public_uuid: string | null;
width: "full" | "fixed";
initially_published_at: string | null;
embedding_params?: EmbeddingParameters | null;
width: "full" | "fixed";
/* Indicates whether static embedding for this dashboard has been published */
enable_embedding: boolean;
......
......@@ -17,6 +17,8 @@ import {
export const createMockCard = (opts?: Partial<Card>): Card => ({
id: 1,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
name: "Question",
description: null,
display: "table",
......@@ -35,6 +37,7 @@ export const createMockCard = (opts?: Partial<Card>): Card => ({
based_on_upload: null,
archived: false,
enable_embedding: false,
initially_published_at: null,
...opts,
});
......
......@@ -10,6 +10,8 @@ import { createMockCard } from "./card";
export const createMockDashboard = (opts?: Partial<Dashboard>): Dashboard => ({
id: 1,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
collection_id: null,
name: "Dashboard",
dashcards: [],
......@@ -28,6 +30,7 @@ export const createMockDashboard = (opts?: Partial<Dashboard>): Dashboard => ({
public_uuid: null,
enable_embedding: false,
embedding_params: null,
initially_published_at: null,
width: "fixed",
...opts,
});
......
......@@ -59,6 +59,8 @@ function convertActionToQuestionCard(
): Card<NativeDatasetQuery> {
return {
id: action.id,
created_at: action.created_at,
updated_at: action.updated_at,
name: action.name,
description: action.description,
dataset_query: action.dataset_query,
......@@ -77,6 +79,7 @@ function convertActionToQuestionCard(
average_query_time: null,
archived: false,
enable_embedding: false,
initially_published_at: null,
};
}
......
......@@ -29,7 +29,10 @@ export const UPDATE_ENABLE_EMBEDDING =
export const updateEnableEmbedding = createAction(
UPDATE_ENABLE_EMBEDDING,
({ id }: DashboardIdPayload, enable_embedding: boolean) =>
DashboardApi.update({ id, enable_embedding }),
DashboardApi.update({
id,
enable_embedding,
}),
);
export const UPDATE_EMBEDDING_PARAMS =
......
......@@ -24,6 +24,8 @@ export const TEST_DASHBOARD_STATE: DashboardState = {
dashboards: {
1: {
id: 1,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
collection_id: 1,
name: "",
description: "",
......@@ -46,6 +48,7 @@ export const TEST_DASHBOARD_STATE: DashboardState = {
],
public_uuid: null,
enable_embedding: false,
initially_published_at: null,
width: "fixed",
},
},
......
......@@ -2,6 +2,10 @@ import type { Dashboard } from "metabase-types/api";
import { createPublicLink, deletePublicLink } from "metabase/dashboard/actions";
import { useDispatch } from "metabase/lib/redux";
import { publicDashboard as getPublicDashboardUrl } from "metabase/lib/urls";
import {
trackPublicLinkCopied,
trackPublicLinkRemoved,
} from "metabase/public/lib/analytics";
import { PublicLinkPopover } from "./PublicLinkPopover";
export const DashboardPublicLinkPopover = ({
......@@ -25,9 +29,19 @@ export const DashboardPublicLinkPopover = ({
await dispatch(createPublicLink(dashboard));
};
const deletePublicDashboardLink = () => {
trackPublicLinkRemoved({
artifact: "dashboard",
source: "public-share",
});
dispatch(deletePublicLink(dashboard));
};
const onCopyLink = () => {
trackPublicLinkCopied({
artifact: "dashboard",
});
};
return (
<PublicLinkPopover
target={target}
......@@ -36,6 +50,7 @@ export const DashboardPublicLinkPopover = ({
createPublicLink={createPublicDashboardLink}
deletePublicLink={deletePublicDashboardLink}
url={url}
onCopyLink={onCopyLink}
/>
);
};
......@@ -24,6 +24,7 @@ export const PublicLinkCopyPanel = ({
onChangeExtension,
removeButtonLabel,
removeTooltipLabel,
onCopy,
}: {
loading?: boolean;
url: string | null;
......@@ -33,6 +34,7 @@ export const PublicLinkCopyPanel = ({
extensions?: ExportFormatType[];
removeButtonLabel?: string;
removeTooltipLabel?: string;
onCopy?: () => void;
}) => (
<Stack spacing={0}>
<TextInput
......@@ -41,7 +43,7 @@ export const PublicLinkCopyPanel = ({
placeholder={loading ? t`Loading…` : undefined}
value={url ?? undefined}
inputWrapperOrder={["label", "input", "error", "description"]}
rightSection={url && <PublicLinkCopyButton value={url} />}
rightSection={url && <PublicLinkCopyButton value={url} onCopy={onCopy} />}
/>
<Box pos="relative">
<Group mt="sm" pos="absolute" w="100%" position="apart" align="center">
......@@ -56,6 +58,7 @@ export const PublicLinkCopyPanel = ({
}
>
<RemoveLinkAnchor
component="button"
fz="sm"
c="error"
fw={700}
......
......@@ -16,6 +16,7 @@ export type PublicLinkPopoverProps = {
extensions?: ExportFormatType[];
selectedExtension?: ExportFormatType | null;
setSelectedExtension?: (extension: ExportFormatType) => void;
onCopyLink?: () => void;
};
export const PublicLinkPopover = ({
......@@ -28,6 +29,7 @@ export const PublicLinkPopover = ({
extensions = [],
selectedExtension,
setSelectedExtension,
onCopyLink,
}: PublicLinkPopoverProps) => {
const isAdmin = useSelector(getUserIsAdmin);
......@@ -78,6 +80,7 @@ export const PublicLinkPopover = ({
onChangeExtension={setSelectedExtension}
removeButtonLabel={t`Remove public link`}
removeTooltipLabel={t`Affects both public link and embed URL for this dashboard`}
onCopy={onCopyLink}
/>
</Box>
</Popover.Dropdown>
......
import { useState } from "react";
import {
trackPublicLinkCopied,
trackPublicLinkRemoved,
} from "metabase/public/lib/analytics";
import { useDispatch } from "metabase/lib/redux";
import {
exportFormats,
......@@ -40,9 +44,20 @@ export const QuestionPublicLinkPopover = ({
await dispatch(createPublicLink(question.card()));
};
const deletePublicQuestionLink = async () => {
trackPublicLinkRemoved({
artifact: "question",
source: "public-share",
});
await dispatch(deletePublicLink(question.card()));
};
const onCopyLink = () => {
trackPublicLinkCopied({
artifact: "question",
format: extension ?? "html",
});
};
return (
<PublicLinkPopover
target={target}
......@@ -54,6 +69,7 @@ export const QuestionPublicLinkPopover = ({
extensions={exportFormats}
selectedExtension={extension}
setSelectedExtension={setExtension}
onCopyLink={onCopyLink}
/>
);
};
......@@ -153,11 +153,11 @@ const dashboards = handleActions(
},
[UPDATE_ENABLE_EMBEDDING]: {
next: (state, { payload }) =>
assocIn(
state,
[payload.id, "enable_embedding"],
payload.enable_embedding,
),
produce(state, draftState => {
const dashboard = draftState[payload.id];
dashboard.enable_embedding = payload.enable_embedding;
dashboard.initially_published_at = payload.initially_published_at;
}),
},
[Dashboards.actionTypes.UPDATE]: {
next: (state, { payload }) => {
......
import type { MouseEvent } from "react";
import { useState } from "react";
import { t } from "ttag";
import {
trackPublicEmbedCodeCopied,
trackPublicLinkRemoved,
} from "metabase/public/lib/analytics";
import { PublicLinkCopyPanel } from "metabase/dashboard/components/PublicLinkPopover/PublicLinkCopyPanel";
import { useSelector } from "metabase/lib/redux";
import { getSetting } from "metabase/selectors/settings";
......@@ -67,6 +71,12 @@ export function SelectEmbedTypePane({
"Public Link Disabled",
resourceType,
);
trackPublicLinkRemoved({
artifact: resourceType,
source: "public-embed",
});
await onDeletePublicLink();
setIsLoadingLink(false);
}
......@@ -96,6 +106,12 @@ export function SelectEmbedTypePane({
return (
<PublicLinkCopyPanel
url={iframeSource}
onCopy={() =>
trackPublicEmbedCodeCopied({
artifact: resourceType,
source: "public-embed",
})
}
onRemoveLink={deletePublicLink}
removeButtonLabel={t`Remove public URL`}
removeTooltipLabel={t`Affects both embed URL and public link for this dashboard`}
......@@ -146,6 +162,7 @@ export function SelectEmbedTypePane({
}
disabled={!isPublicSharingEnabled}
onClick={createPublicLink}
data-testid="sharing-pane-public-embed-button"
illustration={<PublicEmbedIcon disabled={!isPublicSharingEnabled} />}
>
{getPublicLinkElement()}
......
......@@ -13,6 +13,7 @@ type SharingOptionProps = {
description: ReactNode | string;
disabled?: boolean;
onClick?: MouseEventHandler;
"data-testid"?: string;
};
export const SharingPaneButton = ({
......@@ -22,8 +23,13 @@ export const SharingPaneButton = ({
description,
disabled,
onClick,
"data-testid": dataTestId,
}: SharingOptionProps) => (
<SharingPaneButtonContent withBorder disabled={disabled}>
<SharingPaneButtonContent
withBorder
disabled={disabled}
data-testid={dataTestId}
>
<Center
h="22.5rem"
p="8rem"
......
......@@ -25,8 +25,8 @@ const THEME_OPTIONS = [
{ label: t`Light`, value: "light" },
{ label: t`Dark`, value: "night" },
{ label: t`Transparent`, value: "transparent" },
];
const DEFAULT_THEME = THEME_OPTIONS[0].value;
] as const;
type ThemeOptions = typeof THEME_OPTIONS[number]["value"];
export interface AppearanceSettingsProps {
resourceType: EmbedResourceType;
......@@ -59,6 +59,7 @@ export const AppearanceSettings = ({
const utmTags = `?utm_source=${plan}&utm_media=static-embed-settings-appearance`;
const fontControlLabelId = useUniqueId("display-option");
const downloadDataId = useUniqueId("download-data");
return (
<>
......@@ -79,23 +80,22 @@ export const AppearanceSettings = ({
<Stack spacing="1rem">
<DisplayOptionSection title={t`Background`}>
<SegmentedControl
value={displayOptions.theme || DEFAULT_THEME}
data={THEME_OPTIONS}
value={displayOptions.theme}
// `data` type is required to be mutable, but THEME_OPTIONS is const.
data={[...THEME_OPTIONS]}
fullWidth
bg={color("bg-light")}
onChange={value => {
const newValue = value === DEFAULT_THEME ? null : value;
onChange={(value: ThemeOptions) => {
onChangeDisplayOptions({
...displayOptions,
theme: newValue,
theme: value,
});
}}
/>
</DisplayOptionSection>
<Switch
label={t`Dashboard title`}
label={getTitleLabel(resourceType)}
labelPosition="left"
size="sm"
variant="stretch"
......@@ -159,8 +159,12 @@ export const AppearanceSettings = ({
{canWhitelabel && resourceType === "question" && (
// We only show the "Download Data" toggle if the users are pro/enterprise
// and they're sharing a question metabase#23477
<DisplayOptionSection title={t`Download data`}>
<DisplayOptionSection
title={t`Download data`}
titleId={downloadDataId}
>
<Switch
aria-labelledby={downloadDataId}
label={t`Enable users to download data from this embed`}
labelPosition="left"
size="sm"
......@@ -169,7 +173,7 @@ export const AppearanceSettings = ({
onChange={e =>
onChangeDisplayOptions({
...displayOptions,
hide_download_button: !e.target.checked ? true : null,
hide_download_button: !e.target.checked,
})
}
/>
......@@ -195,3 +199,15 @@ export const AppearanceSettings = ({
</>
);
};
function getTitleLabel(resourceType: EmbedResourceType) {
if (resourceType === "dashboard") {
return t`Dashboard title`;
}
if (resourceType === "question") {
return t`Question title`;
}
return null;
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment