Compare commits

...

15 Commits

Author SHA1 Message Date
Anderson
9e349e054c Add e2e tests file for transforms upsells 2026-02-18 18:38:08 -03:00
Anderson
eb27df1788 Remove e2e tests to be moved to a new PR 2026-02-18 18:36:13 -03:00
Anderson
56f6e22b16 Merge branch 'master' into uxw-3103-python-transforms-upsell-missing-on-self-hosted-pro 2026-02-18 18:31:09 -03:00
Anderson
96d40e3631 Fix unit test and also add a new one for useTransformsBilling 2026-02-18 01:10:06 -03:00
Anderson
4809bf0810 Update self-hosted python upsell as per new design / Code clean up 2026-02-17 22:46:40 -03:00
Anderson
4025a2562e Merge branch 'master' into uxw-3103-python-transforms-upsell-missing-on-self-hosted-pro 2026-02-17 20:11:24 -03:00
Anderson
5f57979cee Merge branch 'master' into uxw-3103-python-transforms-upsell-missing-on-self-hosted-pro 2026-02-17 16:56:23 -03:00
Anderson
ef93a556e7 Fix e2e and unit test 2026-02-17 16:55:52 -03:00
Anderson
aa813ad717 Merge branch 'master' into uxw-3103-python-transforms-upsell-missing-on-self-hosted-pro 2026-02-17 15:35:52 -03:00
Anderson
b5fab190b6 Simplify logic 2026-02-17 15:35:23 -03:00
Anderson
ff6d28dbc3 Simplify TransformsUpsellPage a bit more, fix tests 2026-02-17 13:46:22 -03:00
Anderson
31f407bb12 Merge branch 'master' into uxw-3103-python-transforms-upsell-missing-on-self-hosted-pro 2026-02-17 12:31:40 -03:00
Anderson
548e85d9d9 Fix unit test 2026-02-17 12:31:20 -03:00
Anderson
3ef0b9b32b Add e2e tests 2026-02-16 21:56:29 -03:00
Anderson
a2689b8519 Show Python upsell on self hosted 2026-02-16 17:31:56 -03:00
30 changed files with 798 additions and 639 deletions

View File

@@ -0,0 +1,225 @@
import { USERS } from "e2e/support/cypress_data";
const { H } = cy;
describe("scenarios > data studio > transforms > upsells", () => {
beforeEach(() => {
H.restore("postgres-writable");
H.resetTestTable({ type: "postgres", table: "many_schemas" });
cy.signInAsAdmin();
cy.intercept("GET", "api/ee/billing", (req) => {
req.continue((res) => {
if (!res.body["data"]) {
res.body["data"] = {
billing_period_months: 1,
previous_add_ons: [],
};
}
});
});
});
describe("starter plan", () => {
beforeEach(() => {
H.activateToken("starter");
});
it("should render transforms upsell page", () => {
cy.log("should render upsell with no CTA when user is not a store admin");
assertBasicTransformsUpsellPage(false);
cy.log("should render upsell with CTA when user a store admin");
mockSessionProperties({ storeUserEmail: USERS.admin.email });
cy.url().reload();
assertBasicTransformsUpsellPage(true);
});
});
describe("cloud with basic transforms", () => {
beforeEach(() => {
H.activateToken("bleeding-edge");
mockSessionProperties({ features: { "transforms-python": false } });
createTestTransform();
});
it("should show python upsell when trying to use python transforms", () => {
cy.log("show modal upsell with no CTA when user is not store admin");
assertPythonTransformsUpsellModal(false);
cy.log("show modal upsell with CTA when user is store admin");
mockSessionProperties({
features: { "transforms-python": false },
storeUserEmail: USERS.admin.email,
});
cy.url().reload();
assertPythonTransformsUpsellModal(true, true);
});
});
describe("self-hosted", () => {
beforeEach(() => {
H.activateToken("pro-self-hosted");
enableTransformsFlow();
});
it("should show python upsell when trying to use python transforms", () => {
cy.log("show modal upsell with no CTA when user is not store admin");
mockSessionProperties({
storeUserEmail: undefined,
features: { "transforms-python": false },
});
assertPythonTransformsUpsellModal(false);
cy.log("show modal upsell with CTA when user is store admin");
mockSessionProperties({
storeUserEmail: USERS.admin.email,
features: { "transforms-python": false },
});
cy.url().reload();
assertPythonTransformsUpsellModal(true);
});
});
});
function assertBasicTransformsUpsellPage(isStoreUser = true) {
cy.log("Visit data studio page");
cy.visit("/data-studio");
H.DataStudio.nav().should("be.visible");
cy.log("Verify Transforms menu item is visible");
H.DataStudio.nav().findByText("Transforms").should("be.visible");
cy.log(
"Verify there is an upsell gem icon is displayed in Transforms menu item",
);
H.DataStudio.nav()
.findByText("Transforms")
.closest("a")
.within(() => {
cy.findByTestId("upsell-gem").should("be.visible");
});
cy.log("Verify transforms page is accessible");
H.DataStudio.nav().findByText("Transforms").click();
cy.findByText("Start transforming your data in Metabase").should(
"be.visible",
);
if (isStoreUser) {
cy.findByRole("button", { name: "Start trial" }).should("be.visible");
} else {
cy.findByText(/Please ask a Metabase Store Admin/).should("be.visible");
cy.findByRole("button", { name: "Confirm purchase" }).should("not.exist");
cy.findByRole("button", { name: "Add to trial" }).should("not.exist");
}
}
function assertPythonTransformsUpsellModal(
isStoreUser = true,
isCloud = false,
) {
cy.log("Visit data studio page");
cy.visit("/data-studio");
H.DataStudio.nav().should("be.visible");
cy.log("Verify Transforms menu item is visible");
H.DataStudio.nav().findByText("Transforms").should("be.visible");
cy.log("Verify there is an upsell gem icon is not displayed");
H.DataStudio.nav()
.findByText("Transforms")
.closest("a")
.within(() => {
cy.findByTestId("upsell-gem").should("not.exist");
});
H.DataStudio.nav().findByText("Transforms").click();
cy.findByRole("gridcell", { name: /Python library/ }).click();
cy.findByText("Go beyond SQL with advanced transforms").should("be.visible");
if (isStoreUser) {
if (isCloud) {
cy.findByRole("button", {
name: "Confirm purchase",
}).should("be.visible");
} else {
cy.findByRole("link", {
name: "Go to your store account to purchase",
}).should("be.visible");
}
} else {
cy.findByText(/Please ask a Metabase Store Admin/).should("be.visible");
cy.findByRole("link", {
name: "Go to your store account to purchase",
}).should("not.exist");
cy.findByRole("button", {
name: "Confirm purchase",
}).should("not.exist");
}
cy.log(
"Verify upsell modal is also shown when clicking the 'Python script' menu",
);
cy.realType("{esc}");
cy.findByText("Go beyond SQL with advanced transforms").should("not.exist");
cy.findByRole("button", { name: "Create a transform" }).click();
H.popover()
.findByText(/Python script/)
.click();
cy.findByText("Go beyond SQL with advanced transforms").should("be.visible");
}
function mockSessionProperties({
features = {},
storeUserEmail,
}: {
features?: Record<string, boolean>;
storeUserEmail?: string;
}) {
cy.intercept("GET", "api/session/properties", (req) => {
req.continue((res) => {
if (storeUserEmail) {
res.body["token-status"] = {
...res.body["token-status"],
"store-users": [
...res.body["token-status"]["store-users"],
{
id: 9999,
email: storeUserEmail,
"first-name": "Admin",
},
],
};
}
res.body["token-features"] = {
...res.body["token-features"],
...features,
};
});
});
}
function enableTransformsFlow() {
cy.visit("/data-studio");
H.DataStudio.nav().findByText("Transforms").click();
H.DataStudio.Transforms.enableTransformPage()
.should("be.visible")
.findByRole("button", { name: "Enable transforms" })
.click();
H.DataStudio.Transforms.list().should("be.visible");
createTestTransform();
cy.url().reload();
}
function createTestTransform() {
H.createSqlTransform({
sourceQuery: "SELECT 1",
targetTable: "table_a",
targetSchema: "Schema A",
});
}

View File

@@ -4344,38 +4344,3 @@ describe("scenarios > data studio > transforms > permissions > pro-self-hosted",
});
});
});
describe("scenarios > data studio > transforms > permissions > starter", () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
});
it("should have transforms upsell", () => {
H.activateToken("starter").then(() => {
cy.log("Visit data studio page");
cy.visit("/data-studio");
H.DataStudio.nav().should("be.visible");
cy.log("Verify Transforms menu item is visible");
H.DataStudio.nav().findByText("Transforms").should("be.visible");
cy.log(
"Verify there is an upsell gem icon is displayed in Transforms menu item",
);
H.DataStudio.nav()
.findByText("Transforms")
.closest("a")
.within(() => {
cy.findByTestId("upsell-gem").should("be.visible");
});
cy.log("Verify transforms page is accessible");
H.DataStudio.nav().findByText("Transforms").click();
cy.findByText("Start transforming your data in Metabase").should(
"be.visible",
);
});
});
});

View File

@@ -4,7 +4,7 @@ import { hasPremiumFeature } from "metabase-enterprise/settings";
import { PythonTransformEditor } from "./components/PythonTransformEditor";
import { SHARED_LIB_IMPORT_PATH } from "./constants";
import { PythonRunnerSettingsPage } from "./pages/PythonRunnerSettingsPage";
import { getPythonLibraryRoutes } from "./routes";
import { getPythonTransformsRoutes, getPythonUpsellRoutes } from "./routes";
import { getPythonSourceValidationResult } from "./utils";
/**
@@ -13,12 +13,16 @@ import { getPythonSourceValidationResult } from "./utils";
export function initializePlugin() {
if (hasPremiumFeature("transforms-python")) {
PLUGIN_TRANSFORMS_PYTHON.isEnabled = true;
PLUGIN_TRANSFORMS_PYTHON.getPythonLibraryRoutes = getPythonLibraryRoutes;
PLUGIN_TRANSFORMS_PYTHON.getPythonTransformsRoutes =
getPythonTransformsRoutes;
PLUGIN_TRANSFORMS_PYTHON.getPythonSourceValidationResult =
getPythonSourceValidationResult;
PLUGIN_TRANSFORMS_PYTHON.TransformEditor = PythonTransformEditor;
PLUGIN_TRANSFORMS_PYTHON.PythonRunnerSettingsPage =
PythonRunnerSettingsPage;
PLUGIN_TRANSFORMS_PYTHON.sharedLibImportPath = SHARED_LIB_IMPORT_PATH;
} else {
PLUGIN_TRANSFORMS_PYTHON.getPythonTransformsRoutes = getPythonUpsellRoutes;
}
PLUGIN_TRANSFORMS_PYTHON.sharedLibImportPath = SHARED_LIB_IMPORT_PATH;
}

View File

@@ -1,7 +1,35 @@
import { Route } from "react-router";
import { PythonLibraryEditorPage } from "./pages/PythonLibraryEditorPage";
import { ModalRoute } from "metabase/hoc/ModalRoute";
import { NewPythonTransformPage } from "metabase/transforms/pages/NewTransformPage";
import { TransformListPage } from "metabase/transforms/pages/TransformListPage";
export function getPythonLibraryRoutes() {
return <Route path="library/:path" component={PythonLibraryEditorPage} />;
import { PythonLibraryEditorPage } from "./pages/PythonLibraryEditorPage";
import { PythonTransformsUpsellModal } from "./upsells/PythonTransformsUpsellModal";
export function getPythonTransformsRoutes() {
return (
<>
<Route path="library/:path" component={PythonLibraryEditorPage} />
<Route path="new/python" component={NewPythonTransformPage} />
</>
);
}
export function getPythonUpsellRoutes() {
return (
// Render upsell modal on python transforms routes if feature is not enabled
<Route path="" component={TransformListPage}>
<ModalRoute
path="library/:path"
modal={PythonTransformsUpsellModal}
noWrap
/>
<ModalRoute
path="new/python"
modal={PythonTransformsUpsellModal}
noWrap
/>
</Route>
);
}

View File

@@ -3,19 +3,25 @@ import { useCallback } from "react";
import { t } from "ttag";
import { trackUpsellClicked } from "metabase/admin/upsells/components/analytics";
import type { BillingPeriod } from "metabase/data-studio/upsells/types";
import * as Urls from "metabase/lib/urls";
import { useMetadataToasts } from "metabase/metadata/hooks";
import type { CloudPurchaseContentProps } from "metabase/plugins/oss/transforms";
import { Button, Card, Divider, Flex, Group, Stack, Text } from "metabase/ui";
import { usePurchaseCloudAddOnMutation } from "metabase-enterprise/api";
import { TransformsSettingUpModal } from "./TransformsSettingUpModal";
import { TransformsSettingUpModal } from "metabase-enterprise/transforms/upsells/components/TransformsSettingUpModal";
const CAMPAIGN = "data-studio-python-transforms";
const LOCATION = "data-studio-transforms";
type CloudPurchaseContentProps = {
billingPeriod: BillingPeriod;
handleModalClose: VoidFunction;
isTrialFlow: boolean;
pythonPrice: number;
};
export const CloudPurchaseContent = (props: CloudPurchaseContentProps) => {
const { billingPeriod, handleModalClose, isTrialFlow, onError, pythonPrice } =
props;
const { billingPeriod, handleModalClose, isTrialFlow, pythonPrice } = props;
const [purchaseCloudAddOn, { isLoading: isPurchasing }] =
usePurchaseCloudAddOnMutation();
const [settingUpModalOpened, settingUpModalHandlers] = useDisclosure(false);
@@ -30,16 +36,15 @@ export const CloudPurchaseContent = (props: CloudPurchaseContentProps) => {
await purchaseCloudAddOn({
product_type: "transforms-advanced",
}).unwrap();
window.location.href = Urls.transformList(); // On success, do a full-page redirect to transforms list
} catch {
sendErrorToast(
t`It looks like something went wrong. Please refresh the page and try again.`,
);
settingUpModalHandlers.close();
onError();
}
}, [
handleModalClose,
onError,
purchaseCloudAddOn,
sendErrorToast,
settingUpModalHandlers,

View File

@@ -2,7 +2,7 @@ import userEvent from "@testing-library/user-event";
import fetchMock from "fetch-mock";
import { setupPropertiesEndpoints } from "__support__/server-mocks";
import { renderWithProviders, screen, waitFor } from "__support__/ui";
import { renderWithProviders, screen } from "__support__/ui";
import {
createMockSettings,
createMockTokenFeatures,
@@ -22,7 +22,6 @@ const setup = ({
pythonPrice?: number;
}) => {
const handleModalClose = jest.fn();
const onError = jest.fn();
const settings = createMockSettings({
"token-status": {
@@ -44,7 +43,6 @@ const setup = ({
billingPeriod="yearly"
handleModalClose={handleModalClose}
isTrialFlow={isTrialFlow}
onError={onError}
pythonPrice={pythonPrice}
/>,
{
@@ -52,7 +50,7 @@ const setup = ({
},
);
return { handleModalClose, onError };
return { handleModalClose };
};
describe("CloudPurchaseContent", () => {
@@ -103,18 +101,4 @@ describe("CloudPurchaseContent", () => {
screen.getByText("Setting up Python transforms, please wait"),
).toBeInTheDocument();
});
it("calls onError when the purchase request fails", async () => {
fetchMock.post("path:/api/ee/cloud-add-ons/transforms-advanced", 500);
const { onError } = setup({ isTrialFlow: false, pythonPrice: 250 });
await userEvent.click(
screen.getByRole("button", { name: "Confirm purchase" }),
);
await waitFor(() => {
expect(onError).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,159 @@
import { useEffect } from "react";
import { Link } from "react-router";
import { t } from "ttag";
import { trackUpsellViewed } from "metabase/admin/upsells/components/analytics";
import { LoadingAndErrorWrapper } from "metabase/common/components/LoadingAndErrorWrapper";
import { useStoreUrl } from "metabase/common/hooks";
import { useSelector } from "metabase/lib/redux";
import { getStoreUsers } from "metabase/selectors/store-users";
import { getIsHosted } from "metabase/setup/selectors";
import {
Button,
Center,
Divider,
Flex,
Icon,
Modal,
Stack,
Text,
Title,
} from "metabase/ui";
import { useTransformsBilling } from "metabase-enterprise/transforms/upsells/hooks";
import { CloudPurchaseContent } from "./CloudPurchaseContent";
import { CAMPAIGN, LOCATION } from "./constants";
type PythonTransformsUpsellModalProps = {
onClose: VoidFunction;
};
export function PythonTransformsUpsellModal({
onClose,
}: PythonTransformsUpsellModalProps) {
const bulletPoints = [
t`Use Python to handle complex logic that's awkward or brittle in SQL`,
t`Reuse shared Python logic instead of copying SQL patterns`,
t`Unblock advanced use cases without pushing work into another tool`,
t`Put pandas to work for data analysis and manipulation`,
];
const isHosted = useSelector(getIsHosted);
const { isStoreUser, anyStoreUserEmailAddress } = useSelector(getStoreUsers);
const {
isLoading,
error,
billingPeriodMonths,
advancedTransformsAddOn,
isOnTrial,
} = useTransformsBilling();
useEffect(() => {
trackUpsellViewed({ location: LOCATION, campaign: CAMPAIGN });
}, []);
const canUserPurchase =
isStoreUser && (!!advancedTransformsAddOn || !isHosted);
const shouldShowRightColumn = canUserPurchase && isHosted;
return (
<Modal.Root
onClose={onClose}
opened
size={shouldShowRightColumn ? "xl" : "md"}
>
<Modal.Overlay />
<Modal.Content>
<Modal.Body p={0}>
<Flex>
{/* Left Column - Info */}
<Stack p="3rem" flex={1} gap="md">
<Title order={3}>
{t`Go beyond SQL with advanced transforms`}
</Title>
<Text c="text-secondary" lh="lg">
{t`Run Python-based transforms alongside SQL to handle more complex logic and data workflows.`}
</Text>
<Stack gap="md" pb="md" pt="sm">
{bulletPoints.map((point) => (
<Flex direction="row" gap="sm" key={point}>
<Center w={24} h={24}>
<Icon name="check_filled" size={16} c="text-brand" />
</Center>
<Text c="text-secondary" lh="lg">
{point}
</Text>
</Flex>
))}
</Stack>
{!canUserPurchase && (
<Text fw="bold">
{anyStoreUserEmailAddress
? // eslint-disable-next-line metabase/no-literal-metabase-strings -- This string only shows for admins.
t`Please ask a Metabase Store Admin (${anyStoreUserEmailAddress}) to enable this for you.`
: // eslint-disable-next-line metabase/no-literal-metabase-strings -- This string only shows for admins.
t`Please ask a Metabase Store Admin to enable this for you.`}
</Text>
)}
{canUserPurchase && !isHosted && <SelfHostedStorePurchaseLink />}
</Stack>
{shouldShowRightColumn && (
<>
<Divider orientation="vertical" />
<Stack bg="background-secondary" flex={1} gap="lg" p="xl">
<Title order={3}>
{getRightColumnTitle(
isHosted && advancedTransformsAddOn?.trial_days
? advancedTransformsAddOn?.trial_days
: 0,
)}
</Title>
{error || isLoading ? (
<Center py="xl">
<LoadingAndErrorWrapper
loading={isLoading}
error={
error
? t`Error fetching information about available add-ons.`
: undefined
}
/>
</Center>
) : (
<CloudPurchaseContent
billingPeriod={
billingPeriodMonths === 1 ? "monthly" : "yearly"
}
handleModalClose={onClose}
isTrialFlow={isOnTrial}
pythonPrice={
advancedTransformsAddOn?.default_base_fee ?? 0
}
/>
)}
</Stack>
</>
)}
</Flex>
</Modal.Body>
</Modal.Content>
</Modal.Root>
);
}
function getRightColumnTitle(availableTrialDays: number) {
return availableTrialDays > 0
? t`Start a free ${availableTrialDays}-day trial of Python transforms`
: t`Add advanced transforms to your plan`;
}
function SelfHostedStorePurchaseLink() {
const storeUrl = useStoreUrl("account/transforms");
return (
<Button component={Link} to={storeUrl} variant="primary">
{t`Go to your store account to purchase`}
</Button>
);
}

View File

@@ -1,18 +1,8 @@
import userEvent from "@testing-library/user-event";
import { screen } from "__support__/ui";
import { setup } from "./setup";
describe("PythonTransformsUpsellModal - EE", () => {
beforeEach(() => {
jest.clearAllMocks();
});
afterEach(() => {
jest.restoreAllMocks();
});
describe("PythonTransformsUpsellModal", () => {
it("renders single-column layout with admin message when hosted and user is not a store user", () => {
setup({ isHosted: true, isStoreUser: false, isEnterprise: true });
@@ -39,7 +29,7 @@ describe("PythonTransformsUpsellModal - EE", () => {
setup({ isHosted: true, isStoreUser: true, isEnterprise: true });
expect(
screen.getByRole("heading", {
await screen.findByRole("heading", {
name: /Add advanced transforms to your plan/,
}),
).toBeInTheDocument();
@@ -50,19 +40,19 @@ describe("PythonTransformsUpsellModal - EE", () => {
).toBeInTheDocument();
});
it("shows upsell CTA when instance is self-hosted and clicking it closes modal", async () => {
const { onClose } = setup({
it("shows upsell store link when instance is self-hosted", async () => {
setup({
isHosted: false,
isStoreUser: true,
isEnterprise: true,
});
await screen.findByRole("button", { name: /Get Python transforms/ });
await userEvent.click(
screen.getByRole("button", { name: /Get Python transforms/ }),
const link = await screen.findByRole("link", {
name: /Go to your store account to purchase/,
});
expect(link).toHaveAttribute(
"href",
"https://store.staging.metabase.com/account/transforms",
);
expect(onClose).toHaveBeenCalled();
});
});

View File

@@ -1,3 +1,5 @@
import { Route } from "react-router";
import { setupEnterpriseOnlyPlugin } from "__support__/enterprise";
import type { ENTERPRISE_PLUGIN_NAME } from "__support__/enterprise-typed";
import {
@@ -16,7 +18,6 @@ import { createMockState } from "metabase-types/store/mocks";
import { PythonTransformsUpsellModal } from "../PythonTransformsUpsellModal";
export const setup = ({
isOpen = true,
isHosted,
isStoreUser,
billingPeriodMonths = 12,
@@ -66,9 +67,13 @@ export const setup = ({
setupPropertiesEndpoints(createMockSettings(settings));
renderWithProviders(
<PythonTransformsUpsellModal isOpen={isOpen} onClose={onClose} />,
<Route
component={() => <PythonTransformsUpsellModal onClose={onClose} />}
path="/"
/>,
{
storeInitialState: state,
withRouter: true,
},
);

View File

@@ -1,15 +1,10 @@
import { PLUGIN_TRANSFORMS } from "metabase/plugins";
import { hasPremiumFeature } from "metabase-enterprise/settings";
import { CloudPurchaseContent } from "./upsells/components/CloudPurchaseContent";
import { useTransformsBilling } from "./upsells/hooks";
import { TransformsUpsellPage } from "./upsells/pages/TransformsUpsellPage";
// transforms is not a regular plugin, but a set of addons - basic transforms + python transforms
export function initializePlugin() {
PLUGIN_TRANSFORMS.TransformsUpsellPage = TransformsUpsellPage;
PLUGIN_TRANSFORMS.useTransformsBilling = useTransformsBilling;
PLUGIN_TRANSFORMS.CloudPurchaseContent = CloudPurchaseContent;
PLUGIN_TRANSFORMS.isEnabled = !!hasPremiumFeature("transforms");
}

View File

@@ -1,5 +1,6 @@
import { useHasTokenFeature, useSetting } from "metabase/common/hooks";
import type { TransformsBillingData } from "metabase/plugins/oss/transforms";
import { getIsHosted } from "metabase/databases/selectors";
import { useSelector } from "metabase/lib/redux";
import {
useGetBillingInfoQuery,
useListAddOnsQuery,
@@ -7,14 +8,19 @@ import {
const TRANSFORMS_PRODUCT_TYPES = ["transforms"] as const;
export function useTransformsBilling(): TransformsBillingData {
export function useTransformsBilling() {
const tokenStatus = useSetting("token-status");
const isHosted = useSelector(getIsHosted);
const hasTransforms = useHasTokenFeature("transforms");
const hasPythonTransforms = useHasTokenFeature("transforms-python");
const {
data: addOns,
error: addOnsError,
isLoading: addOnsLoading,
} = useListAddOnsQuery();
} = useListAddOnsQuery(undefined, {
skip: !isHosted,
});
const {
data: billingInfo,
@@ -52,10 +58,7 @@ export function useTransformsBilling(): TransformsBillingData {
const isOnTrial = tokenStatus?.trial ?? false;
const trialEndDate = tokenStatus?.["valid-thru"];
const hasTransforms = useHasTokenFeature("transforms");
const hasPythonTransforms = useHasTokenFeature("transforms-python");
// Check if user already has basic transforms (to show upgrade-only)
// Check if user already has basic transforms
const hasBasicTransforms = Boolean(hasTransforms && !hasPythonTransforms);
return {

View File

@@ -0,0 +1,242 @@
import {
setupBillingEndpoints,
setupPropertiesEndpoints,
} from "__support__/server-mocks";
import { mockSettings } from "__support__/settings";
import { renderHookWithProviders, waitFor } from "__support__/ui";
import {
createMockSettings,
createMockTokenFeatures,
} from "metabase-types/api/mocks";
import { createMockState } from "metabase-types/store/mocks";
import { useTransformsBilling } from "./useTransformsBilling";
const BASIC_PRICE = 101;
const ADVANCED_PRICE = 251;
type SetupOpts = {
isHosted?: boolean;
hasBasicTransforms?: boolean;
hasPythonTransforms?: boolean;
hasBasicTransformsAddOn?: boolean;
hasAdvancedTransformsAddOn?: boolean;
previousAddOns?: Array<{
product_type: string;
self_service: boolean;
}>;
};
function setup({
isHosted = true,
hasBasicTransforms = false,
hasPythonTransforms = false,
hasBasicTransformsAddOn = true,
hasAdvancedTransformsAddOn = true,
previousAddOns = [],
}: SetupOpts = {}) {
const settings = createMockSettings({
"is-hosted?": isHosted,
"token-features": createMockTokenFeatures({
transforms: hasBasicTransforms,
"transforms-python": hasPythonTransforms,
}),
});
const storeInitialState = createMockState({
settings: mockSettings({
"is-hosted?": isHosted,
"token-features": createMockTokenFeatures({
transforms: hasBasicTransforms,
"transforms-python": hasPythonTransforms,
}),
}),
});
setupPropertiesEndpoints(settings);
setupBillingEndpoints({
hasBasicTransformsAddOn,
hasAdvancedTransformsAddOn,
transformsBasicPrice: BASIC_PRICE,
transformsAdvancedPrice: ADVANCED_PRICE,
previousAddOns,
});
return renderHookWithProviders(() => useTransformsBilling(), {
storeInitialState,
});
}
describe("useTransformsBilling", () => {
describe("isHosted behavior", () => {
it("should fetch add-ons when isHosted is true", async () => {
const { result } = setup({ isHosted: true });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.basicTransformsAddOn).toBeDefined();
expect(result.current.basicTransformsAddOn?.product_type).toBe(
"transforms-basic",
);
expect(result.current.advancedTransformsAddOn).toBeDefined();
expect(result.current.advancedTransformsAddOn?.product_type).toBe(
"transforms-advanced",
);
});
it("should skip fetching add-ons when isHosted is false", async () => {
const { result } = setup({ isHosted: false });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.basicTransformsAddOn).toBeUndefined();
expect(result.current.advancedTransformsAddOn).toBeUndefined();
});
});
describe("token features", () => {
it("should return hasBasicTransforms=false when no transforms feature", async () => {
const { result } = setup({
hasBasicTransforms: false,
hasPythonTransforms: false,
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.hasBasicTransforms).toBe(false);
});
it("should return hasBasicTransforms=true when transforms is enabled but not python", async () => {
const { result } = setup({
hasBasicTransforms: true,
hasPythonTransforms: false,
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.hasBasicTransforms).toBe(true);
});
it("should return hasBasicTransforms=false when both transforms and python are enabled", async () => {
const { result } = setup({
hasBasicTransforms: true,
hasPythonTransforms: true,
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.hasBasicTransforms).toBe(false);
});
});
describe("hadTransforms", () => {
it("should return hadTransforms=false when there are no previous add-ons", async () => {
const { result } = setup({ previousAddOns: [] });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.hadTransforms).toBe(false);
});
it("should return hadTransforms=true when previous add-ons include a self-service transforms product", async () => {
const { result } = setup({
previousAddOns: [{ product_type: "transforms", self_service: true }],
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.hadTransforms).toBe(true);
});
it("should return hadTransforms=false when previous transforms add-on is not self-service", async () => {
const { result } = setup({
previousAddOns: [{ product_type: "transforms", self_service: false }],
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.hadTransforms).toBe(false);
});
it("should return hadTransforms=false when previous add-ons are unrelated", async () => {
const { result } = setup({
previousAddOns: [{ product_type: "metabase-ai", self_service: true }],
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.hadTransforms).toBe(false);
});
});
describe("add-on matching", () => {
it("should not return add-ons when none are available", async () => {
const { result } = setup({
isHosted: true,
hasBasicTransformsAddOn: false,
hasAdvancedTransformsAddOn: false,
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.basicTransformsAddOn).toBeUndefined();
expect(result.current.advancedTransformsAddOn).toBeUndefined();
});
it("should return only basic add-on when advanced is not available", async () => {
const { result } = setup({
isHosted: true,
hasBasicTransformsAddOn: true,
hasAdvancedTransformsAddOn: false,
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.basicTransformsAddOn).toBeDefined();
expect(result.current.basicTransformsAddOn?.default_base_fee).toBe(
BASIC_PRICE,
);
expect(result.current.advancedTransformsAddOn).toBeUndefined();
});
it("should return only advanced add-on when basic is not available", async () => {
const { result } = setup({
isHosted: true,
hasBasicTransformsAddOn: false,
hasAdvancedTransformsAddOn: true,
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.basicTransformsAddOn).toBeUndefined();
expect(result.current.advancedTransformsAddOn).toBeDefined();
expect(result.current.advancedTransformsAddOn?.default_base_fee).toBe(
ADVANCED_PRICE,
);
});
});
});

View File

@@ -1,5 +1,5 @@
import cx from "classnames";
import { useEffect, useState } from "react";
import { useState } from "react";
import { t } from "ttag";
import { LoadingAndErrorWrapper } from "metabase/common/components/LoadingAndErrorWrapper";
@@ -10,7 +10,6 @@ import { LineDecorator } from "metabase/data-studio/upsells/components/LineDecor
import type { BillingPeriod } from "metabase/data-studio/upsells/types";
import { useSelector } from "metabase/lib/redux";
import { getStoreUsers } from "metabase/selectors/store-users";
import { getIsHosted } from "metabase/setup/selectors";
import {
Card,
Center,
@@ -21,7 +20,8 @@ import {
Text,
Title,
} from "metabase/ui";
import { useTransformsBilling } from "metabase-enterprise/transforms/upsells/hooks/useTransformsBilling";
import { useTransformsBilling } from "../../hooks/useTransformsBilling";
import S from "./TransformsUpsellPage.module.css";
import { PricingSummary } from "./components/PricingSummary";
@@ -29,10 +29,12 @@ import { TierSelection, type TransformTier } from "./components/TierSelection";
/**
* Expected scenarios:
* - User with basic transforms: show advanced only, upgrade flow
* - Trial user: show both tiers with radio selection, $0 due today, CTA as "Add to trial"
* - Trial user with no transforms: show both tiers with radio selection, $0 due today, CTA as "Add to trial"
* - EE user with no transforms, no trial: show both tiers with radio selection, $X due today, CTA as "Add to plan"
* - When trial is available for this add-on, show $0 due today, CTA as "Start trial"
*
* Note: this upsell page should only be displayed to cloud customers since OSS and Self-hosted have
* transforms enabled by default.
*/
export function TransformsUpsellPage() {
const bulletPoints = [
@@ -42,7 +44,6 @@ export function TransformsUpsellPage() {
t`If you go over your cap, transforms bill at 0.01 per transform run`,
];
const isHosted = useSelector(getIsHosted);
const { isStoreUser, anyStoreUserEmailAddress } = useSelector(getStoreUsers);
const [selectedTier, setSelectedTier] = useState<TransformTier>("basic");
@@ -52,10 +53,9 @@ export function TransformsUpsellPage() {
basicTransformsAddOn,
billingPeriodMonths,
error,
hasBasicTransforms,
hadTransforms,
isLoading,
isOnTrial,
hadTransforms,
} = useTransformsBilling();
const hasData =
@@ -68,19 +68,7 @@ export function TransformsUpsellPage() {
const advancedTransformsPrice =
advancedTransformsAddOn?.default_base_fee ?? 0;
/**
* Single-column layout is used when the user doesn't have authority to
* purchase the add-on - we just show the information about the feature
* and a note to ask a store admin to enable it for them.
* Single-column layout will be displayed when:
* - Current user is not a store user; or
* - No billing data available (can't show pricing)
*/
const showSingleColumn = (isHosted && !isStoreUser) || !hasData;
useEffect(() => {
setSelectedTier(hasBasicTransforms ? "advanced" : "basic");
}, [hasBasicTransforms]);
const canUserPurchase = hasData && isStoreUser;
if (error || isLoading) {
return (
@@ -113,11 +101,7 @@ export function TransformsUpsellPage() {
availableTrialDays = 0;
}
const rightColumnTitle = getRightColumnTitle(
isOnTrial,
hasBasicTransforms,
availableTrialDays,
);
const rightColumnTitle = getRightColumnTitle(isOnTrial, availableTrialDays);
const selectTierPrice =
selectedTier === "basic" ? basicTransformsPrice : advancedTransformsPrice;
const dueTodayAmount =
@@ -138,7 +122,7 @@ export function TransformsUpsellPage() {
>
<LineDecorator>
<Card
className={cx(S.container, { [S.singleColumn]: showSingleColumn })}
className={cx(S.container, { [S.singleColumn]: !canUserPurchase })}
withBorder
>
<Stack gap="lg" className={S.leftColumn} p="xl">
@@ -150,19 +134,17 @@ export function TransformsUpsellPage() {
{/* eslint-disable-next-line metabase/no-literal-metabase-strings -- This string only shows for admins. */}
{t`Clean up, reshape, and reuse data directly in Metabase. Add transforms to build models your whole team can use.`}
</Text>
{bulletPoints && bulletPoints.length > 0 && (
<Stack gap="lg" py="sm">
{bulletPoints.map((point) => (
<Flex direction="row" gap="sm" key={point}>
<Center w={24} h={24}>
<Icon name="check_filled" size={16} c="text-brand" />
</Center>
<Text c="text-secondary">{point}</Text>
</Flex>
))}
</Stack>
)}
{showSingleColumn && (
<Stack gap="lg" py="sm">
{bulletPoints.map((point) => (
<Flex direction="row" gap="sm" key={point}>
<Center w={24} h={24}>
<Icon name="check_filled" size={16} c="text-brand" />
</Center>
<Text c="text-secondary">{point}</Text>
</Flex>
))}
</Stack>
{!canUserPurchase && (
<Text fw="bold">
{anyStoreUserEmailAddress
? // eslint-disable-next-line metabase/no-literal-metabase-strings -- This string only shows for admins.
@@ -172,7 +154,7 @@ export function TransformsUpsellPage() {
</Text>
)}
</Stack>
{!showSingleColumn && (
{canUserPurchase && (
<>
<Divider orientation="vertical" />
<Stack gap="lg" className={S.rightColumn} p="xl">
@@ -183,7 +165,6 @@ export function TransformsUpsellPage() {
billingPeriod={billingPeriod}
selectedTier={selectedTier}
setSelectedTier={setSelectedTier}
showAdvancedOnly={hasBasicTransforms}
/>
<PricingSummary
dueTodayAmount={dueTodayAmount}
@@ -203,21 +184,15 @@ export function TransformsUpsellPage() {
// Determine the title based on scenario
const getRightColumnTitle = (
isOnTrial: boolean,
hasBasicTransforms: boolean,
availableTrialDays: number,
) => {
switch (true) {
case isOnTrial && hasBasicTransforms:
return t`Add advanced transforms to your trial`;
case isOnTrial:
return t`Add transforms to your trial`;
case availableTrialDays > 0 && hasBasicTransforms:
return t`Start a free ${availableTrialDays}-day trial of Python transforms`;
case availableTrialDays > 0:
return t`Start a free ${availableTrialDays}-day trial of transforms`;
case hasBasicTransforms:
return t`Add advanced transforms to your plan`;
default:
return t`Add transforms to your plan`;
if (isOnTrial) {
return t`Add transforms to your trial`;
}
if (availableTrialDays > 0) {
return t`Start a free ${availableTrialDays}-day trial of transforms`;
}
return t`Add transforms to your plan`;
};

View File

@@ -26,7 +26,7 @@ describe("TransformsUpsellPage", () => {
).not.toBeInTheDocument();
});
it("renders 2-column layout with CTA when instance is cloud hosted and user is a store user", async () => {
it("renders 2-column layout with CTA when user is a store user", async () => {
setup({ isHosted: true, isStoreUser: true });
await waitForLoadingToFinish();
@@ -41,21 +41,6 @@ describe("TransformsUpsellPage", () => {
).toBeInTheDocument();
});
it("renders 2-column layout with CTA when instance is not cloud hosted", async () => {
setup({ isHosted: false, isStoreUser: true });
await waitForLoadingToFinish();
assertLeftColumnContent();
expect(
screen.getByRole("heading", { name: "Add transforms to your plan" }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Confirm purchase" }),
).toBeInTheDocument();
});
it("shows both transforms tiers and updates price when switching", async () => {
setup({ isHosted: true, isStoreUser: true });
@@ -72,27 +57,6 @@ describe("TransformsUpsellPage", () => {
);
});
it("shows only advanced (python) tier when they already have basic transforms", async () => {
setup({ isHosted: true, isStoreUser: true, hasBasicTransforms: true });
await waitForLoadingToFinish();
expect(screen.queryByText(/SQL only/)).not.toBeInTheDocument();
expect(screen.getByText(/SQL \+ Python/)).toBeInTheDocument();
expect(screen.getByTestId("due-today-amount")).toHaveTextContent(
`$${transformsAdvancedPrice}`,
);
});
it("shows both transforms tiers when cloud user is on trial", async () => {
setup({ isHosted: true, isStoreUser: true, isOnTrial: true });
await waitForLoadingToFinish();
expect(screen.getByText(/SQL only/)).toBeInTheDocument();
expect(screen.getByText(/SQL \+ Python/)).toBeInTheDocument();
});
it("shows due today as $0 and trial heading when trial is available", async () => {
setup({ isHosted: true, isStoreUser: true, trialDays: 13 });

View File

@@ -9,20 +9,12 @@ import S from "./TierSelection.module.css";
export type TransformTier = "basic" | "advanced";
type TierOption = {
value: TransformTier;
label: string;
price: number;
description?: string;
};
type TierSelectionProps = {
advancedTransformsPrice: number;
basicTransformsPrice: number;
billingPeriod: BillingPeriod;
selectedTier: TransformTier;
setSelectedTier: (tier: TransformTier) => void;
showAdvancedOnly: boolean;
};
export const TierSelection = (props: TierSelectionProps) => {
@@ -31,53 +23,26 @@ export const TierSelection = (props: TierSelectionProps) => {
advancedTransformsPrice,
selectedTier,
setSelectedTier,
showAdvancedOnly,
basicTransformsPrice,
} = props;
const { tierOptions, advancedTierOption } = useMemo(() => {
const advancedTierOption: TierOption = {
value: "advanced",
label: t`SQL + Python`,
price: advancedTransformsPrice,
description: t`Run Python-based transforms alongside SQL to handle more complex logic and data workflows.`,
};
const tierOptions: TierOption[] = [
const tierOptions = useMemo(() => {
return [
{
value: "basic",
label: t`SQL only`,
price: basicTransformsPrice,
},
advancedTierOption,
{
value: "advanced",
label: t`SQL + Python`,
price: advancedTransformsPrice,
description: t`Run Python-based transforms alongside SQL to handle more complex logic and data workflows.`,
},
];
return {
tierOptions,
advancedTierOption,
};
}, [advancedTransformsPrice, basicTransformsPrice]);
const billingPeriodLabel = billingPeriod === "monthly" ? t`month` : t`year`;
if (showAdvancedOnly) {
// Single tier display (upgrade from basic)
return (
<Card withBorder p="md" radius="md">
<Flex direction="column" style={{ flex: 1 }}>
<Group justify="space-between" align="flex-start">
<Text fw="bold">{advancedTierOption.label}</Text>
<Text fw="bold">{`$${advancedTierOption.price} / ${billingPeriodLabel}`}</Text>
</Group>
{advancedTierOption.description && (
<Text size="sm" c="text-secondary" mt="sm">
{advancedTierOption.description}
</Text>
)}
</Flex>
</Card>
);
}
return (
<Radio.Group
value={selectedTier}

View File

@@ -11,7 +11,6 @@ const defaultProps: ComponentProps<typeof TierSelection> = {
advancedTransformsPrice: 250,
selectedTier: "basic",
setSelectedTier,
showAdvancedOnly: false,
basicTransformsPrice: 100,
};
@@ -20,20 +19,6 @@ describe("TierSelection", () => {
setSelectedTier.mockClear();
});
it("shows only the python tier option when showsAdvancedOnly is true", () => {
render(<TierSelection {...defaultProps} showAdvancedOnly />);
expect(screen.getByText(/SQL \+ Python/)).toBeInTheDocument();
expect(screen.queryByText(/SQL only/)).not.toBeInTheDocument();
expect(screen.queryAllByRole("radio")).toHaveLength(0);
});
it("shows both regular and python tiers options when showsAdvancedOnly is false", () => {
render(<TierSelection {...defaultProps} showAdvancedOnly={false} />);
expect(screen.getByText(/SQL \+ Python/)).toBeInTheDocument();
expect(screen.getByText(/SQL only/)).toBeInTheDocument();
expect(screen.getAllByRole("radio")).toHaveLength(2);
});
it("calls setSelectedTier with correct param when a tier is selected", async () => {
render(<TierSelection {...defaultProps} />);

View File

@@ -49,6 +49,10 @@ export function newPythonTransform() {
return `${TRANSFORMS_ROOT_URL}/new/python`;
}
export function pythonTransformsUpsell() {
return `${TRANSFORMS_ROOT_URL}/python-transforms`;
}
export function newTransformFromCard(cardId: CardId) {
return `${TRANSFORMS_ROOT_URL}/new/card/${cardId}`;
}

View File

@@ -2,7 +2,6 @@ import type { ComponentType, Context, ReactNode } from "react";
import { createContext } from "react";
import type { OmniPickerItem } from "metabase/common/components/Pickers";
import type { BillingPeriod } from "metabase/data-studio/upsells/types";
import {
NotFoundPlaceholder,
PluginPlaceholder,
@@ -11,7 +10,6 @@ import type Question from "metabase-lib/v1/Question";
import type {
CheckDependenciesResponse,
GetDependencyGraphRequest,
ICloudAddOnProduct,
PythonTransformSourceDraft,
Transform,
UpdateSnippetRequest,
@@ -23,16 +21,9 @@ export type TransformPickerItem = OmniPickerItem & {
model: "transform";
};
export type TransformPickerProps = {
value: TransformPickerItem | undefined;
onItemSelect: (transform: TransformPickerItem) => void;
};
export type TransformsPlugin = {
isEnabled: boolean;
TransformsUpsellPage: ComponentType;
CloudPurchaseContent: ComponentType<CloudPurchaseContentProps>;
useTransformsBilling: () => TransformsBillingData;
};
export type PythonTransformEditorUiOptions = {
@@ -66,14 +57,9 @@ export type PythonTransformSourceValidationResult = {
errorMessage?: string;
};
export type PythonTransformsUpsellModalProps = {
isOpen: boolean;
onClose: () => void;
};
export type PythonTransformsPlugin = {
isEnabled: boolean;
getPythonLibraryRoutes: () => ReactNode;
getPythonTransformsRoutes: () => ReactNode;
getPythonSourceValidationResult: (
source: PythonTransformSourceDraft,
) => PythonTransformSourceValidationResult;
@@ -152,48 +138,16 @@ function useCheckDependencies<TChange>({
};
}
export type CloudPurchaseContentProps = {
billingPeriod: BillingPeriod;
handleModalClose: VoidFunction;
isTrialFlow: boolean;
onError: VoidFunction;
pythonPrice: number;
};
export type TransformsBillingData = {
error: unknown;
isLoading: boolean;
billingPeriodMonths: number | undefined;
basicTransformsAddOn: ICloudAddOnProduct | undefined;
advancedTransformsAddOn: ICloudAddOnProduct | undefined;
hadTransforms: boolean;
isOnTrial: boolean;
trialEndDate: string | undefined;
hasBasicTransforms: boolean;
};
const getDefaultPluginTransforms = (): TransformsPlugin => ({
isEnabled: true, // transforms are enabled by default in OSS
TransformsUpsellPage: PluginPlaceholder,
CloudPurchaseContent: PluginPlaceholder,
useTransformsBilling: () => ({
error: null,
isLoading: false,
billingPeriodMonths: undefined,
basicTransformsAddOn: undefined,
advancedTransformsAddOn: undefined,
hadTransforms: false,
isOnTrial: false,
trialEndDate: undefined,
hasBasicTransforms: true,
}),
});
export const PLUGIN_TRANSFORMS = getDefaultPluginTransforms();
const getDefaultPluginTransformsPython = (): PythonTransformsPlugin => ({
isEnabled: false,
getPythonLibraryRoutes: () => null,
getPythonTransformsRoutes: () => null,
getPythonSourceValidationResult: () => ({ isValid: true }),
TransformEditor: PluginPlaceholder,
SourceSection: PluginPlaceholder,

View File

@@ -45,7 +45,9 @@ export type StorePaths =
/** redirects to the specific instance storage management page */
| "account/storage"
/** EE, self-hosted upsell that communicates back with the instance */
| "checkout/upgrade/self-hosted";
| "checkout/upgrade/self-hosted"
/** transforms add-ons management page */
| "account/transforms";
const DEFAULT_STORE_URL = "https://store.metabase.com/";

View File

@@ -6,11 +6,9 @@ import { UpsellGem } from "metabase/admin/upsells/components/UpsellGem";
import { useListDatabasesQuery } from "metabase/api";
import { QuestionPickerModal } from "metabase/common/components/Pickers";
import { useHasTokenFeature } from "metabase/common/hooks";
import { getIsHosted } from "metabase/databases/selectors";
import { useDispatch, useSelector } from "metabase/lib/redux";
import * as Urls from "metabase/lib/urls";
import { getShouldShowPythonTransformsUpsell } from "metabase/transforms/selectors";
import { PythonTransformsUpsellModal } from "metabase/transforms/upsells/components/PythonTransformsUpsellModal";
import { Button, Center, Icon, Loader, Menu, Tooltip } from "metabase/ui";
import { trackTransformCreate } from "../../analytics";
@@ -26,14 +24,8 @@ export const CreateTransformMenu = () => {
isCollectionModalOpened,
{ open: openCollectionModal, close: closeCollectionModal },
] = useDisclosure();
const [
isPythonUpsellOpened,
{ open: openPythonUpsell, close: closePythonUpsell },
] = useDisclosure();
const hasPythonTransformsFeature = useHasTokenFeature("transforms-python");
const isHosted = useSelector(getIsHosted);
const shouldShowPythonTransformsUpsell = useSelector(
getShouldShowPythonTransformsUpsell,
);
@@ -42,15 +34,13 @@ export const CreateTransformMenu = () => {
include_analytics: true,
});
const shouldShowPythonScriptOption =
isHosted &&
(shouldShowPythonTransformsUpsell || hasPythonTransformsFeature);
hasPythonTransformsFeature || shouldShowPythonTransformsUpsell;
const handlePythonClick = () => {
dispatch(push(Urls.newPythonTransform())); // Route will show upsell modal if feature is not enabled
if (hasPythonTransformsFeature) {
trackTransformCreate({ creationType: "python" });
dispatch(push(Urls.newPythonTransform()));
} else {
openPythonUpsell();
}
};
@@ -140,11 +130,6 @@ export const CreateTransformMenu = () => {
{isCollectionModalOpened && (
<CreateTransformCollectionModal onClose={closeCollectionModal} />
)}
<PythonTransformsUpsellModal
isOpen={isPythonUpsellOpened}
onClose={closePythonUpsell}
/>
</>
);
};

View File

@@ -1,6 +1,7 @@
import type { Row } from "@tanstack/react-table";
import {
type ComponentProps,
type PropsWithChildren,
useCallback,
useEffect,
useMemo,
@@ -33,6 +34,7 @@ import { PLUGIN_REMOTE_SYNC, PLUGIN_TRANSFORMS_PYTHON } from "metabase/plugins";
import { CreateTransformMenu } from "metabase/transforms/components/CreateTransformMenu";
import { ListEmptyState } from "metabase/transforms/components/ListEmptyState";
import { useTransformPermissions } from "metabase/transforms/hooks/use-transform-permissions";
import { getShouldShowPythonTransformsUpsell } from "metabase/transforms/selectors";
import {
Card,
EntityNameCell,
@@ -94,7 +96,12 @@ const globalFilterFn = (
);
};
export const TransformListPage = ({ location }: WithRouterProps) => {
type TransformListPageProps = WithRouterProps & PropsWithChildren;
export const TransformListPage = ({
children,
location,
}: TransformListPageProps) => {
const { transformsDatabases = [], isLoadingDatabases } =
useTransformPermissions();
const isRemoteSyncReadOnly = useSelector(
@@ -130,12 +137,19 @@ export const TransformListPage = ({ location }: WithRouterProps) => {
const isLoading =
isLoadingCollections || isLoadingTransforms || isLoadingDatabases;
const error = collectionsError ?? transformsError;
const shouldShowPythonTransformsUpsell = useSelector(
getShouldShowPythonTransformsUpsell,
);
const treeData = useMemo(() => {
const data = buildTreeData(collections, transforms);
// Only show Python library item if there's at least one item in the table
// It will trigger upsell if feature isn't enabled
if (data.length > 0 && hasPythonTransformsFeature) {
// It will trigger the upsell modal if the feature isn't enabled.
const shouldShowPythonLibraryRow =
data.length > 0 &&
(hasPythonTransformsFeature || shouldShowPythonTransformsUpsell);
if (shouldShowPythonLibraryRow) {
data.push({
id: "library",
name: t`Python library`,
@@ -151,6 +165,7 @@ export const TransformListPage = ({ location }: WithRouterProps) => {
}, [
collections,
hasPythonTransformsFeature,
shouldShowPythonTransformsUpsell,
transforms,
transformsDatabases.length,
]);
@@ -274,25 +289,18 @@ export const TransformListPage = ({ location }: WithRouterProps) => {
];
}, [hasPythonTransformsFeature]);
const getRowHref = useCallback(
(row: Row<TreeNode>) => {
if (isRowDisabled(row)) {
return null;
}
if (row.original.nodeType === "transform" && row.original.transformId) {
return Urls.transform(row.original.transformId);
}
if (
row.original.nodeType === "library" &&
row.original.url &&
hasPythonTransformsFeature
) {
return row.original.url;
}
const getRowHref = useCallback((row: Row<TreeNode>) => {
if (isRowDisabled(row)) {
return null;
},
[hasPythonTransformsFeature],
);
}
if (row.original.nodeType === "transform" && row.original.transformId) {
return Urls.transform(row.original.transformId);
}
if (row.original.nodeType === "library" && row.original.url) {
return row.original.url;
}
return null;
}, []);
const treeTableInstance = useTreeTableInstance({
data: treeData,
@@ -376,6 +384,7 @@ export const TransformListPage = ({ location }: WithRouterProps) => {
)}
</Card>
</Stack>
{children}
</PageContainer>
);
};

View File

@@ -11,7 +11,6 @@ import { NewJobPage } from "./pages/NewJobPage";
import {
NewCardTransformPage,
NewNativeTransformPage,
NewPythonTransformPage,
NewQueryTransformPage,
} from "./pages/NewTransformPage";
import { RunListPage } from "./pages/RunListPage";
@@ -36,9 +35,6 @@ export function getDataStudioTransformRoutes() {
<Route path="new/query" component={NewQueryTransformPage} />
<Route path="new/native" component={NewNativeTransformPage} />
<Route path="new/card/:cardId" component={NewCardTransformPage} />
{PLUGIN_TRANSFORMS_PYTHON.isEnabled && (
<Route path="new/python" component={NewPythonTransformPage} />
)}
<Route path=":transformId" component={TransformQueryPage} />
<Route path=":transformId/edit" component={TransformQueryPage} />
<Route path=":transformId/run" component={TransformRunPage} />
@@ -51,7 +47,7 @@ export function getDataStudioTransformRoutes() {
<IndexRoute component={PLUGIN_DEPENDENCIES.DependencyGraphPage} />
</Route>
)}
{PLUGIN_TRANSFORMS_PYTHON.getPythonLibraryRoutes()}
{PLUGIN_TRANSFORMS_PYTHON.getPythonTransformsRoutes()}
</Route>
</>
);

View File

@@ -33,8 +33,9 @@ export const getShouldShowTransformsUpsell = createSelector(
);
export const getShouldShowPythonTransformsUpsell = createSelector(
getIsHosted,
(state: State) => getTokenFeature(state, "transforms-python"),
(isHosted, hasPythonTransformsFeature) =>
isHosted && !hasPythonTransformsFeature,
(state: State) => getPlan(getSetting(state, "token-features")),
(hasPythonTransformsFeature, plan) => {
return !hasPythonTransformsFeature && plan !== "oss";
},
);

View File

@@ -1,191 +0,0 @@
import { useDisclosure } from "@mantine/hooks";
import { useCallback, useEffect } from "react";
import { t } from "ttag";
import { trackUpsellViewed } from "metabase/admin/upsells/components/analytics";
import { LoadingAndErrorWrapper } from "metabase/common/components/LoadingAndErrorWrapper";
import type { BillingPeriod } from "metabase/data-studio/upsells/types";
import { useSelector } from "metabase/lib/redux";
import { PLUGIN_TRANSFORMS } from "metabase/plugins";
import { getStoreUsers } from "metabase/selectors/store-users";
import { getIsHosted } from "metabase/setup/selectors";
import {
Center,
Divider,
Flex,
Icon,
Modal,
Stack,
Text,
Title,
} from "metabase/ui";
import { SelfHostedContent } from "./SelfHostedContent";
import { CAMPAIGN, LOCATION } from "./constants";
type PythonTransformsUpsellModalProps = {
isOpen: boolean;
onClose: () => void;
};
export function PythonTransformsUpsellModal({
isOpen,
onClose,
}: PythonTransformsUpsellModalProps) {
const bulletPoints = [
t`Use Python to handle complex logic that's awkward or brittle in SQL`,
t`Reuse shared Python logic instead of copying SQL patterns`,
t`Unblock advanced use cases without pushing work into another tool`,
t`Put pandas to work for data analysis and manipulation`,
];
const isHosted = useSelector(getIsHosted);
const { isStoreUser, anyStoreUserEmailAddress } = useSelector(getStoreUsers);
// Track if we should force the modal open after a purchase error
const [
forceModalToOpen,
{ open: enableForceModalToOpen, close: disableForceModalToOpen },
] = useDisclosure(false);
// Reset forceOpenAfterError when parent reopens the modal
useEffect(() => {
if (isOpen) {
disableForceModalToOpen();
}
}, [isOpen, disableForceModalToOpen]);
// Modal is open if parent says so OR if we're forcing it open after an error
const modalOpen = isOpen || forceModalToOpen;
const {
isLoading,
error,
billingPeriodMonths,
advancedTransformsAddOn,
isOnTrial,
} = PLUGIN_TRANSFORMS.useTransformsBilling();
useEffect(() => {
if (isOpen) {
trackUpsellViewed({ location: LOCATION, campaign: CAMPAIGN });
}
}, [isOpen]);
const hasData = billingPeriodMonths !== undefined && advancedTransformsAddOn;
const billingPeriod: BillingPeriod =
billingPeriodMonths === 1 ? "monthly" : "yearly";
const pythonPrice = advancedTransformsAddOn?.default_base_fee ?? 0;
const isTrialFlow = isOnTrial;
const showSingleColumn = isHosted && !isStoreUser;
const handleModalClose = useCallback(() => {
disableForceModalToOpen();
onClose();
}, [onClose, disableForceModalToOpen]);
const renderNonStoreUserContent = () => (
<Text fw="bold">
{anyStoreUserEmailAddress
? // eslint-disable-next-line metabase/no-literal-metabase-strings -- This string only shows for admins.
t`Please ask a Metabase Store Admin (${anyStoreUserEmailAddress}) to enable this for you.`
: // eslint-disable-next-line metabase/no-literal-metabase-strings -- This string only shows for admins.
t`Please ask a Metabase Store Admin to enable this for you.`}
</Text>
);
const renderCloudPurchaseContent = () => {
if (error) {
return (
<Center py="xl">
<LoadingAndErrorWrapper
loading={false}
error={t`Error fetching information about available add-ons.`}
/>
</Center>
);
}
if (isLoading) {
return (
<Center py="xl">
<LoadingAndErrorWrapper loading={true} error={null} />
</Center>
);
}
// If no billing data available (e.g., product not available for this plan),
// fall back to showing the upsell CTA
if (!hasData) {
return;
}
return (
<PLUGIN_TRANSFORMS.CloudPurchaseContent
billingPeriod={billingPeriod}
handleModalClose={handleModalClose}
isTrialFlow={isTrialFlow}
onError={enableForceModalToOpen}
pythonPrice={pythonPrice}
/>
);
};
const renderRightColumnContent = () => {
if (!isHosted) {
return <SelfHostedContent handleModalClose={handleModalClose} />;
}
return renderCloudPurchaseContent();
};
return (
<Modal.Root
opened={modalOpen}
onClose={handleModalClose}
size={showSingleColumn ? "md" : "xl"}
>
<Modal.Overlay />
<Modal.Content>
<Modal.Body p={0}>
<Flex>
{/* Left Column - Info */}
<Stack gap="lg" p="xl" flex={1}>
<Title
order={2}
>{t`Go beyond SQL with advanced transforms`}</Title>
<Text c="text-secondary">
{t`Run Python-based transforms alongside SQL to handle more complex logic and data workflows.`}
</Text>
<Stack gap="lg" py="sm">
{bulletPoints.map((point) => (
<Flex direction="row" gap="sm" key={point}>
<Center w={24} h={24}>
<Icon name="check_filled" size={16} c="text-brand" />
</Center>
<Text c="text-secondary">{point}</Text>
</Flex>
))}
</Stack>
{showSingleColumn && renderNonStoreUserContent()}
</Stack>
{/* Right Column - Purchase Card (hidden for non-store users) */}
{!showSingleColumn && (
<>
<Divider orientation="vertical" />
<Stack bg="background-secondary" flex={1} gap="lg" p="xl">
<Title
order={3}
>{t`Add advanced transforms to your plan`}</Title>
{renderRightColumnContent()}
</Stack>
</>
)}
</Flex>
</Modal.Body>
</Modal.Content>
</Modal.Root>
);
}

View File

@@ -1,74 +0,0 @@
import { t } from "ttag";
import { useUpgradeAction } from "metabase/admin/upsells/components/UpgradeModal";
import { UpsellCta } from "metabase/admin/upsells/components/UpsellCta";
import { trackUpsellClicked } from "metabase/admin/upsells/components/analytics";
import { useUpsellLink } from "metabase/admin/upsells/components/use-upsell-link";
import { DATA_STUDIO_UPGRADE_URL } from "metabase/admin/upsells/constants";
import { getPlan } from "metabase/common/utils/plan";
import { useSelector } from "metabase/lib/redux";
import { PLUGIN_ADMIN_SETTINGS } from "metabase/plugins";
import { getSetting } from "metabase/selectors/settings";
import { Flex } from "metabase/ui";
import { CAMPAIGN, LOCATION, UPGRADE_URL } from "./constants";
type SelfHostedContentProps = {
handleModalClose: VoidFunction;
};
export const SelfHostedContent = (props: SelfHostedContentProps) => {
const { handleModalClose } = props;
const plan = useSelector((state) =>
getPlan(getSetting(state, "token-features")),
);
const upsellUrl = useUpsellLink({
url: UPGRADE_URL,
campaign: CAMPAIGN,
location: LOCATION,
});
const { triggerUpsellFlow } = PLUGIN_ADMIN_SETTINGS.useUpsellFlow({
campaign: CAMPAIGN,
location: LOCATION,
});
const { onClick: upgradeOnClick, url: upgradeUrl } = useUpgradeAction({
url: DATA_STUDIO_UPGRADE_URL,
campaign: CAMPAIGN,
location: LOCATION,
});
const handleClickStarter = () => {
triggerUpsellFlow?.();
handleModalClose();
};
return (
<Flex justify="flex-end">
{plan === "oss" ? (
<UpsellCta
onClick={upgradeOnClick}
url={upgradeUrl}
internalLink={undefined}
buttonText={t`Get Python transforms`}
onClickCapture={() =>
trackUpsellClicked({ location: LOCATION, campaign: CAMPAIGN })
}
size="large"
/>
) : (
<UpsellCta
onClick={handleClickStarter}
url={upsellUrl}
internalLink={undefined}
buttonText={t`Get Python transforms`}
onClickCapture={() =>
trackUpsellClicked({ location: LOCATION, campaign: CAMPAIGN })
}
size="large"
/>
)}
</Flex>
);
};

View File

@@ -1,25 +0,0 @@
import { screen } from "__support__/ui";
import { setup } from "./setup";
describe("PythonTransformsUpsellModal - OSS", () => {
beforeEach(() => {
jest.clearAllMocks();
});
afterEach(() => {
jest.restoreAllMocks();
});
it("shows upsell CTA when instance is self-hosted and clicking it closes modal", async () => {
setup({ isHosted: false, isStoreUser: true });
const upgradeLink = (await screen.findByRole("link", {
name: /Get Python transforms/,
})) as HTMLAnchorElement;
expect(upgradeLink.href).toMatch(
"https://www.metabase.com/upgrade/data-studio",
);
});
});

View File

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

View File

@@ -10,6 +10,7 @@ export function setupBillingEndpoints({
transformsBasicPrice = 100,
transformsAdvancedPrice = 250,
trialDays,
previousAddOns = [],
}: {
billingPeriodMonths?: number;
hasBasicTransformsAddOn?: boolean;
@@ -17,6 +18,10 @@ export function setupBillingEndpoints({
transformsBasicPrice?: number;
transformsAdvancedPrice?: number;
trialDays?: number;
previousAddOns?: Array<{
product_type: string;
self_service: boolean;
}>;
} = {}) {
const cloudAddOns = [
...(hasBasicTransformsAddOn
@@ -74,7 +79,7 @@ export function setupBillingEndpoints({
version: "0",
data: {
billing_period_months: billingPeriodMonths,
previous_add_ons: [],
previous_add_ons: previousAddOns,
},
});
}