From 015355da96a1f3f9ef3eac9dfcc1bac131a818a8 Mon Sep 17 00:00:00 2001 From: "metabase-bot[bot]" <109303359+metabase-bot[bot]@users.noreply.github.com> Date: Wed, 27 Mar 2024 12:31:46 -0500 Subject: [PATCH] Show more billing info on license and billing settings page (#38497) (#40683) Co-authored-by: Sloan Sparger <sloansparger@users.noreply.github.com> --- e2e/support/commands/user/createUser.js | 5 +- .../settings/license-and-billing.cy.spec.js | 102 +++++++++ .../BillingInfo/BillingGoToStore.tsx | 21 ++ .../BillingInfo/BillingInfo.styled.tsx | 53 +++++ .../components/BillingInfo/BillingInfo.tsx | 38 ++++ .../BillingInfo/BillingInfoError.tsx | 28 +++ .../BillingInfoNotStoreManaged.tsx | 16 ++ .../BillingInfo/BillingInfoTable.tsx | 133 ++++++++++++ .../license/components/BillingInfo/index.tsx | 1 + .../license/components/BillingInfo/utils.ts | 60 ++++++ .../LicenseAndBillingSettings.tsx | 85 ++++---- .../LicenseAndBillingSettings.unit.spec.tsx | 197 ++++++++++++++++-- .../StillNeedHelp/StillNeedHelp.tsx | 31 +++ .../license/components/StillNeedHelp/index.ts | 1 + .../metabase-enterprise/license/services.ts | 5 - .../settings/hooks/use-billing-info.ts | 77 +++++++ .../settings/hooks/use-license.ts | 10 +- frontend/src/metabase-types/api/store.ts | 34 +++ .../LicenseInput/LicenseInput.styled.tsx | 3 +- .../SettingsLicense.styled.tsx | 2 +- frontend/src/metabase/services.js | 1 + 21 files changed, 830 insertions(+), 73 deletions(-) create mode 100644 e2e/test/scenarios/admin/settings/license-and-billing.cy.spec.js create mode 100644 enterprise/frontend/src/metabase-enterprise/license/components/BillingInfo/BillingGoToStore.tsx create mode 100644 enterprise/frontend/src/metabase-enterprise/license/components/BillingInfo/BillingInfo.styled.tsx create mode 100644 enterprise/frontend/src/metabase-enterprise/license/components/BillingInfo/BillingInfo.tsx create mode 100644 enterprise/frontend/src/metabase-enterprise/license/components/BillingInfo/BillingInfoError.tsx create mode 100644 enterprise/frontend/src/metabase-enterprise/license/components/BillingInfo/BillingInfoNotStoreManaged.tsx create mode 100644 enterprise/frontend/src/metabase-enterprise/license/components/BillingInfo/BillingInfoTable.tsx create mode 100644 enterprise/frontend/src/metabase-enterprise/license/components/BillingInfo/index.tsx create mode 100644 enterprise/frontend/src/metabase-enterprise/license/components/BillingInfo/utils.ts create mode 100644 enterprise/frontend/src/metabase-enterprise/license/components/StillNeedHelp/StillNeedHelp.tsx create mode 100644 enterprise/frontend/src/metabase-enterprise/license/components/StillNeedHelp/index.ts delete mode 100644 enterprise/frontend/src/metabase-enterprise/license/services.ts create mode 100644 enterprise/frontend/src/metabase-enterprise/settings/hooks/use-billing-info.ts rename {frontend/src/metabase/admin => enterprise/frontend/src/metabase-enterprise}/settings/hooks/use-license.ts (93%) diff --git a/e2e/support/commands/user/createUser.js b/e2e/support/commands/user/createUser.js index 14d52b2ea61..60474b1e848 100644 --- a/e2e/support/commands/user/createUser.js +++ b/e2e/support/commands/user/createUser.js @@ -1,9 +1,10 @@ import { USERS } from "e2e/support/cypress_data"; Cypress.Commands.add("createUserFromRawData", user => { - return cy.request("POST", "/api/user", user).then(({ body }) => { + return cy.request("POST", "/api/user", user).then(({ body: user }) => { // Dismiss `it's ok to play around` modal for the created user - cy.request("PUT", `/api/user/${body.id}/modal/qbnewb`, {}); + cy.request("PUT", `/api/user/${user.id}/modal/qbnewb`, {}); + return Promise.resolve(user); }); }); diff --git a/e2e/test/scenarios/admin/settings/license-and-billing.cy.spec.js b/e2e/test/scenarios/admin/settings/license-and-billing.cy.spec.js new file mode 100644 index 00000000000..e78c032f2d2 --- /dev/null +++ b/e2e/test/scenarios/admin/settings/license-and-billing.cy.spec.js @@ -0,0 +1,102 @@ +import { restore, describeEE, setTokenFeatures } from "e2e/support/helpers"; + +const HOSTING_FEATURE_KEY = "hosting"; +const STORE_MANAGED_FEATURE_KEY = "metabase-store-managed"; +const NO_UPSELL_FEATURE_HEY = "no-upsell"; + +// mocks data the will be returned by enterprise useLicense hook +const mockBillingTokenFeatures = features => { + return cy.intercept("GET", "/api/premium-features/token/status", { + "valid-thru": "2099-12-31T12:00:00", + valid: true, + trial: false, + features, + status: "something", + }); +}; + +describe("scenarios > admin > license and billing", () => { + beforeEach(() => { + restore(); + cy.signInAsAdmin(); + }); + + describeEE("store info", () => { + it("should show the user a link to the store for an unlincensed enterprise instance", () => { + cy.visit("/admin/settings/license"); + cy.findByTestId("license-and-billing-content") + .findByText("Go to the Metabase Store") + .should("have.prop", "tagName", "A"); + }); + + it("should show the user store info for an self-hosted instance managed by the store", () => { + setTokenFeatures("all"); + mockBillingTokenFeatures([ + STORE_MANAGED_FEATURE_KEY, + NO_UPSELL_FEATURE_HEY, + ]); + + const harborMasterConnectedAccount = { + email: "ci-admins@metabase.com", + first_name: "CI", + last_name: "Admins", + password: "test-password-123", + }; + + // create an admin user who is also connected to our test harbormaster account + cy.request("GET", "/api/permissions/group") + .then(({ body: groups }) => { + const adminGroup = groups.find(g => g.name === "Administrators"); + return cy + .createUserFromRawData(harborMasterConnectedAccount) + .then(user => Promise.resolve([adminGroup.id, user])); + }) + .then(([adminGroupId, user]) => { + const data = { user_id: user.id, group_id: adminGroupId }; + return cy + .request("POST", "/api/permissions/membership", data) + .then(() => Promise.resolve(user)); + }) + .then(user => { + cy.signOut(); // stop being normal admin user and be store connected admin user + return cy.request("POST", "/api/session", { + username: user.email, + password: harborMasterConnectedAccount.password, + }); + }) + .then(() => { + // core test + cy.visit("/admin/settings/license"); + cy.findByTestId("billing-info-key-plan").should("exist"); + cy.findByTestId("license-input").should("exist"); + }); + }); + + it("should not show license input for cloud-hosted instances", () => { + setTokenFeatures("all"); + mockBillingTokenFeatures([ + STORE_MANAGED_FEATURE_KEY, + NO_UPSELL_FEATURE_HEY, + HOSTING_FEATURE_KEY, + ]); + cy.visit("/admin/settings/license"); + cy.findByTestId("license-input").should("not.exist"); + }); + + it("should render an error if something fails when fetching billing info", () => { + setTokenFeatures("all"); + mockBillingTokenFeatures([ + STORE_MANAGED_FEATURE_KEY, + NO_UPSELL_FEATURE_HEY, + ]); + // force an error + cy.intercept("GET", "/api/ee/billing", req => { + req.reply({ statusCode: 500 }); + }); + cy.visit("/admin/settings/license"); + cy.findByTestId("license-and-billing-content") + .findByText(/An error occurred/) + .should("exist"); + }); + }); +}); diff --git a/enterprise/frontend/src/metabase-enterprise/license/components/BillingInfo/BillingGoToStore.tsx b/enterprise/frontend/src/metabase-enterprise/license/components/BillingInfo/BillingGoToStore.tsx new file mode 100644 index 00000000000..544b19a4c19 --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/license/components/BillingInfo/BillingGoToStore.tsx @@ -0,0 +1,21 @@ +import { t } from "ttag"; + +import { SectionHeader } from "metabase/admin/settings/components/SettingsLicense"; +import { getStoreUrl } from "metabase/selectors/settings"; +import { Text } from "metabase/ui"; + +import { StoreButtonLink } from "./BillingInfo.styled"; + +export const BillingGoToStore = () => { + const url = getStoreUrl(); + + return ( + <> + <SectionHeader>{t`Billing`}</SectionHeader> + <Text color="text-md">{t`Manage your Cloud account, including billing preferences, in your Metabase Store account.`}</Text> + <StoreButtonLink href={url}> + {t`Go to the Metabase Store`} + </StoreButtonLink> + </> + ); +}; diff --git a/enterprise/frontend/src/metabase-enterprise/license/components/BillingInfo/BillingInfo.styled.tsx b/enterprise/frontend/src/metabase-enterprise/license/components/BillingInfo/BillingInfo.styled.tsx new file mode 100644 index 00000000000..9503c243e67 --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/license/components/BillingInfo/BillingInfo.styled.tsx @@ -0,0 +1,53 @@ +import { css } from "@emotion/react"; +import styled from "@emotion/styled"; + +import Card from "metabase/components/Card"; +import ExternalLink from "metabase/core/components/ExternalLink"; +import Link from "metabase/core/components/Link"; +import { color } from "metabase/lib/colors"; +import { Icon } from "metabase/ui"; + +export const BillingInfoCard = styled(Card)` + margin-top: 1rem; +`; + +export const BillingInfoRowContainer = styled.div<{ extraPadding?: boolean }>` + display: flex; + justify-content: space-between; + padding: ${({ extraPadding }) => (extraPadding ? `1.5rem` : `0.5rem`)} 1rem; + align-items: center; + + &:not(:last-child) { + border-bottom: 1px solid ${color("bg-medium")}; + } +`; + +const linkStyles = css` + display: inline-flex; + align-items: center; + color: ${color("brand")}; +`; + +export const BillingInternalLink = styled(Link)(linkStyles); + +export const BillingExternalLink = styled(ExternalLink)(linkStyles); + +export const BillingExternalLinkIcon = styled(Icon)` + margin-left: 0.25rem; +`; + +export const StoreButtonLink = styled(ExternalLink)` + display: inline-flex; + background-color: ${color("brand")}; + color: ${color("text-white")}; + align-items: center; + font-weight: bold; + padding: 0.75rem 1rem; + margin-top: 1rem; + border-radius: 6px; + + &:hover { + opacity: 0.88; + transition: all 200ms linear; + } +`; diff --git a/enterprise/frontend/src/metabase-enterprise/license/components/BillingInfo/BillingInfo.tsx b/enterprise/frontend/src/metabase-enterprise/license/components/BillingInfo/BillingInfo.tsx new file mode 100644 index 00000000000..042e8938f16 --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/license/components/BillingInfo/BillingInfo.tsx @@ -0,0 +1,38 @@ +import type { BillingInfo as IBillingInfo } from "metabase-types/api"; + +import { BillingGoToStore } from "./BillingGoToStore"; +import { BillingInfoError } from "./BillingInfoError"; +import { BillingInfoNotStoreManaged } from "./BillingInfoNotStoreManaged"; +import { BillingInfoTable } from "./BillingInfoTable"; + +interface BillingInfoProps { + isStoreManagedBilling: boolean; + billingInfo?: IBillingInfo | null; + hasToken: boolean; + error: boolean; +} + +export function BillingInfo({ + isStoreManagedBilling, + billingInfo, + hasToken, + error, +}: BillingInfoProps) { + if (error) { + return <BillingInfoError />; + } + + if (!hasToken) { + return <BillingGoToStore />; + } + + if (!isStoreManagedBilling) { + return <BillingInfoNotStoreManaged />; + } + + if (!billingInfo || !billingInfo.content || !billingInfo.content.length) { + return <BillingGoToStore />; + } + + return <BillingInfoTable billingInfo={billingInfo} />; +} diff --git a/enterprise/frontend/src/metabase-enterprise/license/components/BillingInfo/BillingInfoError.tsx b/enterprise/frontend/src/metabase-enterprise/license/components/BillingInfo/BillingInfoError.tsx new file mode 100644 index 00000000000..8c215cbea87 --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/license/components/BillingInfo/BillingInfoError.tsx @@ -0,0 +1,28 @@ +import { t } from "ttag"; + +import { SectionHeader } from "metabase/admin/settings/components/SettingsLicense"; +import Alert from "metabase/core/components/Alert"; +import { Text, Anchor, Box } from "metabase/ui"; + +export const BillingInfoError = () => { + return ( + <> + <SectionHeader>{t`Billing`}</SectionHeader> + <Box mt="1rem" data-testid="billing-info-error"> + <Alert variant="error" icon="warning"> + <Text color="text-medium"> + {t`An error occurred while fetching information about your billing.`} + <br /> + <strong>{t`Need help?`}</strong>{" "} + {t`You can ask for billing help at `} + <strong> + <Anchor href="mailto:billing@metabase.com"> + billing@metabase.com + </Anchor> + </strong> + </Text> + </Alert> + </Box> + </> + ); +}; diff --git a/enterprise/frontend/src/metabase-enterprise/license/components/BillingInfo/BillingInfoNotStoreManaged.tsx b/enterprise/frontend/src/metabase-enterprise/license/components/BillingInfo/BillingInfoNotStoreManaged.tsx new file mode 100644 index 00000000000..63f181b96dd --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/license/components/BillingInfo/BillingInfoNotStoreManaged.tsx @@ -0,0 +1,16 @@ +import { t } from "ttag"; + +import { SectionHeader } from "metabase/admin/settings/components/SettingsLicense"; +import { Text, Anchor } from "metabase/ui"; + +export const BillingInfoNotStoreManaged = () => { + return ( + <> + <SectionHeader>{t`Billing`}</SectionHeader> + <Text color="text-medium"> + {t`To manage your billing preferences, please email `} + <Anchor href="mailto:billing@metabase.com">billing@metabase.com</Anchor> + </Text> + </> + ); +}; diff --git a/enterprise/frontend/src/metabase-enterprise/license/components/BillingInfo/BillingInfoTable.tsx b/enterprise/frontend/src/metabase-enterprise/license/components/BillingInfo/BillingInfoTable.tsx new file mode 100644 index 00000000000..6d6f34dba33 --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/license/components/BillingInfo/BillingInfoTable.tsx @@ -0,0 +1,133 @@ +import { t } from "ttag"; + +import ErrorBoundary from "metabase/ErrorBoundary"; +import { SectionHeader } from "metabase/admin/settings/components/SettingsLicense"; +import { Text } from "metabase/ui"; +import type { BillingInfoLineItem, BillingInfo } from "metabase-types/api"; + +import { StillNeedHelp } from "../StillNeedHelp"; + +import { + BillingInfoCard, + BillingInfoRowContainer, + BillingInternalLink, + BillingExternalLink, + BillingExternalLinkIcon, +} from "./BillingInfo.styled"; +import { + getBillingInfoId, + isSupportedLineItem, + formatBillingValue, + isUnsupportedInternalLink, + internalLinkMap, +} from "./utils"; + +const BillingInfoValue = ({ + lineItem, + ...props +}: { + lineItem: BillingInfoLineItem; +}) => { + const formattedValue = formatBillingValue(lineItem); + + if (lineItem.display === "value") { + return ( + <Text fw="bold" color="currentColor" {...props}> + {formattedValue} + </Text> + ); + } + + if (lineItem.display === "internal-link") { + return ( + <BillingInternalLink + to={internalLinkMap[lineItem.link]} + data-testid="test-link" + {...props} + > + <Text fw="bold" color="currentColor"> + {formattedValue} + </Text> + </BillingInternalLink> + ); + } + + if (lineItem.display === "external-link") { + return ( + <BillingExternalLink href={lineItem.link} {...props}> + <Text fw="bold" color="currentColor"> + {formattedValue} + </Text> + <BillingExternalLinkIcon size="16" name="external" /> + </BillingExternalLink> + ); + } + + // do not display items with unknown display or value types + return null; +}; + +function BillingInfoRow({ + lineItem, + extraPadding, + ...props +}: { + lineItem: BillingInfoLineItem; + extraPadding: boolean; +}) { + // avoid rendering the entire row if we can't format/display the value + if (!isSupportedLineItem(lineItem)) { + return null; + } + + // avoid rendering internal links where we do not have the ability + // to link the user to the appropriate page due to instance being + // an older version of MB + if (isUnsupportedInternalLink(lineItem)) { + return null; + } + + const id = getBillingInfoId(lineItem); + + // ErrorBoundary serves as an extra guard in case billingInfo schema + // changes in a way the current application doesn't expect + return ( + <ErrorBoundary errorComponent={() => null}> + <BillingInfoRowContainer extraPadding={extraPadding} {...props}> + <Text + color="text-md" + maw="15rem" + data-testid={`billing-info-key-${id}`} + > + {lineItem.name} + </Text> + <BillingInfoValue + lineItem={lineItem} + data-testid={`billing-info-value-${id}`} + /> + </BillingInfoRowContainer> + </ErrorBoundary> + ); +} + +export const BillingInfoTable = ({ + billingInfo, +}: { + billingInfo: BillingInfo; +}) => { + return ( + <> + <SectionHeader>{t`Billing`}</SectionHeader> + <BillingInfoCard flat> + {billingInfo.content?.map((lineItem, index, arr) => ( + <BillingInfoRow + key={lineItem.name} + lineItem={lineItem} + extraPadding={arr.length === index + 1} + /> + ))} + </BillingInfoCard> + <StillNeedHelp /> + </> + ); +}; diff --git a/enterprise/frontend/src/metabase-enterprise/license/components/BillingInfo/index.tsx b/enterprise/frontend/src/metabase-enterprise/license/components/BillingInfo/index.tsx new file mode 100644 index 00000000000..6b33b8e93e1 --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/license/components/BillingInfo/index.tsx @@ -0,0 +1 @@ +export { BillingInfo } from "./BillingInfo"; diff --git a/enterprise/frontend/src/metabase-enterprise/license/components/BillingInfo/utils.ts b/enterprise/frontend/src/metabase-enterprise/license/components/BillingInfo/utils.ts new file mode 100644 index 00000000000..787c318d149 --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/license/components/BillingInfo/utils.ts @@ -0,0 +1,60 @@ +import { formatNumber, formatDateTimeWithUnit } from "metabase/lib/formatting"; +import type { BillingInfoLineItem } from "metabase-types/api"; +import { + supportedFormatTypes, + supportedDisplayTypes, +} from "metabase-types/api"; + +const supportedFormatTypesSet = new Set<string>(supportedFormatTypes); +const supportedDisplayTypesSet = new Set<string | undefined>( + supportedDisplayTypes, +); + +export const isSupportedLineItem = (lineItem: BillingInfoLineItem) => { + return ( + supportedFormatTypesSet.has(lineItem.format) && + supportedDisplayTypesSet.has(lineItem.display) + ); +}; + +export const formatBillingValue = (lineItem: BillingInfoLineItem): string => { + switch (lineItem.format) { + case "string": + return lineItem.value; + case "integer": + return formatNumber(lineItem.value); + case "float": + return formatNumber(lineItem.value, { + minimumFractionDigits: lineItem.precision, + maximumFractionDigits: lineItem.precision, + }); + case "datetime": { + const dow = formatDateTimeWithUnit(lineItem.value, "day-of-week"); + const day = formatDateTimeWithUnit(lineItem.value, "day"); + return `${dow}, ${day}`; + } + case "currency": + return formatNumber(lineItem.value, { + currency: lineItem.currency, + number_style: "currency", + }); + default: { + const _exhaustiveCheck: never = lineItem; + return ""; + } + } +}; + +export const internalLinkMap: Record<string, string> = { + "user-list": "/admin/people", +}; + +export const isUnsupportedInternalLink = (lineItem: BillingInfoLineItem) => { + return lineItem.display === "internal-link" + ? !internalLinkMap[lineItem.link] + : false; +}; + +export const getBillingInfoId = (lineItem: BillingInfoLineItem) => { + return lineItem.name.toLowerCase().replaceAll(" ", "-"); +}; diff --git a/enterprise/frontend/src/metabase-enterprise/license/components/LicenseAndBillingSettings/LicenseAndBillingSettings.tsx b/enterprise/frontend/src/metabase-enterprise/license/components/LicenseAndBillingSettings/LicenseAndBillingSettings.tsx index a57cbe581c3..0b433eee684 100644 --- a/enterprise/frontend/src/metabase-enterprise/license/components/LicenseAndBillingSettings/LicenseAndBillingSettings.tsx +++ b/enterprise/frontend/src/metabase-enterprise/license/components/LicenseAndBillingSettings/LicenseAndBillingSettings.tsx @@ -11,17 +11,18 @@ import { SettingsLicenseContainer, } from "metabase/admin/settings/components/SettingsLicense"; import { ExplorePlansIllustration } from "metabase/admin/settings/components/SettingsLicense/ExplorePlansIllustration"; -import type { TokenStatus } from "metabase/admin/settings/hooks/use-license"; -import { useLicense } from "metabase/admin/settings/hooks/use-license"; import LoadingSpinner from "metabase/components/LoadingSpinner"; import ExternalLink from "metabase/core/components/ExternalLink"; -import MetabaseSettings from "metabase/lib/settings"; import { getUpgradeUrl } from "metabase/selectors/settings"; -import { Text, Anchor } from "metabase/ui"; import { showLicenseAcceptedToast } from "metabase-enterprise/license/actions"; +import { useBillingInfo } from "metabase-enterprise/settings/hooks/use-billing-info"; +import type { TokenStatus } from "metabase-enterprise/settings/hooks/use-license"; +import { useLicense } from "metabase-enterprise/settings/hooks/use-license"; import type { SettingDefinition } from "metabase-types/api"; import type { State } from "metabase-types/store"; +import { BillingInfo } from "../BillingInfo"; + const HOSTING_FEATURE_KEY = "hosting"; const STORE_MANAGED_FEATURE_KEY = "metabase-store-managed"; const NO_UPSELL_FEATURE_HEY = "no-upsell"; @@ -78,9 +79,29 @@ const LicenseAndBillingSettings = ({ setting => setting.key === "premium-embedding-token", ) ?? {}; - const { isLoading, error, tokenStatus, updateToken, isUpdating } = useLicense( - showLicenseAcceptedToast, - ); + const { + loading: licenseLoading, + error: licenseError, + tokenStatus, + updateToken, + isUpdating, + } = useLicense(showLicenseAcceptedToast); + + const isInvalidToken = + !!licenseError || (tokenStatus != null && !tokenStatus.isValid); + + const isStoreManagedBilling = + tokenStatus?.features?.has(STORE_MANAGED_FEATURE_KEY) ?? false; + const shouldFetchBillingInfo = + !licenseLoading && !isInvalidToken && isStoreManagedBilling; + + const { + loading: billingLoading, + error: billingError, + billingInfo, + } = useBillingInfo(shouldFetchBillingInfo); + + const isLoading = licenseLoading || billingLoading; if (isLoading) { return ( @@ -92,48 +113,22 @@ const LicenseAndBillingSettings = ({ ); } - const isInvalid = !!error || (tokenStatus != null && !tokenStatus.isValid); - const description = getDescription(tokenStatus, !!token); + const hasToken = Boolean(!!token || is_env_setting); + const description = getDescription(tokenStatus, hasToken); - const isStoreManagedBilling = tokenStatus?.features?.includes( - STORE_MANAGED_FEATURE_KEY, - ); const shouldShowLicenseInput = - !tokenStatus?.features?.includes(HOSTING_FEATURE_KEY); + !tokenStatus?.features?.has(HOSTING_FEATURE_KEY); - const shouldUpsell = !tokenStatus?.features?.includes(NO_UPSELL_FEATURE_HEY); + const shouldUpsell = !tokenStatus?.features?.has(NO_UPSELL_FEATURE_HEY); return ( <SettingsLicenseContainer data-testid="license-and-billing-content"> - <> - <SectionHeader>{t`Billing`}</SectionHeader> - - {isStoreManagedBilling && ( - <> - <SectionDescription> - {t`Manage your Cloud account, including billing preferences, in your Metabase Store account.`} - </SectionDescription> - - <ExternalLink - href={MetabaseSettings.storeUrl()} - className="Button Button--primary" - > - {t`Go to the Metabase Store`} - </ExternalLink> - </> - )} - - {!isStoreManagedBilling && ( - <> - <Text color="text-medium"> - {t`To manage your billing preferences, please email `} - <Anchor href="mailto:billing@metabase.com"> - billing@metabase.com - </Anchor> - </Text> - </> - )} - </> + <BillingInfo + isStoreManagedBilling={isStoreManagedBilling} + hasToken={hasToken} + billingInfo={billingInfo} + error={billingError} + /> {shouldShowLicenseInput && ( <> @@ -144,9 +139,9 @@ const LicenseAndBillingSettings = ({ <LicenseInput disabled={is_env_setting} placeholder={is_env_setting ? t`Using ${env_name}` : undefined} - invalid={isInvalid} + invalid={isInvalidToken} loading={isUpdating} - error={error} + error={licenseError} token={token ? String(token) : undefined} onUpdate={updateToken} /> diff --git a/enterprise/frontend/src/metabase-enterprise/license/components/LicenseAndBillingSettings/LicenseAndBillingSettings.unit.spec.tsx b/enterprise/frontend/src/metabase-enterprise/license/components/LicenseAndBillingSettings/LicenseAndBillingSettings.unit.spec.tsx index 65bec82c44f..3e0aa7f4346 100644 --- a/enterprise/frontend/src/metabase-enterprise/license/components/LicenseAndBillingSettings/LicenseAndBillingSettings.unit.spec.tsx +++ b/enterprise/frontend/src/metabase-enterprise/license/components/LicenseAndBillingSettings/LicenseAndBillingSettings.unit.spec.tsx @@ -1,9 +1,13 @@ import userEvent from "@testing-library/user-event"; import fetchMock from "fetch-mock"; +import { Route } from "react-router"; import { renderWithProviders, screen } from "__support__/ui"; +import type { BillingInfo, BillingInfoLineItem } from "metabase-types/api"; import { createMockAdminState } from "metabase-types/store/mocks"; +import { getBillingInfoId } from "../BillingInfo/utils"; + import LicenseAndBillingSettings from "./LicenseAndBillingSettings"; const setupState = ({ @@ -57,26 +61,184 @@ describe("LicenseAndBilling", () => { jest.restoreAllMocks(); }); - it("renders settings for store managed billing with a valid token", async () => { + describe("render store info", () => { + it("should render valid store billing info", async () => { + mockTokenStatus(true, ["metabase-store-managed"]); + const plan: BillingInfoLineItem = { + name: "Plan", + value: "Metabase Cloud Pro", + format: "string", + display: "value", + }; + const users: BillingInfoLineItem = { + name: "Users", + value: 4000, + format: "integer", + display: "internal-link", + link: "user-list", + }; + const nextCharge: BillingInfoLineItem = { + name: "Next charge", + value: "2024-01-22T13:08:54Z", + format: "datetime", + display: "value", + }; + const billingFreq: BillingInfoLineItem = { + name: "Billing frequency", + value: "Monthly", + format: "string", + display: "value", + }; + const nextChargeValue: BillingInfoLineItem = { + name: "Next charge value", + value: 500, + format: "currency", + currency: "USD", + display: "value", + }; + const float: BillingInfoLineItem = { + name: "Pi", + value: 3.14159, + format: "float", + display: "value", + precision: 2, + }; + const managePreferences: BillingInfoLineItem = { + name: "Visit the Metabase store to manage your account and billing preferences.", + value: "Manage preferences", + format: "string", + display: "external-link", + link: "https://store.metabase.com/", + }; + const mockData: BillingInfo = { + version: "v1", + content: [ + plan, + users, + nextCharge, + billingFreq, + nextChargeValue, + float, + managePreferences, + ], + }; + + fetchMock.get("path:/api/ee/billing", mockData); + + renderWithProviders( + <Route path="/" component={LicenseAndBillingSettings}></Route>, + { withRouter: true, ...setupState({ token: "token" }) }, + ); + + // test string format + expect(await screen.findByText(plan.name)).toBeInTheDocument(); + expect(await screen.findByText(plan.name)).toBeInTheDocument(); + + // test integer format + internal-link display + expect(await screen.findByText(users.name)).toBeInTheDocument(); + const userTableValue = await screen.findByTestId( + `billing-info-value-${getBillingInfoId(users)}`, + ); + expect(userTableValue).toHaveTextContent("4,000"); + expect(userTableValue).toHaveAttribute("href", "/admin/people"); + + // test datetime format + expect(await screen.findByText(nextCharge.name)).toBeInTheDocument(); + expect( + await screen.findByText(`Monday, January 22, 2024`), + ).toBeInTheDocument(); + + // test currency + expect(await screen.findByText(nextChargeValue.name)).toBeInTheDocument(); + expect(await screen.findByText(`$500.00`)).toBeInTheDocument(); + + // test float + expect(await screen.findByText(float.name)).toBeInTheDocument(); + expect(screen.queryByText("" + float.value)).not.toBeInTheDocument(); + expect(await screen.findByText("3.14")).toBeInTheDocument(); + + // test internal + external-link displays + expect( + await screen.findByText(managePreferences.name), + ).toBeInTheDocument(); + const managePreferencesTableValue = await screen.findByTestId( + `billing-info-value-${getBillingInfoId(managePreferences)}`, + ); + expect(managePreferencesTableValue).toHaveTextContent( + managePreferences.value, + ); + expect(managePreferencesTableValue).toHaveAttribute( + "href", + managePreferences.link, + ); + + expect( + screen.getByText( + "Your license is active until Dec 31, 2099! Hope you’re enjoying it.", + ), + ).toBeInTheDocument(); + }); + + it("should not render store info with unknown format types, display types, or invalid data", () => { + mockTokenStatus(true, ["metabase-store-managed"]); + + // provide one valid value so the table renders + const plan: BillingInfoLineItem = { + name: "Plan", + value: "Metabase Cloud Pro", + format: "string", + display: "value", + }; + // mocking some future format that doesn't exist yet + const unsupportedFormat: any = { + name: "Unsupported format", + value: "Unsupported format", + format: "unsupported-format", + display: "value", + }; + // mocking some future diplay that doesn't exist yet + const unsupportedDisplay: any = { + name: "Unsupported display", + value: "Unsupported display", + format: "string", + display: "unsupported-display", + }; + // mocking some incorrect data we're not expecting + const invalidValue: any = { + name: "Invalid value", + }; + const mockData: BillingInfo = { + version: "v1", + content: [plan, unsupportedFormat, unsupportedDisplay, invalidValue], + }; + fetchMock.get("path:/api/ee/billing", mockData); + + renderWithProviders( + <LicenseAndBillingSettings />, + setupState({ token: "token" }), + ); + + // test unsupported display, unsupported format, and invalid items do not render + expect( + screen.queryByText(unsupportedFormat.name), + ).not.toBeInTheDocument(); + expect( + screen.queryByText(unsupportedDisplay.name), + ).not.toBeInTheDocument(); + expect(screen.queryByText(invalidValue.name)).not.toBeInTheDocument(); + }); + }); + + it("renders error for billing info for store managed billing and info request fails", async () => { mockTokenStatus(true, ["metabase-store-managed"]); + fetchMock.get("path:/api/ee/billing", 500); renderWithProviders( <LicenseAndBillingSettings />, setupState({ token: "token" }), ); - expect( - await screen.findByText( - "Manage your Cloud account, including billing preferences, in your Metabase Store account.", - ), - ).toBeInTheDocument(); - expect(screen.getByText("Go to the Metabase Store")).toBeInTheDocument(); - - expect( - screen.getByText( - "Your license is active until Dec 31, 2099! Hope you’re enjoying it.", - ), - ).toBeInTheDocument(); + expect(await screen.findByTestId("billing-info-error")).toBeInTheDocument(); }); it("renders settings for non-store-managed billing with a valid token", async () => { @@ -131,6 +293,15 @@ describe("LicenseAndBilling", () => { ).toBeDisabled(); }); + it("does not render an input when token has hosting feature enabled", async () => { + mockTokenStatus(true, ["hosting"]); + renderWithProviders(<LicenseAndBillingSettings />, setupState({})); + expect( + await screen.findByText("Go to the Metabase Store"), + ).toBeInTheDocument(); + expect(screen.queryByTestId("license-input")).not.toBeInTheDocument(); + }); + it("shows an error when entered license is not valid", async () => { mockTokenNotExist(); mockUpdateToken(false); diff --git a/enterprise/frontend/src/metabase-enterprise/license/components/StillNeedHelp/StillNeedHelp.tsx b/enterprise/frontend/src/metabase-enterprise/license/components/StillNeedHelp/StillNeedHelp.tsx new file mode 100644 index 00000000000..d2176c3f455 --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/license/components/StillNeedHelp/StillNeedHelp.tsx @@ -0,0 +1,31 @@ +import styled from "@emotion/styled"; +import { t } from "ttag"; + +import { color } from "metabase/lib/colors"; +import { Text, Anchor } from "metabase/ui"; + +const Container = styled.div` + background: ${color("bg-light")}; + border-radius: 0.5rem; + padding: 0.75rem 1rem; + margin-top: 1.5rem; +`; + +const Title = styled.div` + color: ${color("text-medium")}; + font-weight: bold; + text-transform: uppercase; + margin-bottom: 0.5rem; +`; + +export const StillNeedHelp = () => { + return ( + <Container> + <Title>{t`Still need help?`}</Title> + <Text color="text-medium"> + {t`You can ask for billing help at `} + <Anchor href="mailto:billing@metabase.com">billing@metabase.com</Anchor> + </Text> + </Container> + ); +}; diff --git a/enterprise/frontend/src/metabase-enterprise/license/components/StillNeedHelp/index.ts b/enterprise/frontend/src/metabase-enterprise/license/components/StillNeedHelp/index.ts new file mode 100644 index 00000000000..38d73c98951 --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/license/components/StillNeedHelp/index.ts @@ -0,0 +1 @@ +export * from "./StillNeedHelp"; diff --git a/enterprise/frontend/src/metabase-enterprise/license/services.ts b/enterprise/frontend/src/metabase-enterprise/license/services.ts deleted file mode 100644 index 6da5dca66c6..00000000000 --- a/enterprise/frontend/src/metabase-enterprise/license/services.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { GET } from "metabase/lib/api"; - -export const StoreApi = { - tokenStatus: GET("/api/premium-features/token/status"), -}; diff --git a/enterprise/frontend/src/metabase-enterprise/settings/hooks/use-billing-info.ts b/enterprise/frontend/src/metabase-enterprise/settings/hooks/use-billing-info.ts new file mode 100644 index 00000000000..24d5a2e7499 --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/settings/hooks/use-billing-info.ts @@ -0,0 +1,77 @@ +import { useEffect, useReducer } from "react"; + +import { StoreApi } from "metabase/services"; +import type { BillingInfo } from "metabase-types/api"; + +type UseBillingAction = + | { type: "SET_LOADING" } + | { type: "SET_ERROR" } + | { type: "SET_DATA"; payload: BillingInfo }; + +type UseBillingState = + | { loading: false; error: false; billingInfo: undefined } + | { loading: true; error: false; billingInfo: undefined } + | { loading: false; error: true; billingInfo: undefined } + | { loading: false; error: false; billingInfo: BillingInfo }; + +const defaultState: UseBillingState = { + loading: false, + error: false, + billingInfo: undefined, +}; + +function reducer( + _state: UseBillingState, + action: UseBillingAction, +): UseBillingState { + switch (action.type) { + case "SET_LOADING": + return { loading: true, error: false, billingInfo: undefined }; + case "SET_ERROR": + return { loading: false, error: true, billingInfo: undefined }; + case "SET_DATA": + return { loading: false, error: false, billingInfo: action.payload }; + default: { + const _exhaustiveCheck: never = action; + throw Error( + "Unknown action dispatched in useBilling:" + + JSON.stringify(_exhaustiveCheck), + ); + } + } +} + +export const useBillingInfo = ( + shouldFetchBillingInfo: boolean, +): UseBillingState => { + const [state, dispatch] = useReducer(reducer, defaultState); + + useEffect(() => { + let cancelled = false; + + const fetchBillingInfo = async () => { + dispatch({ type: "SET_LOADING" }); + + try { + const billingResponse = await StoreApi.billingInfo(); + if (!cancelled) { + dispatch({ type: "SET_DATA", payload: billingResponse }); + } + } catch (err: any) { + if (!cancelled) { + dispatch({ type: "SET_ERROR" }); + } + } + }; + + if (shouldFetchBillingInfo) { + fetchBillingInfo(); + } + + return () => { + cancelled = true; + }; + }, [shouldFetchBillingInfo]); + + return state; +}; diff --git a/frontend/src/metabase/admin/settings/hooks/use-license.ts b/enterprise/frontend/src/metabase-enterprise/settings/hooks/use-license.ts similarity index 93% rename from frontend/src/metabase/admin/settings/hooks/use-license.ts rename to enterprise/frontend/src/metabase-enterprise/settings/hooks/use-license.ts index 3c5c2f7d400..b4ce0688717 100644 --- a/frontend/src/metabase/admin/settings/hooks/use-license.ts +++ b/enterprise/frontend/src/metabase-enterprise/settings/hooks/use-license.ts @@ -12,13 +12,13 @@ export type TokenStatus = { validUntil: Date; isValid: boolean; isTrial: boolean; - features: string[]; + features: Set<string>; status: string; }; export const useLicense = (onActivated?: () => void) => { const [tokenStatus, setTokenStatus] = useState<TokenStatus>(); - const [isLoading, setIsLoading] = useState(true); + const [loading, setLoading] = useState(true); const [isUpdating, setIsUpdating] = useState(false); const [error, setError] = useState<string>(); @@ -59,7 +59,7 @@ export const useLicense = (onActivated?: () => void) => { validUntil: new Date(response["valid-thru"]), isValid: response.valid, isTrial: response.trial, - features: response.features, + features: new Set(response.features), status: response.status, }); } catch (e) { @@ -67,7 +67,7 @@ export const useLicense = (onActivated?: () => void) => { setError(UNABLE_TO_VALIDATE_TOKEN); } } finally { - setIsLoading(false); + setLoading(false); } }; @@ -78,7 +78,7 @@ export const useLicense = (onActivated?: () => void) => { isUpdating, error, tokenStatus, - isLoading, + loading, updateToken, }; }; diff --git a/frontend/src/metabase-types/api/store.ts b/frontend/src/metabase-types/api/store.ts index 324713b71e4..0913bf1d1d1 100644 --- a/frontend/src/metabase-types/api/store.ts +++ b/frontend/src/metabase-types/api/store.ts @@ -3,3 +3,37 @@ export interface StoreTokenStatus { valid: boolean; trial: boolean; } + +export const supportedFormatTypes = [ + "string", + "integer", + "float", + "datetime", + "currency", +] as const; + +export const supportedDisplayTypes = [ + "internal-link", + "external-link", + "value", +] as const; + +type BillingInfoDisplayType = + | { display: "internal-link"; link: string } + | { display: "external-link"; link: string } + | { display: "value" }; + +type BillingInfoFormatType = + | { name: string; value: string; format: "string" } + | { name: string; value: number; format: "integer" } + | { name: string; value: number; format: "float"; precision: number } + | { name: string; value: string; format: "datetime" } + | { name: string; value: number; format: "currency"; currency: string }; + +export type BillingInfoLineItem = BillingInfoFormatType & + BillingInfoDisplayType; + +export type BillingInfo = { + version: string; + content: BillingInfoLineItem[] | null; +}; diff --git a/frontend/src/metabase/admin/settings/components/LicenseInput/LicenseInput.styled.tsx b/frontend/src/metabase/admin/settings/components/LicenseInput/LicenseInput.styled.tsx index 27e42d04e14..b3e203635f0 100644 --- a/frontend/src/metabase/admin/settings/components/LicenseInput/LicenseInput.styled.tsx +++ b/frontend/src/metabase/admin/settings/components/LicenseInput/LicenseInput.styled.tsx @@ -6,8 +6,7 @@ import { color } from "metabase/lib/colors"; export const LicenseInputContainer = styled.div` display: flex; flex-wrap: nowrap; - // min-width: 680px; - width: 680px; + width: 100%; `; export const LicenseTextInput = styled(Input)` diff --git a/frontend/src/metabase/admin/settings/components/SettingsLicense/SettingsLicense.styled.tsx b/frontend/src/metabase/admin/settings/components/SettingsLicense/SettingsLicense.styled.tsx index e914d9ef847..a76e355eb11 100644 --- a/frontend/src/metabase/admin/settings/components/SettingsLicense/SettingsLicense.styled.tsx +++ b/frontend/src/metabase/admin/settings/components/SettingsLicense/SettingsLicense.styled.tsx @@ -39,7 +39,7 @@ export const ExplorePaidPlansContainer = styled.div<ExplorePaidPlansContainerPro export const SettingsLicenseContainer = styled.div` width: 580px; - padding: 16px; + padding: 0 16px; `; export const LoaderContainer = styled.div` diff --git a/frontend/src/metabase/services.js b/frontend/src/metabase/services.js index 083f369eecc..2c11f9a0fbf 100644 --- a/frontend/src/metabase/services.js +++ b/frontend/src/metabase/services.js @@ -43,6 +43,7 @@ export const GTAPApi = { export const StoreApi = { tokenStatus: GET("/api/premium-features/token/status"), + billingInfo: GET("/api/ee/billing"), }; // Pivot tables need extra data beyond what's described in the MBQL query itself. -- GitLab