From d43863a2c10b6831bf9dbb50863e16aa4eb89c97 Mon Sep 17 00:00:00 2001 From: Oisin Coveney <oisin@metabase.com> Date: Fri, 9 Feb 2024 18:57:46 +0100 Subject: [PATCH] Add snowplow events for embedding setup flow (#37617) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 🎉 * 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: Cal Herries <39073188+calherries@users.noreply.github.com> * Update src/metabase/util/embed.clj Co-authored-by: Cal Herries <39073188+calherries@users.noreply.github.com> * Fix backend linter error, hopefully. --------- Co-authored-by: Mahatthana Nomsawadi <mahatthana.n@gmail.com> Co-authored-by: Mahatthana (Kelvin) Nomsawadi <me@bboykelvin.dev> Co-authored-by: Cal Herries <39073188+calherries@users.noreply.github.com> Co-authored-by: Nicolò Pretto <info@npretto.com> --- e2e/support/helpers/e2e-snowplow-helpers.js | 49 +- .../helpers/e2e-ui-elements-helpers.js | 5 + .../embedding-downloads-questions.cy.spec.js | 2 +- .../embedding/embedding-smoketests.cy.spec.js | 4 +- ...c-sharing-embed-button-behavior.cy.spec.js | 556 +++++++++++++++++- frontend/src/metabase-types/api/card.ts | 3 + frontend/src/metabase-types/api/dashboard.ts | 5 +- frontend/src/metabase-types/api/mocks/card.ts | 3 + .../src/metabase-types/api/mocks/dashboard.ts | 3 + .../QueryActionContextProvider.tsx | 3 + .../src/metabase/dashboard/actions/sharing.ts | 5 +- .../components/DashboardTabs/test-utils.ts | 3 + .../DashboardPublicLinkPopover.tsx | 15 + .../PublicLinkPopover/PublicLinkCopyPanel.tsx | 5 +- .../PublicLinkPopover/PublicLinkPopover.tsx | 3 + .../QuestionPublicLinkPopover.tsx | 16 + frontend/src/metabase/dashboard/reducers.js | 10 +- .../SelectEmbedTypePane.tsx | 17 + .../SharingPaneButton/SharingPaneButton.tsx | 8 +- .../AppearanceSettings.tsx | 38 +- .../ClientEmbedCodePane.tsx | 19 +- .../StaticEmbedSetupPane/CodeSample.tsx | 16 +- .../EmbedModalContentStatusBar.tsx | 8 +- .../StaticEmbedSetupPane/OverviewSettings.tsx | 16 +- .../ServerEmbedCodePane.tsx | 50 +- .../StaticEmbedSetupPane.tsx | 170 ++++-- .../EmbedModal/StaticEmbedSetupPane/config.ts | 17 +- .../EmbedModal/StaticEmbedSetupPane/tabs.ts | 5 + .../tests/common.unit.spec.tsx | 6 +- .../tests/premium.unit.spec.tsx | 12 +- frontend/src/metabase/public/lib/analytics.ts | 154 +++++ .../src/metabase/public/lib/code-templates.ts | 45 +- frontend/src/metabase/public/lib/code.ts | 16 +- frontend/src/metabase/public/lib/embed.ts | 10 +- frontend/src/metabase/public/lib/types.ts | 8 +- .../metabase/query_builder/actions/sharing.ts | 5 +- .../src/metabase/query_builder/reducers.js | 1 + .../ui/components/overlays/Popover/index.ts | 3 - .../ui/components/overlays/Popover/index.tsx | 18 + .../migrations/001_update_migrations.yaml | 32 + .../com.metabase/embed_flow/jsonschema/1-0-0 | 198 +++++++ src/metabase/models/card.clj | 4 +- src/metabase/models/dashboard.clj | 8 +- src/metabase/util/embed.clj | 7 + test/metabase/api/card_test.clj | 45 +- test/metabase/api/dashboard_test.clj | 1 + test/metabase/util/embed_test.clj | 29 +- 47 files changed, 1465 insertions(+), 191 deletions(-) create mode 100644 frontend/src/metabase/public/components/EmbedModal/StaticEmbedSetupPane/tabs.ts create mode 100644 frontend/src/metabase/public/lib/analytics.ts delete mode 100644 frontend/src/metabase/ui/components/overlays/Popover/index.ts create mode 100644 frontend/src/metabase/ui/components/overlays/Popover/index.tsx create mode 100644 snowplow/iglu-client-embedded/schemas/com.metabase/embed_flow/jsonschema/1-0-0 diff --git a/e2e/support/helpers/e2e-snowplow-helpers.js b/e2e/support/helpers/e2e-snowplow-helpers.js index a4cc0f50c8c..b44c957a459 100644 --- a/e2e/support/helpers/e2e-snowplow-helpers.js +++ b/e2e/support/helpers/e2e-snowplow-helpers.js @@ -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") diff --git a/e2e/support/helpers/e2e-ui-elements-helpers.js b/e2e/support/helpers/e2e-ui-elements-helpers.js index a3472ff58f5..6c1b497ba7b 100644 --- a/e2e/support/helpers/e2e-ui-elements-helpers.js +++ b/e2e/support/helpers/e2e-ui-elements-helpers.js @@ -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() { diff --git a/e2e/test/scenarios/embedding/embedding-downloads-questions.cy.spec.js b/e2e/test/scenarios/embedding/embedding-downloads-questions.cy.spec.js index 7d5f40e7415..9c8aed32747 100644 --- a/e2e/test/scenarios/embedding/embedding-downloads-questions.cy.spec.js +++ b/e2e/test/scenarios/embedding/embedding-downloads-questions.cy.spec.js @@ -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"); diff --git a/e2e/test/scenarios/embedding/embedding-smoketests.cy.spec.js b/e2e/test/scenarios/embedding/embedding-smoketests.cy.spec.js index dd039426a8c..f9d82516afc 100644 --- a/e2e/test/scenarios/embedding/embedding-smoketests.cy.spec.js +++ b/e2e/test/scenarios/embedding/embedding-smoketests.cy.spec.js @@ -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) => diff --git a/e2e/test/scenarios/sharing/public-sharing-embed-button-behavior.cy.spec.js b/e2e/test/scenarios/sharing/public-sharing-embed-button-behavior.cy.spec.js index 1ad00d2ddac..0e4bce55b93 100644 --- a/e2e/test/scenarios/sharing/public-sharing-embed-button-behavior.cy.spec.js +++ b/e2e/test/scenarios/sharing/public-sharing-embed-button-behavior.cy.spec.js @@ -1,20 +1,26 @@ -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; + }; +} diff --git a/frontend/src/metabase-types/api/card.ts b/frontend/src/metabase-types/api/card.ts index 8dafa3052a5..6e907352509 100644 --- a/frontend/src/metabase-types/api/card.ts +++ b/frontend/src/metabase-types/api/card.ts @@ -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; diff --git a/frontend/src/metabase-types/api/dashboard.ts b/frontend/src/metabase-types/api/dashboard.ts index c20df180634..fc387cd95b9 100644 --- a/frontend/src/metabase-types/api/dashboard.ts +++ b/frontend/src/metabase-types/api/dashboard.ts @@ -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; diff --git a/frontend/src/metabase-types/api/mocks/card.ts b/frontend/src/metabase-types/api/mocks/card.ts index 60edf49f58f..6d32b45069b 100644 --- a/frontend/src/metabase-types/api/mocks/card.ts +++ b/frontend/src/metabase-types/api/mocks/card.ts @@ -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, }); diff --git a/frontend/src/metabase-types/api/mocks/dashboard.ts b/frontend/src/metabase-types/api/mocks/dashboard.ts index a3a79ef766d..053271ef72c 100644 --- a/frontend/src/metabase-types/api/mocks/dashboard.ts +++ b/frontend/src/metabase-types/api/mocks/dashboard.ts @@ -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, }); diff --git a/frontend/src/metabase/actions/containers/ActionCreator/ActionContext/QueryActionContextProvider/QueryActionContextProvider.tsx b/frontend/src/metabase/actions/containers/ActionCreator/ActionContext/QueryActionContextProvider/QueryActionContextProvider.tsx index 6ff0d604cac..37af1219d53 100644 --- a/frontend/src/metabase/actions/containers/ActionCreator/ActionContext/QueryActionContextProvider/QueryActionContextProvider.tsx +++ b/frontend/src/metabase/actions/containers/ActionCreator/ActionContext/QueryActionContextProvider/QueryActionContextProvider.tsx @@ -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, }; } diff --git a/frontend/src/metabase/dashboard/actions/sharing.ts b/frontend/src/metabase/dashboard/actions/sharing.ts index 3317e3e5b98..06f2619de15 100644 --- a/frontend/src/metabase/dashboard/actions/sharing.ts +++ b/frontend/src/metabase/dashboard/actions/sharing.ts @@ -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 = diff --git a/frontend/src/metabase/dashboard/components/DashboardTabs/test-utils.ts b/frontend/src/metabase/dashboard/components/DashboardTabs/test-utils.ts index c204f28dd95..d0029e2b1ed 100644 --- a/frontend/src/metabase/dashboard/components/DashboardTabs/test-utils.ts +++ b/frontend/src/metabase/dashboard/components/DashboardTabs/test-utils.ts @@ -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", }, }, diff --git a/frontend/src/metabase/dashboard/components/PublicLinkPopover/DashboardPublicLinkPopover.tsx b/frontend/src/metabase/dashboard/components/PublicLinkPopover/DashboardPublicLinkPopover.tsx index 92a2fe98070..2b62f9fd634 100644 --- a/frontend/src/metabase/dashboard/components/PublicLinkPopover/DashboardPublicLinkPopover.tsx +++ b/frontend/src/metabase/dashboard/components/PublicLinkPopover/DashboardPublicLinkPopover.tsx @@ -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} /> ); }; diff --git a/frontend/src/metabase/dashboard/components/PublicLinkPopover/PublicLinkCopyPanel.tsx b/frontend/src/metabase/dashboard/components/PublicLinkPopover/PublicLinkCopyPanel.tsx index 7ccff209cfa..4d264c5f3d8 100644 --- a/frontend/src/metabase/dashboard/components/PublicLinkPopover/PublicLinkCopyPanel.tsx +++ b/frontend/src/metabase/dashboard/components/PublicLinkPopover/PublicLinkCopyPanel.tsx @@ -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} diff --git a/frontend/src/metabase/dashboard/components/PublicLinkPopover/PublicLinkPopover.tsx b/frontend/src/metabase/dashboard/components/PublicLinkPopover/PublicLinkPopover.tsx index f1fd28f9a78..b434b664e36 100644 --- a/frontend/src/metabase/dashboard/components/PublicLinkPopover/PublicLinkPopover.tsx +++ b/frontend/src/metabase/dashboard/components/PublicLinkPopover/PublicLinkPopover.tsx @@ -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> diff --git a/frontend/src/metabase/dashboard/components/PublicLinkPopover/QuestionPublicLinkPopover.tsx b/frontend/src/metabase/dashboard/components/PublicLinkPopover/QuestionPublicLinkPopover.tsx index e423eb6277f..268847371b5 100644 --- a/frontend/src/metabase/dashboard/components/PublicLinkPopover/QuestionPublicLinkPopover.tsx +++ b/frontend/src/metabase/dashboard/components/PublicLinkPopover/QuestionPublicLinkPopover.tsx @@ -1,4 +1,8 @@ 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} /> ); }; diff --git a/frontend/src/metabase/dashboard/reducers.js b/frontend/src/metabase/dashboard/reducers.js index aeed41833df..4f1f44223cb 100644 --- a/frontend/src/metabase/dashboard/reducers.js +++ b/frontend/src/metabase/dashboard/reducers.js @@ -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 }) => { diff --git a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/SelectEmbedTypePane.tsx b/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/SelectEmbedTypePane.tsx index a02de7f3a24..ddb6b60c4e3 100644 --- a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/SelectEmbedTypePane.tsx +++ b/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/SelectEmbedTypePane.tsx @@ -1,6 +1,10 @@ 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()} diff --git a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/SharingPaneButton/SharingPaneButton.tsx b/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/SharingPaneButton/SharingPaneButton.tsx index faf21ba9165..55cdc44f0e4 100644 --- a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/SharingPaneButton/SharingPaneButton.tsx +++ b/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/SharingPaneButton/SharingPaneButton.tsx @@ -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" diff --git a/frontend/src/metabase/public/components/EmbedModal/StaticEmbedSetupPane/AppearanceSettings.tsx b/frontend/src/metabase/public/components/EmbedModal/StaticEmbedSetupPane/AppearanceSettings.tsx index 8e7ab4dbcba..65e1d4e17a7 100644 --- a/frontend/src/metabase/public/components/EmbedModal/StaticEmbedSetupPane/AppearanceSettings.tsx +++ b/frontend/src/metabase/public/components/EmbedModal/StaticEmbedSetupPane/AppearanceSettings.tsx @@ -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; +} diff --git a/frontend/src/metabase/public/components/EmbedModal/StaticEmbedSetupPane/ClientEmbedCodePane.tsx b/frontend/src/metabase/public/components/EmbedModal/StaticEmbedSetupPane/ClientEmbedCodePane.tsx index 47879860fa2..de7c7e99ae4 100644 --- a/frontend/src/metabase/public/components/EmbedModal/StaticEmbedSetupPane/ClientEmbedCodePane.tsx +++ b/frontend/src/metabase/public/components/EmbedModal/StaticEmbedSetupPane/ClientEmbedCodePane.tsx @@ -10,17 +10,19 @@ import "ace/mode-html_ruby"; interface ClientEmbedCodePaneProps { clientCodeOptions: ClientCodeSampleConfig[]; - selectedClientCodeOptionName: string; - setSelectedClientCodeOptionName: (languageName: string) => void; + selectedClientCodeOptionId: string; + setSelectedClientCodeOptionId: (languageName: string) => void; + onCopy: () => void; } export const ClientEmbedCodePane = ({ clientCodeOptions, - selectedClientCodeOptionName, - setSelectedClientCodeOptionName, + selectedClientCodeOptionId, + setSelectedClientCodeOptionId, + onCopy, }: ClientEmbedCodePaneProps): JSX.Element | null => { const selectedClientCodeOption = clientCodeOptions.find( - ({ name }) => name === selectedClientCodeOptionName, + ({ id }) => id === selectedClientCodeOptionId, ); if (!selectedClientCodeOption) { @@ -31,11 +33,12 @@ export const ClientEmbedCodePane = ({ <CodeSample dataTestId="embed-frontend" title={t`Then insert this code snippet in your HTML template or single page app.`} - selectedOptionName={selectedClientCodeOptionName} - languageOptions={clientCodeOptions.map(({ name }) => name)} + selectedOptionId={selectedClientCodeOptionId} + languageOptions={clientCodeOptions} source={selectedClientCodeOption.source} textHighlightMode={selectedClientCodeOption.mode} - onChangeOption={setSelectedClientCodeOptionName} + onChangeOption={setSelectedClientCodeOptionId} + onCopy={onCopy} /> ); }; diff --git a/frontend/src/metabase/public/components/EmbedModal/StaticEmbedSetupPane/CodeSample.tsx b/frontend/src/metabase/public/components/EmbedModal/StaticEmbedSetupPane/CodeSample.tsx index cc480b60881..b8870a2a507 100644 --- a/frontend/src/metabase/public/components/EmbedModal/StaticEmbedSetupPane/CodeSample.tsx +++ b/frontend/src/metabase/public/components/EmbedModal/StaticEmbedSetupPane/CodeSample.tsx @@ -7,9 +7,9 @@ import type { CodeSampleOption } from "metabase/public/lib/types"; import { CopyButtonContainer } from "./CodeSample.styled"; interface CodeSampleProps { - selectedOptionName: CodeSampleOption["name"]; + selectedOptionId: CodeSampleOption["id"]; source: string; - languageOptions: CodeSampleOption["name"][]; + languageOptions: CodeSampleOption[]; title?: string; textHighlightMode: string; highlightedTexts?: string[]; @@ -18,10 +18,11 @@ interface CodeSampleProps { className?: string; onChangeOption: (optionName: string) => void; + onCopy?: () => void; } export const CodeSample = ({ - selectedOptionName, + selectedOptionId, source, title, languageOptions, @@ -30,6 +31,7 @@ export const CodeSample = ({ highlightedTexts, className, onChangeOption, + onCopy, }: CodeSampleProps): JSX.Element => { return ( <div className={className} data-testid={dataTestId}> @@ -39,7 +41,7 @@ export const CodeSample = ({ {languageOptions.length > 1 ? ( <Select className="AdminSelect--borderless ml-auto" - value={selectedOptionName} + value={selectedOptionId} onChange={(e: ChangeEvent<HTMLSelectElement>) => onChangeOption(e.target.value) } @@ -48,8 +50,8 @@ export const CodeSample = ({ }} > {languageOptions.map(option => ( - <Option key={option} value={option}> - {option} + <Option key={option.id} value={option.id}> + {option.name} </Option> ))} </Select> @@ -68,7 +70,7 @@ export const CodeSample = ({ /> {source && ( <CopyButtonContainer> - <CopyButton className="p1" value={source} /> + <CopyButton className="p1" value={source} onCopy={onCopy} /> </CopyButtonContainer> )} </div> diff --git a/frontend/src/metabase/public/components/EmbedModal/StaticEmbedSetupPane/EmbedModalContentStatusBar.tsx b/frontend/src/metabase/public/components/EmbedModal/StaticEmbedSetupPane/EmbedModalContentStatusBar.tsx index b2f63342f45..227ca5e1512 100644 --- a/frontend/src/metabase/public/components/EmbedModal/StaticEmbedSetupPane/EmbedModalContentStatusBar.tsx +++ b/frontend/src/metabase/public/components/EmbedModal/StaticEmbedSetupPane/EmbedModalContentStatusBar.tsx @@ -24,7 +24,13 @@ export const EmbedModalContentStatusBar = ({ const [isUnpublishing, setIsUnpublishing] = useState(false); return ( - <Paper withBorder shadow="sm" m="1.5rem 2rem" p="0.75rem 1rem"> + <Paper + withBorder + shadow="sm" + m="1.5rem 2rem" + p="0.75rem 1rem" + data-testid="embed-modal-content-status-bar" + > <Flex w="100%" justify="space-between" align="center" gap="0.5rem"> <Text fw="bold"> {!isPublished diff --git a/frontend/src/metabase/public/components/EmbedModal/StaticEmbedSetupPane/OverviewSettings.tsx b/frontend/src/metabase/public/components/EmbedModal/StaticEmbedSetupPane/OverviewSettings.tsx index 82f0cb40cd6..ba764254f29 100644 --- a/frontend/src/metabase/public/components/EmbedModal/StaticEmbedSetupPane/OverviewSettings.tsx +++ b/frontend/src/metabase/public/components/EmbedModal/StaticEmbedSetupPane/OverviewSettings.tsx @@ -20,6 +20,7 @@ export interface OverviewSettingsProps { resourceType: EmbedResourceType; serverEmbedCodeSlot: ReactNode; selectedServerCodeOption: ServerCodeSampleConfig | undefined; + onClientCodeCopy: (language: string) => void; } const clientCodeOptions = getEmbedClientCodeExampleOptions(); @@ -28,6 +29,7 @@ export const OverviewSettings = ({ resourceType, serverEmbedCodeSlot, selectedServerCodeOption, + onClientCodeCopy, }: OverviewSettingsProps): JSX.Element => { const docsUrl = useSelector(state => // eslint-disable-next-line no-unconditional-metabase-links-render -- Only appear to admins @@ -37,8 +39,9 @@ export const OverviewSettings = ({ getPlan(getSetting(state, "token-features")), ); - const [selectedClientCodeOptionName, setSelectedClientCodeOptionName] = - useState(clientCodeOptions[0].name); + const [selectedClientCodeOptionId, setSelectedClientCodeOptionId] = useState( + clientCodeOptions[0].id, + ); useEffect(() => { if (selectedServerCodeOption) { @@ -46,9 +49,9 @@ export const OverviewSettings = ({ if ( embedOption && - clientCodeOptions.find(({ name }) => name === embedOption) + clientCodeOptions.find(({ id }) => id === embedOption) ) { - setSelectedClientCodeOptionName(embedOption); + setSelectedClientCodeOptionId(embedOption); } } }, [selectedServerCodeOption]); @@ -81,8 +84,9 @@ export const OverviewSettings = ({ <ClientEmbedCodePane clientCodeOptions={clientCodeOptions} - selectedClientCodeOptionName={selectedClientCodeOptionName} - setSelectedClientCodeOptionName={setSelectedClientCodeOptionName} + selectedClientCodeOptionId={selectedClientCodeOptionId} + setSelectedClientCodeOptionId={setSelectedClientCodeOptionId} + onCopy={() => onClientCodeCopy(selectedClientCodeOptionId)} /> <Box my="1rem"> diff --git a/frontend/src/metabase/public/components/EmbedModal/StaticEmbedSetupPane/ServerEmbedCodePane.tsx b/frontend/src/metabase/public/components/EmbedModal/StaticEmbedSetupPane/ServerEmbedCodePane.tsx index b0f42eb2c36..c07075389d9 100644 --- a/frontend/src/metabase/public/components/EmbedModal/StaticEmbedSetupPane/ServerEmbedCodePane.tsx +++ b/frontend/src/metabase/public/components/EmbedModal/StaticEmbedSetupPane/ServerEmbedCodePane.tsx @@ -3,7 +3,7 @@ import { t } from "ttag"; import { getEmbedServerCodeExampleOptions } from "metabase/public/lib/code"; import type { EmbeddingDisplayOptions, - EmbeddingParameters, + EmbeddingParametersValues, EmbedResource, EmbedResourceType, ServerCodeSampleConfig, @@ -14,8 +14,10 @@ import "ace/mode-javascript"; import "ace/mode-ruby"; import "ace/mode-python"; +import { useSelector } from "metabase/lib/redux"; +import { getCanWhitelabel } from "metabase/selectors/whitelabel"; import type { EmbedCodePaneVariant } from "./types"; -import { DEFAULT_DISPLAY_OPTIONS } from "./config"; +import { getDefaultDisplayOptions } from "./config"; import { CodeSample } from "./CodeSample"; type EmbedCodePaneProps = { @@ -24,13 +26,14 @@ type EmbedCodePaneProps = { variant: EmbedCodePaneVariant; resource: EmbedResource; resourceType: EmbedResourceType; - params: EmbeddingParameters; + params: EmbeddingParametersValues; displayOptions: EmbeddingDisplayOptions; - initialPreviewParameters: EmbeddingParameters; + initialPreviewParameters: EmbeddingParametersValues; serverCodeOptions: ServerCodeSampleConfig[]; - selectedServerCodeOptionName: string; - setSelectedServerCodeOptionName: (languageName: string) => void; + selectedServerCodeOptionId: string; + setSelectedServerCodeOptionId: (languageName: string) => void; + onCopy: () => void; className?: string; }; @@ -45,15 +48,19 @@ export const ServerEmbedCodePane = ({ displayOptions, initialPreviewParameters, serverCodeOptions, - selectedServerCodeOptionName, - setSelectedServerCodeOptionName, + selectedServerCodeOptionId, + setSelectedServerCodeOptionId, + onCopy, className, }: EmbedCodePaneProps): JSX.Element | null => { const selectedServerCodeOption = serverCodeOptions.find( - ({ name }) => name === selectedServerCodeOptionName, + ({ id }) => id === selectedServerCodeOptionId, ); + const canWhitelabel = useSelector(getCanWhitelabel); + const shouldShowDownloadData = canWhitelabel && resourceType === "question"; + if (!selectedServerCodeOption) { return null; } @@ -63,12 +70,13 @@ export const ServerEmbedCodePane = ({ initialPreviewParameters, params, selectedServerCodeOption, - selectedServerCodeOptionName, + selectedServerCodeOptionId, siteUrl, secretKey, resourceType, resource, displayOptions, + shouldShowDownloadData, }); const title = getTitle({ @@ -82,12 +90,13 @@ export const ServerEmbedCodePane = ({ dataTestId="embed-backend" className={className} title={title} - selectedOptionName={selectedServerCodeOptionName} - languageOptions={serverCodeOptions.map(({ name }) => name)} + selectedOptionId={selectedServerCodeOptionId} + languageOptions={serverCodeOptions} source={selectedServerCodeOption.source} textHighlightMode={selectedServerCodeOption.mode} highlightedTexts={highlightedTexts} - onChangeOption={setSelectedServerCodeOptionName} + onChangeOption={setSelectedServerCodeOptionId} + onCopy={onCopy} /> ); }; @@ -96,23 +105,25 @@ function getHighlightedText({ initialPreviewParameters, params, selectedServerCodeOption, - selectedServerCodeOptionName, + selectedServerCodeOptionId, siteUrl, secretKey, resourceType, resource, displayOptions, + shouldShowDownloadData, }: { siteUrl: string; secretKey: string; resource: EmbedResource; resourceType: EmbedResourceType; - params: EmbeddingParameters; + params: EmbeddingParametersValues; displayOptions: EmbeddingDisplayOptions; - initialPreviewParameters: EmbeddingParameters; + initialPreviewParameters: EmbeddingParametersValues; selectedServerCodeOption: ServerCodeSampleConfig; - selectedServerCodeOptionName: string; + selectedServerCodeOptionId: string; + shouldShowDownloadData: boolean; }) { const hasParametersCodeDiff = !_.isEqual(initialPreviewParameters, params) && @@ -124,11 +135,10 @@ function getHighlightedText({ resourceId: resource.id, params: initialPreviewParameters, displayOptions, - }).find(({ name }) => name === selectedServerCodeOptionName) - ?.parametersSource; + }).find(({ id }) => id === selectedServerCodeOptionId)?.parametersSource; const hasAppearanceCodeDiff = !_.isEqual( - DEFAULT_DISPLAY_OPTIONS, + getDefaultDisplayOptions(shouldShowDownloadData), displayOptions, ); diff --git a/frontend/src/metabase/public/components/EmbedModal/StaticEmbedSetupPane/StaticEmbedSetupPane.tsx b/frontend/src/metabase/public/components/EmbedModal/StaticEmbedSetupPane/StaticEmbedSetupPane.tsx index 1db71d6acb4..1de885f281c 100644 --- a/frontend/src/metabase/public/components/EmbedModal/StaticEmbedSetupPane/StaticEmbedSetupPane.tsx +++ b/frontend/src/metabase/public/components/EmbedModal/StaticEmbedSetupPane/StaticEmbedSetupPane.tsx @@ -9,6 +9,7 @@ import type { EmbeddingDisplayOptions, EmbeddingParameters, EmbeddingParametersValues, + EmbeddingParameterVisibility, EmbedResource, EmbedResourceParameter, EmbedResourceType, @@ -18,24 +19,37 @@ import { optionsToHashParams, } from "metabase/public/lib/embed"; import { getEmbedServerCodeExampleOptions } from "metabase/public/lib/code"; - +import { + trackStaticEmbedCodeCopied, + trackStaticEmbedDiscarded, + trackStaticEmbedPublished, + trackStaticEmbedUnpublished, +} from "metabase/public/lib/analytics"; +import { getCanWhitelabel } from "metabase/selectors/whitelabel"; import { getParameterValue } from "metabase-lib/parameters/utils/parameter-values"; -import { DEFAULT_DISPLAY_OPTIONS } from "./config"; +import { getDefaultDisplayOptions } from "./config"; import { ServerEmbedCodePane } from "./ServerEmbedCodePane"; import { EmbedModalContentStatusBar } from "./EmbedModalContentStatusBar"; import { ParametersSettings } from "./ParametersSettings"; import { AppearanceSettings } from "./AppearanceSettings"; import { OverviewSettings } from "./OverviewSettings"; import type { ActivePreviewPane, EmbedCodePaneVariant } from "./types"; +import { EMBED_MODAL_TABS } from "./tabs"; import { SettingsTabLayout } from "./StaticEmbedSetupPane.styled"; import { PreviewModeSelector } from "./PreviewModeSelector"; import { PreviewPane } from "./PreviewPane"; -const TABS = { - Overview: "overview" as const, - Parameters: "parameters" as const, - Appearance: "appearance" as const, -}; +const countEmbeddingParameterOptions = (embeddingParams: EmbeddingParameters) => + Object.values(embeddingParams).reduce( + (acc, value) => { + acc[value] += 1; + return acc; + }, + { disabled: 0, locked: 0, enabled: 0 } as Record< + EmbeddingParameterVisibility, + number + >, + ); export interface StaticEmbedSetupPaneProps { resource: EmbedResource; @@ -70,8 +84,11 @@ export const StaticEmbedSetupPane = ({ ); const [parameterValues, setParameterValues] = useState<EmbeddingParametersValues>({}); + + const canWhitelabel = useSelector(getCanWhitelabel); + const shouldShowDownloadData = canWhitelabel && resourceType === "question"; const [displayOptions, setDisplayOptions] = useState<EmbeddingDisplayOptions>( - DEFAULT_DISPLAY_OPTIONS, + getDefaultDisplayOptions(shouldShowDownloadData), ); const previewParametersBySlug = useMemo( @@ -102,11 +119,12 @@ export const StaticEmbedSetupPane = ({ displayOptions, }); - const [selectedServerCodeOptionName, setSelectedServerCodeOptionName] = - useState(serverCodeOptions[0].name); + const [selectedServerCodeOptionId, setSelectedServerCodeOptionId] = useState( + serverCodeOptions[0].id, + ); const selectedServerCodeOption = serverCodeOptions.find( - ({ name }) => name === selectedServerCodeOptionName, + ({ id }) => id === selectedServerCodeOptionId, ); const hasSettingsChanges = getHasSettingsChanges({ @@ -141,36 +159,83 @@ export const StaticEmbedSetupPane = ({ await onUpdateEnableEmbedding(true); } await onUpdateEmbeddingParams(embeddingParams); + trackStaticEmbedPublished({ + artifact: resourceType, + resource, + params: countEmbeddingParameterOptions({ + ...convertResourceParametersToEmbeddingParams(resourceParameters), + ...embeddingParams, + }), + }); }; const handleUnpublish = async () => { await onUpdateEnableEmbedding(false); + trackStaticEmbedUnpublished({ + artifact: resourceType, + resource, + }); }; const handleDiscard = () => { setEmbeddingParams(getDefaultEmbeddingParams(resource, resourceParameters)); + trackStaticEmbedDiscarded({ + artifact: resourceType, + }); }; - const getServerEmbedCodePane = (variant: EmbedCodePaneVariant) => ( - <ServerEmbedCodePane - className="flex-full w-full" - variant={variant} - initialPreviewParameters={initialPreviewParameters} - resource={resource} - resourceType={resourceType} - siteUrl={siteUrl} - secretKey={secretKey} - params={previewParametersBySlug} - displayOptions={displayOptions} - serverCodeOptions={serverCodeOptions} - selectedServerCodeOptionName={selectedServerCodeOptionName} - setSelectedServerCodeOptionName={setSelectedServerCodeOptionName} - /> - ); + const getServerEmbedCodePane = (variant: EmbedCodePaneVariant) => { + return ( + <ServerEmbedCodePane + className="flex-full w-full" + variant={variant} + initialPreviewParameters={initialPreviewParameters} + resource={resource} + resourceType={resourceType} + siteUrl={siteUrl} + secretKey={secretKey} + params={previewParametersBySlug} + displayOptions={displayOptions} + serverCodeOptions={serverCodeOptions} + selectedServerCodeOptionId={selectedServerCodeOptionId} + setSelectedServerCodeOptionId={setSelectedServerCodeOptionId} + onCopy={() => + handleCodeCopy({ + code: "backend", + variant, + language: selectedServerCodeOptionId, + }) + } + /> + ); + }; - const [activeTab, setActiveTab] = useState<typeof TABS[keyof typeof TABS]>( - TABS.Overview, - ); + const handleCodeCopy = ({ + code, + variant, + language, + }: { + code: "backend" | "view"; + variant: EmbedCodePaneVariant; + language: string; + }) => { + const locationMap = { + overview: "code_overview", + parameters: "code_params", + appearance: "code_appearance", + } as const; + trackStaticEmbedCodeCopied({ + artifact: resourceType, + location: locationMap[variant], + code, + language, + displayOptions, + }); + }; + + const [activeTab, setActiveTab] = useState< + typeof EMBED_MODAL_TABS[keyof typeof EMBED_MODAL_TABS] + >(EMBED_MODAL_TABS.Overview); return ( <Stack spacing={0}> <EmbedModalContentStatusBar @@ -182,19 +247,22 @@ export const StaticEmbedSetupPane = ({ onDiscard={handleDiscard} /> - <Tabs defaultValue={TABS.Overview} data-testid="embedding-preview"> + <Tabs + defaultValue={EMBED_MODAL_TABS.Overview} + data-testid="embedding-preview" + > <Tabs.List p="0 1.5rem"> <Tabs.Tab - value={TABS.Overview} - onClick={() => setActiveTab(TABS.Overview)} + value={EMBED_MODAL_TABS.Overview} + onClick={() => setActiveTab(EMBED_MODAL_TABS.Overview)} >{t`Overview`}</Tabs.Tab> <Tabs.Tab - value={TABS.Parameters} - onClick={() => setActiveTab(TABS.Parameters)} + value={EMBED_MODAL_TABS.Parameters} + onClick={() => setActiveTab(EMBED_MODAL_TABS.Parameters)} >{t`Parameters`}</Tabs.Tab> <Tabs.Tab - value={TABS.Appearance} - onClick={() => setActiveTab(TABS.Appearance)} + value={EMBED_MODAL_TABS.Appearance} + onClick={() => setActiveTab(EMBED_MODAL_TABS.Appearance)} >{t`Appearance`}</Tabs.Tab> </Tabs.List> {/** @@ -211,13 +279,18 @@ export const StaticEmbedSetupPane = ({ * different `Tabs.Panel` if you were to use it as Mantine suggests. */} <Tabs.Panel value={activeTab}> - {activeTab === TABS.Overview ? ( + {activeTab === EMBED_MODAL_TABS.Overview ? ( <OverviewSettings resourceType={resourceType} selectedServerCodeOption={selectedServerCodeOption} - serverEmbedCodeSlot={getServerEmbedCodePane(TABS.Overview)} + serverEmbedCodeSlot={getServerEmbedCodePane( + EMBED_MODAL_TABS.Overview, + )} + onClientCodeCopy={language => + handleCodeCopy({ code: "view", variant: "overview", language }) + } /> - ) : activeTab === TABS.Parameters ? ( + ) : activeTab === EMBED_MODAL_TABS.Parameters ? ( <SettingsTabLayout settingsSlot={ <ParametersSettings @@ -248,12 +321,12 @@ export const StaticEmbedSetupPane = ({ isTransparent={displayOptions.theme === "transparent"} /> {activePane === "code" - ? getServerEmbedCodePane(TABS.Parameters) + ? getServerEmbedCodePane(EMBED_MODAL_TABS.Parameters) : null} </> } /> - ) : activeTab === TABS.Appearance ? ( + ) : activeTab === EMBED_MODAL_TABS.Appearance ? ( <SettingsTabLayout settingsSlot={ <AppearanceSettings @@ -275,7 +348,7 @@ export const StaticEmbedSetupPane = ({ isTransparent={displayOptions.theme === "transparent"} /> {activePane === "code" - ? getServerEmbedCodePane(TABS.Appearance) + ? getServerEmbedCodePane(EMBED_MODAL_TABS.Appearance) : null} </> } @@ -376,3 +449,14 @@ function getNonDisabledEmbeddingParams( return result; }, {} as EmbeddingParameters); } + +function convertResourceParametersToEmbeddingParams( + resourceParameters: EmbedResourceParameter[], +) { + const embeddingParams: EmbeddingParameters = {}; + for (const parameter of resourceParameters) { + embeddingParams[parameter.slug] = "disabled"; + } + + return embeddingParams; +} diff --git a/frontend/src/metabase/public/components/EmbedModal/StaticEmbedSetupPane/config.ts b/frontend/src/metabase/public/components/EmbedModal/StaticEmbedSetupPane/config.ts index 01be596df3d..9994ed4bd27 100644 --- a/frontend/src/metabase/public/components/EmbedModal/StaticEmbedSetupPane/config.ts +++ b/frontend/src/metabase/public/components/EmbedModal/StaticEmbedSetupPane/config.ts @@ -1,8 +1,13 @@ import type { EmbeddingDisplayOptions } from "metabase/public/lib/types"; -export const DEFAULT_DISPLAY_OPTIONS: EmbeddingDisplayOptions = { - font: null, - theme: null, - bordered: true, - titled: true, -}; +export function getDefaultDisplayOptions( + shouldShownDownloadData: boolean, +): EmbeddingDisplayOptions { + return { + font: null, + theme: "light", + bordered: true, + titled: true, + hide_download_button: shouldShownDownloadData ? false : null, + }; +} diff --git a/frontend/src/metabase/public/components/EmbedModal/StaticEmbedSetupPane/tabs.ts b/frontend/src/metabase/public/components/EmbedModal/StaticEmbedSetupPane/tabs.ts new file mode 100644 index 00000000000..5fdce66b08f --- /dev/null +++ b/frontend/src/metabase/public/components/EmbedModal/StaticEmbedSetupPane/tabs.ts @@ -0,0 +1,5 @@ +export const EMBED_MODAL_TABS = { + Overview: "overview" as const, + Parameters: "parameters" as const, + Appearance: "appearance" as const, +}; diff --git a/frontend/src/metabase/public/components/EmbedModal/StaticEmbedSetupPane/tests/common.unit.spec.tsx b/frontend/src/metabase/public/components/EmbedModal/StaticEmbedSetupPane/tests/common.unit.spec.tsx index def6d118524..0b0d5c7fb82 100644 --- a/frontend/src/metabase/public/components/EmbedModal/StaticEmbedSetupPane/tests/common.unit.spec.tsx +++ b/frontend/src/metabase/public/components/EmbedModal/StaticEmbedSetupPane/tests/common.unit.spec.tsx @@ -448,7 +448,11 @@ describe("Static Embed Setup phase", () => { `"#theme=transparent&bordered=true&titled=true"`, ); - userEvent.click(screen.getByText("Dashboard title")); + userEvent.click( + screen.getByText( + resourceType === "dashboard" ? "Dashboard title" : "Question title", + ), + ); expect(screen.getByTestId("text-editor-mock")).toHaveTextContent( `"#theme=transparent&bordered=true&titled=false"`, diff --git a/frontend/src/metabase/public/components/EmbedModal/StaticEmbedSetupPane/tests/premium.unit.spec.tsx b/frontend/src/metabase/public/components/EmbedModal/StaticEmbedSetupPane/tests/premium.unit.spec.tsx index 31449b102b0..696b98ef307 100644 --- a/frontend/src/metabase/public/components/EmbedModal/StaticEmbedSetupPane/tests/premium.unit.spec.tsx +++ b/frontend/src/metabase/public/components/EmbedModal/StaticEmbedSetupPane/tests/premium.unit.spec.tsx @@ -118,15 +118,9 @@ describe("Static Embed Setup phase - EE, with token", () => { }); expect(screen.getByText("Download data")).toBeVisible(); - expect( - screen.getByLabelText( - "Enable users to download data from this embed", - ), - ).toBeChecked(); - - userEvent.click( - screen.getByText("Enable users to download data from this embed"), - ); + expect(screen.getByLabelText("Download data")).toBeChecked(); + + userEvent.click(screen.getByLabelText("Download data")); expect(screen.getByTestId("text-editor-mock")).toHaveTextContent( `hide_download_button=true`, diff --git a/frontend/src/metabase/public/lib/analytics.ts b/frontend/src/metabase/public/lib/analytics.ts new file mode 100644 index 00000000000..01a1bde2cb5 --- /dev/null +++ b/frontend/src/metabase/public/lib/analytics.ts @@ -0,0 +1,154 @@ +import type { ExportFormatType } from "metabase/dashboard/components/PublicLinkPopover/types"; +import { trackSchemaEvent } from "metabase/lib/analytics"; +import type { + EmbeddingDisplayOptions, + EmbedResource, + EmbedResourceType, +} from "./types"; + +const SCHEMA_NAME = "embed_flow"; +const SCHEMA_VERSION = "1-0-0"; + +type Appearance = { + titled: boolean; + bordered: boolean; + theme: "light" | "night" | "transparent"; + font: "instance" | "custom"; + hide_download_button: boolean | null; +}; + +export const trackStaticEmbedDiscarded = ({ + artifact, +}: { + artifact: EmbedResourceType; +}): void => { + trackSchemaEvent(SCHEMA_NAME, SCHEMA_VERSION, { + event: "static_embed_discarded", + artifact, + }); +}; + +export const trackStaticEmbedPublished = ({ + artifact, + resource, + params, +}: { + artifact: EmbedResourceType; + resource: EmbedResource; + params: Record<string, number>; +}): void => { + const now = Date.now(); + trackSchemaEvent(SCHEMA_NAME, SCHEMA_VERSION, { + event: "static_embed_published", + artifact, + new_embed: !resource.initially_published_at, + time_since_creation: toSecond( + now - new Date(resource.created_at).getTime(), + ), + time_since_initial_publication: resource.initially_published_at + ? toSecond(now - new Date(resource.initially_published_at).getTime()) + : null, + params, + }); +}; + +function toSecond(milliseconds: number) { + return Math.round(milliseconds / 1000); +} + +export const trackStaticEmbedUnpublished = ({ + artifact, + resource, +}: { + artifact: EmbedResourceType; + resource: EmbedResource; +}): void => { + const now = Date.now(); + trackSchemaEvent(SCHEMA_NAME, SCHEMA_VERSION, { + event: "static_embed_unpublished", + artifact, + time_since_creation: toSecond( + now - new Date(resource.created_at).getTime(), + ), + time_since_initial_publication: resource.initially_published_at + ? toSecond(now - new Date(resource.initially_published_at).getTime()) + : null, + }); +}; + +export const trackStaticEmbedCodeCopied = ({ + artifact, + language, + location, + code, + displayOptions, +}: { + artifact: EmbedResourceType; + language: string; + location: "code_overview" | "code_params" | "code_appearance"; + code: "backend" | "view"; + displayOptions: EmbeddingDisplayOptions; +}): void => { + trackSchemaEvent(SCHEMA_NAME, SCHEMA_VERSION, { + event: "static_embed_code_copied", + artifact, + language, + location, + code, + appearance: normalizeAppearance(displayOptions), + }); +}; + +function normalizeAppearance( + displayOptions: EmbeddingDisplayOptions, +): Appearance { + return { + titled: displayOptions.titled, + bordered: displayOptions.bordered, + theme: displayOptions.theme ?? "light", + font: displayOptions.font ? "custom" : "instance", + hide_download_button: displayOptions.hide_download_button, + }; +} + +export const trackPublicLinkCopied = ({ + artifact, + format = null, +}: { + artifact: EmbedResourceType; + format?: ExportFormatType | null; +}): void => { + trackSchemaEvent(SCHEMA_NAME, SCHEMA_VERSION, { + event: "public_link_copied", + artifact, + format, + }); +}; + +export const trackPublicEmbedCodeCopied = ({ + artifact, + source, +}: { + artifact: EmbedResourceType; + source: "public-embed" | "public-share"; +}): void => { + trackSchemaEvent(SCHEMA_NAME, SCHEMA_VERSION, { + event: "public_embed_code_copied", + artifact, + source, + }); +}; + +export const trackPublicLinkRemoved = ({ + artifact, + source, +}: { + artifact: EmbedResourceType; + source: "public-embed" | "public-share"; +}): void => { + trackSchemaEvent(SCHEMA_NAME, SCHEMA_VERSION, { + event: "public_link_removed", + artifact, + source, + }); +}; diff --git a/frontend/src/metabase/public/lib/code-templates.ts b/frontend/src/metabase/public/lib/code-templates.ts index 5a631dcb640..aab2d16aa85 100644 --- a/frontend/src/metabase/public/lib/code-templates.ts +++ b/frontend/src/metabase/public/lib/code-templates.ts @@ -1,16 +1,38 @@ import type { CodeSampleParameters, EmbeddingDisplayOptions, - EmbeddingParameters, + EmbeddingParametersValues, } from "./types"; import { optionsToHashParams } from "./embed"; +function getIframeQuerySource(displayOptions: EmbeddingDisplayOptions) { + return JSON.stringify( + optionsToHashParams( + removeDefaultValueParameters(displayOptions, { + theme: "light", + hide_download_button: false, + }), + ), + ); +} + +function removeDefaultValueParameters( + options: EmbeddingDisplayOptions, + defaultValues: Partial<EmbeddingDisplayOptions>, +): Partial<EmbeddingDisplayOptions> { + return Object.fromEntries( + Object.entries(options).filter( + ([key, value]) => + value !== defaultValues[key as keyof EmbeddingDisplayOptions], + ), + ); +} + export const node = { - getParametersSource: (params: EmbeddingParameters) => + getParametersSource: (params: EmbeddingParametersValues) => `params: ${JSON.stringify(params, null, 2).split("\n").join("\n ")},`, - getIframeQuerySource: (displayOptions: EmbeddingDisplayOptions) => - JSON.stringify(optionsToHashParams(displayOptions)), + getIframeQuerySource, getServerSource: ({ siteUrl, @@ -39,15 +61,14 @@ var iframeUrl = METABASE_SITE_URL + "/embed/${resourceType}/" + token + }; export const python = { - getParametersSource: (params: EmbeddingParameters) => + getParametersSource: (params: EmbeddingParametersValues) => `"params": { ${Object.entries(params) .map(([key, value]) => JSON.stringify(key) + ": " + JSON.stringify(value)) .join(",\n ")} },`, - getIframeQuerySource: (displayOptions: EmbeddingDisplayOptions) => - JSON.stringify(optionsToHashParams(displayOptions)), + getIframeQuerySource, getServerSource: ({ siteUrl, @@ -77,7 +98,7 @@ iframeUrl = METABASE_SITE_URL + "/embed/${resourceType}/" + token + }; export const ruby = { - getParametersSource: (params: EmbeddingParameters) => + getParametersSource: (params: EmbeddingParametersValues) => `:params => { ${Object.entries(params) .map( @@ -89,8 +110,7 @@ export const ruby = { .join(",\n ")} },`, - getIframeQuerySource: (displayOptions: EmbeddingDisplayOptions) => - JSON.stringify(optionsToHashParams(displayOptions)), + getIframeQuerySource, getServerSource: ({ siteUrl, @@ -119,13 +139,12 @@ iframe_url = METABASE_SITE_URL + "/embed/${resourceType}/" + token + }; export const clojure = { - getParametersSource: (params: EmbeddingParameters) => + getParametersSource: (params: EmbeddingParametersValues) => `:params {${Object.entries(params) .map(([key, value]) => JSON.stringify(key) + " " + JSON.stringify(value)) .join(",\n ")}}`, - getIframeQuerySource: (displayOptions: EmbeddingDisplayOptions) => - JSON.stringify(optionsToHashParams(displayOptions)), + getIframeQuerySource, getServerSource: ({ siteUrl, diff --git a/frontend/src/metabase/public/lib/code.ts b/frontend/src/metabase/public/lib/code.ts index 3a44727c298..85b6d85b0e2 100644 --- a/frontend/src/metabase/public/lib/code.ts +++ b/frontend/src/metabase/public/lib/code.ts @@ -16,21 +16,25 @@ import { export const getEmbedClientCodeExampleOptions = (): ClientCodeSampleConfig[] => [ { + id: "pug", name: "Pug / Jade", source: getPugSource({ iframeUrl: `iframeUrl` }), mode: "ace/mode/jade", }, { + id: "mustache", name: "Mustache", source: getHtmlSource({ iframeUrl: `"{{iframeUrl}}"` }), mode: "ace/mode/html", }, { + id: "erb", name: "ERB", source: getHtmlSource({ iframeUrl: `"<%= @iframe_url %>"` }), mode: "ace/mode/html_ruby", }, { + id: "jsx", name: "JSX", source: getJsxSource({ iframeUrl: `{iframeUrl}` }), mode: "ace/mode/jsx", @@ -41,6 +45,7 @@ export const getEmbedServerCodeExampleOptions = ( codeSampleParameters: CodeSampleParameters, ): ServerCodeSampleConfig[] => [ { + id: "node", name: "Node.js", source: node.getServerSource(codeSampleParameters), parametersSource: node.getParametersSource(codeSampleParameters.params), @@ -48,9 +53,10 @@ export const getEmbedServerCodeExampleOptions = ( codeSampleParameters.displayOptions, ), mode: "ace/mode/javascript", - embedOption: "Pug / Jade", + embedOption: "pug", }, { + id: "ruby", name: "Ruby", source: ruby.getServerSource(codeSampleParameters), parametersSource: ruby.getParametersSource(codeSampleParameters.params), @@ -58,9 +64,10 @@ export const getEmbedServerCodeExampleOptions = ( codeSampleParameters.displayOptions, ), mode: "ace/mode/ruby", - embedOption: "ERB", + embedOption: "erb", }, { + id: "python", name: "Python", source: python.getServerSource(codeSampleParameters), parametersSource: python.getParametersSource(codeSampleParameters.params), @@ -68,9 +75,10 @@ export const getEmbedServerCodeExampleOptions = ( codeSampleParameters.displayOptions, ), mode: "ace/mode/python", - embedOption: "Pug / Jade", + embedOption: "pug", }, { + id: "clojure", name: "Clojure", source: clojure.getServerSource(codeSampleParameters), parametersSource: clojure.getParametersSource(codeSampleParameters.params), @@ -78,7 +86,7 @@ export const getEmbedServerCodeExampleOptions = ( codeSampleParameters.displayOptions, ), mode: "ace/mode/clojure", - embedOption: "Pug / Jade", + embedOption: "pug", }, ]; diff --git a/frontend/src/metabase/public/lib/embed.ts b/frontend/src/metabase/public/lib/embed.ts index cf0f6f9027e..4e7e3eb3c39 100644 --- a/frontend/src/metabase/public/lib/embed.ts +++ b/frontend/src/metabase/public/lib/embed.ts @@ -4,15 +4,15 @@ import { KJUR } from "jsrsasign"; // using jsrsasign because jsonwebtoken doesn' import type { EmbedResourceType, EmbedResource, - EmbeddingParameters, + EmbeddingParametersValues, } from "./types"; function getSignedToken( resourceType: EmbedResourceType, resourceId: EmbedResource["id"], - params: EmbeddingParameters = {}, + params: EmbeddingParametersValues = {}, secretKey: string, - previewEmbeddingParams: EmbeddingParameters, + previewEmbeddingParams: EmbeddingParametersValues, ) { const unsignedToken: Record<string, any> = { resource: { [resourceType]: resourceId }, @@ -32,9 +32,9 @@ export function getSignedPreviewUrlWithoutHash( siteUrl: string, resourceType: EmbedResourceType, resourceId: EmbedResource["id"], - params: EmbeddingParameters = {}, + params: EmbeddingParametersValues = {}, secretKey: string, - previewEmbeddingParams: EmbeddingParameters, + previewEmbeddingParams: EmbeddingParametersValues, ) { const token = getSignedToken( resourceType, diff --git a/frontend/src/metabase/public/lib/types.ts b/frontend/src/metabase/public/lib/types.ts index 7bc26225e5a..0b2e34ae220 100644 --- a/frontend/src/metabase/public/lib/types.ts +++ b/frontend/src/metabase/public/lib/types.ts @@ -25,10 +25,10 @@ export type EmbeddingParametersValues = Record<string, string>; export type EmbeddingDisplayOptions = { font: null | string; - theme: null | string; + theme: "light" | "night" | "transparent"; bordered: boolean; titled: boolean; - hide_download_button?: true | null; + hide_download_button: boolean | null; }; export type CodeSampleParameters = { @@ -36,17 +36,19 @@ export type CodeSampleParameters = { secretKey: string; resourceType: EmbedResourceType; resourceId: EmbedResource["id"]; - params: EmbeddingParameters; + params: EmbeddingParametersValues; displayOptions: EmbeddingDisplayOptions; }; export type ClientCodeSampleConfig = { + id: string; name: string; source: string; mode: string; }; export type ServerCodeSampleConfig = { + id: string; name: string; source: string; parametersSource: string; diff --git a/frontend/src/metabase/query_builder/actions/sharing.ts b/frontend/src/metabase/query_builder/actions/sharing.ts index 4b150a4d018..3cfdbd49218 100644 --- a/frontend/src/metabase/query_builder/actions/sharing.ts +++ b/frontend/src/metabase/query_builder/actions/sharing.ts @@ -32,7 +32,10 @@ export const UPDATE_ENABLE_EMBEDDING = "metabase/card/UPDATE_ENABLE_EMBEDDING"; export const updateEnableEmbedding = createAction( UPDATE_ENABLE_EMBEDDING, ({ id }: CardIdPayload, enable_embedding: boolean) => - CardApi.update({ id, enable_embedding }), + CardApi.update({ + id, + enable_embedding, + }), ); export const UPDATE_EMBEDDING_PARAMS = "metabase/card/UPDATE_EMBEDDING_PARAMS"; diff --git a/frontend/src/metabase/query_builder/reducers.js b/frontend/src/metabase/query_builder/reducers.js index 746a0a7aaeb..b860bfc7803 100644 --- a/frontend/src/metabase/query_builder/reducers.js +++ b/frontend/src/metabase/query_builder/reducers.js @@ -399,6 +399,7 @@ export const card = handleActions( next: (state, { payload }) => ({ ...state, embedding_params: payload.embedding_params, + initially_published_at: payload.initially_published_at, }), }, }, diff --git a/frontend/src/metabase/ui/components/overlays/Popover/index.ts b/frontend/src/metabase/ui/components/overlays/Popover/index.ts deleted file mode 100644 index 9fb2451a522..00000000000 --- a/frontend/src/metabase/ui/components/overlays/Popover/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { Popover } from "@mantine/core"; -export type { PopoverBaseProps, PopoverProps } from "@mantine/core"; -export { getPopoverOverrides } from "./Popover.styled"; diff --git a/frontend/src/metabase/ui/components/overlays/Popover/index.tsx b/frontend/src/metabase/ui/components/overlays/Popover/index.tsx new file mode 100644 index 00000000000..0e9236799f1 --- /dev/null +++ b/frontend/src/metabase/ui/components/overlays/Popover/index.tsx @@ -0,0 +1,18 @@ +import { Popover } from "@mantine/core"; +import type { PopoverDropdownProps } from "@mantine/core"; + +export type { PopoverBaseProps, PopoverProps } from "@mantine/core"; +export { getPopoverOverrides } from "./Popover.styled"; + +const MantinePopoverDropdown = Popover.Dropdown; + +const PopoverDropdown = Object.assign(function PopoverDropdown( + props: PopoverDropdownProps, +) { + return <MantinePopoverDropdown {...props} data-popover="mantine-popover" />; +}, +MantinePopoverDropdown); + +Popover.Dropdown = PopoverDropdown as typeof MantinePopoverDropdown; + +export { Popover }; diff --git a/resources/migrations/001_update_migrations.yaml b/resources/migrations/001_update_migrations.yaml index cb6260ffc3a..f8a532ef898 100644 --- a/resources/migrations/001_update_migrations.yaml +++ b/resources/migrations/001_update_migrations.yaml @@ -5317,6 +5317,38 @@ databaseChangeLog: - dbms: type: mysql,mariadb + - changeSet: + id: v49.2024-02-02T11:27:49 + author: oisincoveney + comment: >- + Add report_card.initially_published_at + changes: + - addColumn: + tableName: report_card + columns: + - column: + name: initially_published_at + type: ${timestamp_type} + constraints: + nullable: true + remarks: The timestamp when the card was first published in a static embed + + - changeSet: + id: v49.2024-02-02T11:28:36 + author: oisincoveney + comment: >- + Add report_dashboard.initially_published_at + changes: + - addColumn: + tableName: report_dashboard + columns: + - column: + name: initially_published_at + type: ${timestamp_type} + constraints: + nullable: true + remarks: The timestamp when the dashboard was first published in a static embed + - changeSet: id: v49.2024-02-07T21:52:01 author: noahmoss diff --git a/snowplow/iglu-client-embedded/schemas/com.metabase/embed_flow/jsonschema/1-0-0 b/snowplow/iglu-client-embedded/schemas/com.metabase/embed_flow/jsonschema/1-0-0 new file mode 100644 index 00000000000..361634dec2e --- /dev/null +++ b/snowplow/iglu-client-embedded/schemas/com.metabase/embed_flow/jsonschema/1-0-0 @@ -0,0 +1,198 @@ +{ + "$schema": "http://iglucentral.com/schemas/com.snowplowanalytics.self-desc/schema/jsonschema/1-0-0#", + "description": "User interactions with public links, static embedding, and public embedding", + "self": { + "vendor": "com.metabase", + "name": "embed_flow", + "format": "jsonschema", + "version": "1-0-0" + }, + "type": "object", + "properties": { + "event": { + "type": "string", + "enum": [ + "static_embed_discarded", + "static_embed_published", + "static_embed_unpublished", + "static_embed_code_copied", + "public_link_copied", + "public_embed_code_copied", + "public_link_removed" + ], + "description": "The type of event being recorded." + }, + "artifact": { + "type": "string", + "enum": [ + "dashboard", + "question" + ], + "description": "The type of artifact involved in the event (either a dashboard or a question)." + }, + "new_embed": { + "type": [ + "boolean", + "null" + ], + "description": "Indicates if the embed is new." + }, + "params": { + "type": [ + "object", + "null" + ], + "description": "Parameters related to the artifact.", + "properties": { + "locked": { + "type": "integer", + "description": "Number of locked parameters in the artifact.", + "minimum": 0, + "maximum": 2147483647 + }, + "editable": { + "type": "integer", + "description": "Number of editable parameters in the artifact.", + "minimum": 0, + "maximum": 2147483647 + }, + "disabled": { + "type": "integer", + "description": "Number of disabled parameters in the artifact.", + "minimum": 0, + "maximum": 2147483647 + } + } + }, + "first_published_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "The timestamp when the artifact was first published." + }, + "language": { + "type": [ + "string", + "null" + ], + "description": "The backend or view language of the artifact.", + "maxLength": 1024 + }, + "location": { + "type": [ + "string", + "null" + ], + "enum": [ + "code_overview", + "code_params", + "code_appearance" + ], + "description": "The location in the code where the event is triggered." + }, + "code": { + "type": [ + "string", + "null" + ], + "enum": [ + "backend", + "view" + ], + "description": "Type of the language of the artifact where the event is triggered." + }, + "appearance": { + "type": [ + "object", + "null" + ], + "description": "The appearance settings of the artifact.", + "properties": { + "title": { + "type": "boolean", + "description": "Indicates if the title is visible." + }, + "border": { + "type": "boolean", + "description": "Indicates if the border is visible." + }, + "theme": { + "type": [ + "string" + ], + "enum": [ + "light", + "night", + "transparent" + ], + "description": "The theme of the artifact's appearance." + }, + "font": { + "type": "string", + "enum": [ + "instance", + "custom" + ], + "description": "The font type used in the artifact." + }, + "hide_download_button": { + "type": [ + "boolean", + "null" + ], + "description": "Indicates if the download button is hidden. It will be null on OSS instance since it's not supported." + } + } + }, + "format": { + "type": [ + "string", + "null" + ], + "enum": [ + "html", + "csv", + "xlsx", + "json", + null + ], + "description": "The format of the public link." + }, + "source": { + "type": [ + "string", + "null" + ], + "enum": [ + "public-embed", + "public-share" + ], + "description": "Location where the public link is copied from" + }, + "time_since_creation": { + "description": "Number of seconds from the creation of the artifact until the event is triggered.", + "type": [ + "number", + "null" + ], + "minimum": 0, + "maximum": 2147483647 + }, + "time_since_initial_publication": { + "description": "Number of seconds from the initial publication of the artifact until the event is triggered.", + "type": [ + "number", + "null" + ], + "minimum": 0, + "maximum": 2147483647 + } + }, + "required": [ + "event", + "artifact" + ], + "additionalProperties": false +} diff --git a/src/metabase/models/card.clj b/src/metabase/models/card.clj index 580a3c1ee6a..30c22e07e01 100644 --- a/src/metabase/models/card.clj +++ b/src/metabase/models/card.clj @@ -42,6 +42,7 @@ [metabase.shared.util.i18n :refer [trs]] [metabase.sync.analyze.query-results :as qr] [metabase.util :as u] + [metabase.util.embed :refer [maybe-populate-initially-published-at]] [metabase.util.i18n :refer [tru]] [metabase.util.log :as log] [metabase.util.malli :as mu] @@ -181,7 +182,7 @@ ;;; --------------------------------------------------- Revisions ---------------------------------------------------- (def ^:private excluded-columns-for-card-revision - [:id :created_at :updated_at :entity_id :creator_id :public_uuid :made_public_by_id :metabase_version + [:id :created_at :updated_at :entity_id :creator_id :public_uuid :made_public_by_id :metabase_version :initially_published_at ;; we'll use type now :dataset]) @@ -560,6 +561,7 @@ populate-result-metadata pre-update populate-query-fields + maybe-populate-initially-published-at (dissoc :id))) ;; Cards don't normally get deleted (they get archived instead) so this mostly affects tests diff --git a/src/metabase/models/dashboard.clj b/src/metabase/models/dashboard.clj index 0029ea39b17..9dc82ef5249 100644 --- a/src/metabase/models/dashboard.clj +++ b/src/metabase/models/dashboard.clj @@ -28,6 +28,7 @@ [metabase.public-settings :as public-settings] [metabase.query-processor.async :as qp.async] [metabase.util :as u] + [metabase.util.embed :refer [maybe-populate-initially-published-at]] [metabase.util.i18n :as i18n :refer [deferred-tru deferred-trun tru]] [metabase.util.log :as log] [metabase.util.malli :as mu] @@ -94,10 +95,11 @@ (t2/define-before-update :model/Dashboard [dashboard] - (u/prog1 dashboard + (u/prog1 (maybe-populate-initially-published-at dashboard) (params/assert-valid-parameters dashboard) (parameter-card/upsert-or-delete-from-parameters! "dashboard" (:id dashboard) (:parameters dashboard)) - (collection/check-collection-namespace Dashboard (:collection_id dashboard)))) + (collection/check-collection-namespace Dashboard (:collection_id dashboard))) + (maybe-populate-initially-published-at dashboard)) (defn- update-dashboard-subscription-pulses! "Updates the pulses' names and collection IDs, and syncs the PulseCards" @@ -227,7 +229,7 @@ ;; lower-numbered positions appearing before higher numbered ones. ;; TODO: querying on stats we don't have any dashboard that has a position, maybe we could just drop it? :public_uuid :made_public_by_id - :position]) + :position :initially_published_at]) (def ^:private excluded-columns-for-dashcard-revision [:entity_id :created_at :updated_at :collection_authority_level]) diff --git a/src/metabase/util/embed.clj b/src/metabase/util/embed.clj index 75dd068fd1a..489905aba0f 100644 --- a/src/metabase/util/embed.clj +++ b/src/metabase/util/embed.clj @@ -108,6 +108,13 @@ (or (get-in unsigned-token keyseq) (throw (ex-info (tru "Token is missing value for keypath {0}" keyseq) {:status-code 400})))) +(defn maybe-populate-initially-published-at + "Populate `initially_published_at` if embedding is set to true" + [{:keys [enable_embedding initially_published_at] :as card-or-dashboard}] + (cond-> card-or-dashboard + (and (true? enable_embedding) (nil? initially_published_at)) + (assoc :initially_published_at :%now))) + (defsetting show-static-embed-terms (deferred-tru "Check if the static embedding licensing should be hidden in the static embedding flow") :type :boolean diff --git a/test/metabase/api/card_test.clj b/test/metabase/api/card_test.clj index e522e843285..48d674b369a 100644 --- a/test/metabase/api/card_test.clj +++ b/test/metabase/api/card_test.clj @@ -66,28 +66,29 @@ (def card-defaults "The default card params." - {:archived false - :collection_id nil - :collection_position nil - :collection_preview true - :dataset_query {} - :dataset false - :type "question" - :description nil - :display "scalar" - :enable_embedding false - :entity_id nil - :embedding_params nil - :made_public_by_id nil - :parameters [] - :parameter_mappings [] - :moderation_reviews () - :public_uuid nil - :query_type nil - :cache_ttl nil - :average_query_time nil - :last_query_start nil - :result_metadata nil}) + {:archived false + :collection_id nil + :collection_position nil + :collection_preview true + :dataset_query {} + :dataset false + :type "question" + :description nil + :display "scalar" + :enable_embedding false + :initially_published_at nil + :entity_id nil + :embedding_params nil + :made_public_by_id nil + :parameters [] + :parameter_mappings [] + :moderation_reviews () + :public_uuid nil + :query_type nil + :cache_ttl nil + :average_query_time nil + :last_query_start nil + :result_metadata nil}) ;; Used in dashboard tests (def card-defaults-no-hydrate diff --git a/test/metabase/api/dashboard_test.clj b/test/metabase/api/dashboard_test.clj index 1de1543f357..1ccfb496d37 100644 --- a/test/metabase/api/dashboard_test.clj +++ b/test/metabase/api/dashboard_test.clj @@ -202,6 +202,7 @@ :description nil :embedding_params nil :enable_embedding false + :initially_published_at nil :entity_id true :made_public_by_id nil :parameters [] diff --git a/test/metabase/util/embed_test.clj b/test/metabase/util/embed_test.clj index 19a5648cbc3..dcc2316e998 100644 --- a/test/metabase/util/embed_test.clj +++ b/test/metabase/util/embed_test.clj @@ -3,12 +3,16 @@ [buddy.sign.jwt :as jwt] [clojure.test :refer :all] [crypto.random :as crypto-random] + [java-time.api :as t] [metabase.config :as config] [metabase.public-settings.premium-features :as premium-features] [metabase.public-settings.premium-features-test :as premium-features-test] [metabase.test :as mt] - [metabase.util.embed :as embed])) + [metabase.util :as u] + [metabase.util.embed :as embed] + [toucan2.core :as t2] + [toucan2.tools.with-temp :as t2.with-temp])) (def ^:private ^String token-with-alg-none "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJhZG1pbiI6dHJ1ZX0.3Dbtd6Z0yuSfw62fOzBGHyiL0BJp3pod_PZE-BBdR-I") @@ -54,3 +58,26 @@ (testing "should return false when the user has already accepted licensing terms" (embed/show-static-embed-terms! false) (is (= (embed/show-static-embed-terms) false)))))))))) + +(deftest maybe-populate-initially-published-at-test + (let [now #t "2022-09-01T12:34:56Z"] + (doseq [model [:model/Card :model/Dashboard]] + (testing "should populate `initially_published_at` when a Card's enable_embedding is changed to true" + (t2.with-temp/with-temp [model card {:enable_embedding false}] + (is (= nil (:initially_published_at card))) + (t2/update! model (u/the-id card) {:enable_embedding true}) + (is (not= nil (:initially_published_at (t2/select-one model :id (u/the-id card))))))) + (testing "should keep `initially_published_at` value when a Card's enable_embedding is changed to false" + (t2.with-temp/with-temp [model card {:enable_embedding true :initially_published_at now}] + (is (not= nil (:initially_published_at card))) + (t2/update! model (u/the-id card) {:enable_embedding false}) + (is (= (t/offset-date-time now) (:initially_published_at (t2/select-one model :id (u/the-id card))))))) + (testing "should keep `initially_published_at` value when `enable_embedding` is already set to true" + (t2.with-temp/with-temp [model card {:enable_embedding true :initially_published_at now}] + (t2/update! model (u/the-id card) {:enable_embedding true}) + (is (= (t/offset-date-time now) (:initially_published_at (t2/select-one model :id (u/the-id card))))))) + (testing "should keep `initially_published_at` value when `enable_embedding` is already set to false" + (t2.with-temp/with-temp [model card {:enable_embedding false}] + (is (= nil (:initially_published_at card))) + (t2/update! model (u/the-id card) {:enable_embedding false}) + (is (= nil (:initially_published_at (t2/select-one model :id (u/the-id card)))))))))) -- GitLab