Compare commits
15 Commits
python-run
...
transforms
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e349e054c | ||
|
|
eb27df1788 | ||
|
|
56f6e22b16 | ||
|
|
96d40e3631 | ||
|
|
4809bf0810 | ||
|
|
4025a2562e | ||
|
|
5f57979cee | ||
|
|
ef93a556e7 | ||
|
|
aa813ad717 | ||
|
|
b5fab190b6 | ||
|
|
ff6d28dbc3 | ||
|
|
31f407bb12 | ||
|
|
548e85d9d9 | ||
|
|
3ef0b9b32b | ||
|
|
a2689b8519 |
@@ -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",
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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`;
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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} />);
|
||||
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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/";
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export { PythonTransformsUpsellModal } from "./PythonTransformsUpsellModal";
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user