From b0309e2c21ba0a109c2ef448506a3cb925876c98 Mon Sep 17 00:00:00 2001 From: Alexander Lesnenko <alxnddr@users.noreply.github.com> Date: Tue, 4 Jan 2022 23:39:34 +0100 Subject: [PATCH] Replace enterprise with license page (#19343) * remove old store page * add license pages * add remove webpack cache command * lint * fix specs * remove license widget from the oss version * add missing translation * show loader only for unput when updating token * fix specs * amend the license page * unactivated state fix * add premium embedding page, refactor, fix specs * amend the license page * amend the license page * change license page padding --- enterprise/README.md | 2 + .../metabase-enterprise/license/actions.ts | 16 + .../LicenseAndBillingSettings.tsx | 124 +++++++ .../LicenseAndBillingSettings.unit.spec.tsx | 179 ++++++++++ .../LicenseAndBillingSettings/index.ts | 1 + .../src/metabase-enterprise/license/index.ts | 4 + .../lib/services.js => license/services.ts} | 0 .../src/metabase-enterprise/plugins.js | 2 +- .../store/components/StoreIcon.jsx | 30 -- .../store/containers/StoreAccount.jsx | 306 ------------------ .../store/containers/StoreActivate.jsx | 98 ------ .../src/metabase-enterprise/store/index.js | 9 - .../metabase-enterprise/store/lib/features.js | 52 --- .../src/metabase-enterprise/store/routes.jsx | 17 - frontend/src/metabase/admin/routes.jsx | 5 + .../LicenseInput/LicenseInput.styled.tsx | 21 ++ .../components/LicenseInput/LicenseInput.tsx | 62 ++++ .../settings/components/LicenseInput/index.ts | 1 + .../SettingsLicense.styled.tsx | 50 +++ .../SettingsLicense/SettingsLicense.tsx | 34 ++ .../content/ExplorePlansIllustration.tsx | 155 +++++++++ .../content/StarterContent.tsx | 44 +++ .../content/UnlicensedContent.tsx | 35 ++ .../components/SettingsLicense/index.ts | 2 + .../widgets/EmbeddingCustomizationInfo.tsx | 2 +- .../PremiumEmbeddingLinkWidget.styled.tsx | 10 + .../PremiumEmbeddingLinkWidget.tsx | 16 + .../PremiumEmbeddingLinkWidget/index.ts | 1 + .../PremiumEmbeddingLicensePage.styled.tsx | 37 +++ .../PremiumEmbeddingLicensePage.tsx | 124 +++++++ .../PremiumEmbeddingLicensePage/index.ts | 1 + .../admin/settings/hooks/use-license.ts | 81 +++++ .../src/metabase/admin/settings/selectors.js | 15 +- .../src/metabase/admin/settings/settings.js | 2 - ...tInput.styled.jsx => TextInput.styled.tsx} | 26 +- .../{TextInput.jsx => TextInput.tsx} | 45 +-- frontend/src/metabase/lib/settings.ts | 16 +- frontend/src/metabase/plugins/index.js | 4 + frontend/src/metabase/services.js | 4 + package.json | 1 + 40 files changed, 1092 insertions(+), 542 deletions(-) create mode 100644 enterprise/frontend/src/metabase-enterprise/license/actions.ts create mode 100644 enterprise/frontend/src/metabase-enterprise/license/components/LicenseAndBillingSettings/LicenseAndBillingSettings.tsx create mode 100644 enterprise/frontend/src/metabase-enterprise/license/components/LicenseAndBillingSettings/LicenseAndBillingSettings.unit.spec.tsx create mode 100644 enterprise/frontend/src/metabase-enterprise/license/components/LicenseAndBillingSettings/index.ts create mode 100644 enterprise/frontend/src/metabase-enterprise/license/index.ts rename enterprise/frontend/src/metabase-enterprise/{store/lib/services.js => license/services.ts} (100%) delete mode 100644 enterprise/frontend/src/metabase-enterprise/store/components/StoreIcon.jsx delete mode 100644 enterprise/frontend/src/metabase-enterprise/store/containers/StoreAccount.jsx delete mode 100644 enterprise/frontend/src/metabase-enterprise/store/containers/StoreActivate.jsx delete mode 100644 enterprise/frontend/src/metabase-enterprise/store/index.js delete mode 100644 enterprise/frontend/src/metabase-enterprise/store/lib/features.js delete mode 100644 enterprise/frontend/src/metabase-enterprise/store/routes.jsx create mode 100644 frontend/src/metabase/admin/settings/components/LicenseInput/LicenseInput.styled.tsx create mode 100644 frontend/src/metabase/admin/settings/components/LicenseInput/LicenseInput.tsx create mode 100644 frontend/src/metabase/admin/settings/components/LicenseInput/index.ts create mode 100644 frontend/src/metabase/admin/settings/components/SettingsLicense/SettingsLicense.styled.tsx create mode 100644 frontend/src/metabase/admin/settings/components/SettingsLicense/SettingsLicense.tsx create mode 100644 frontend/src/metabase/admin/settings/components/SettingsLicense/content/ExplorePlansIllustration.tsx create mode 100644 frontend/src/metabase/admin/settings/components/SettingsLicense/content/StarterContent.tsx create mode 100644 frontend/src/metabase/admin/settings/components/SettingsLicense/content/UnlicensedContent.tsx create mode 100644 frontend/src/metabase/admin/settings/components/SettingsLicense/index.ts create mode 100644 frontend/src/metabase/admin/settings/components/widgets/PremiumEmbeddingLinkWidget/PremiumEmbeddingLinkWidget.styled.tsx create mode 100644 frontend/src/metabase/admin/settings/components/widgets/PremiumEmbeddingLinkWidget/PremiumEmbeddingLinkWidget.tsx create mode 100644 frontend/src/metabase/admin/settings/components/widgets/PremiumEmbeddingLinkWidget/index.ts create mode 100644 frontend/src/metabase/admin/settings/containers/PremiumEmbeddingLicensePage/PremiumEmbeddingLicensePage.styled.tsx create mode 100644 frontend/src/metabase/admin/settings/containers/PremiumEmbeddingLicensePage/PremiumEmbeddingLicensePage.tsx create mode 100644 frontend/src/metabase/admin/settings/containers/PremiumEmbeddingLicensePage/index.ts create mode 100644 frontend/src/metabase/admin/settings/hooks/use-license.ts rename frontend/src/metabase/components/TextInput/{TextInput.styled.jsx => TextInput.styled.tsx} (71%) rename frontend/src/metabase/components/TextInput/{TextInput.jsx => TextInput.tsx} (65%) diff --git a/enterprise/README.md b/enterprise/README.md index 6295a227d0c..c34a7b2bf2d 100644 --- a/enterprise/README.md +++ b/enterprise/README.md @@ -17,6 +17,8 @@ Unless otherwise noted, all files Copyright © 2021 Metabase, Inc. MB_EDITION=ee yarn build-hot ``` +Clear the Webpack cache using `yarn remove-webpack-cache` if you previously ran OSS edition in dev mode to avoid unexpected application behavior. + ### Back-end You need to add the `:ee` alias to the Clojure CLI command to run Metabase Enterprise Edition. diff --git a/enterprise/frontend/src/metabase-enterprise/license/actions.ts b/enterprise/frontend/src/metabase-enterprise/license/actions.ts new file mode 100644 index 00000000000..5dedb76ec59 --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/license/actions.ts @@ -0,0 +1,16 @@ +import { t } from "ttag"; +import { createThunkAction } from "metabase/lib/redux"; +import { addUndo } from "metabase/redux/undo"; + +export const SHOW_LICENSE_ACCEPTED_TOAST = + "metabase-enterprise/license/SHOW_LICENSE_ACCEPTED_TOAST"; +export const showLicenseAcceptedToast = createThunkAction( + SHOW_LICENSE_ACCEPTED_TOAST, + () => (dispatch: any) => { + dispatch( + addUndo({ + message: t`Your license is active!`, + }), + ); + }, +); diff --git a/enterprise/frontend/src/metabase-enterprise/license/components/LicenseAndBillingSettings/LicenseAndBillingSettings.tsx b/enterprise/frontend/src/metabase-enterprise/license/components/LicenseAndBillingSettings/LicenseAndBillingSettings.tsx new file mode 100644 index 00000000000..e8f8e452cf3 --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/license/components/LicenseAndBillingSettings/LicenseAndBillingSettings.tsx @@ -0,0 +1,124 @@ +import React from "react"; +import { t, jt } from "ttag"; +import { connect } from "react-redux"; +import moment from "moment"; +import { showLicenseAcceptedToast } from "metabase-enterprise/license/actions"; +import { + TokenStatus, + useLicense, +} from "metabase/admin/settings/hooks/use-license"; +import { + LoaderContainer, + SectionDescription, + SectionHeader, + SettingsLicenseContainer, +} from "metabase/admin/settings/components/SettingsLicense"; +import ExternalLink from "metabase/components/ExternalLink"; +import MetabaseSettings from "metabase/lib/settings"; +import LoadingSpinner from "metabase/components/LoadingSpinner"; +import { LicenseInput } from "metabase/admin/settings/components/LicenseInput"; + +const getDescription = (tokenStatus?: TokenStatus, hasToken?: boolean) => { + if (!hasToken) { + return t`Bought a license to unlock advanced functionality? Please enter it below.`; + } + + if (!tokenStatus || !tokenStatus.isValid) { + return ( + <> + {jt`Your license isn’t valid anymore. If you have a new license, please + enter it below, otherwise please contact ${( + <ExternalLink href="mailto:support@metabase.com"> + support@metabase.com + </ExternalLink> + )}`} + </> + ); + } + + const daysRemaining = moment(tokenStatus.validUntil).diff(moment(), "days"); + + if (tokenStatus.isValid && tokenStatus.isTrial) { + return t`Your trial ends in ${daysRemaining} days. If you already have a license, please enter it below.`; + } + + const validUntil = moment(tokenStatus.validUntil).format("MMM D, YYYY"); + return t`Your license is active until ${validUntil}! Hope you’re enjoying it.`; +}; + +interface LicenseAndBillingSettingsProps { + showLicenseAcceptedToast: () => void; +} + +const LicenseAndBillingSettings = ({ + showLicenseAcceptedToast, +}: LicenseAndBillingSettingsProps) => { + const { isLoading, error, tokenStatus, updateToken, isUpdating } = useLicense( + showLicenseAcceptedToast, + ); + + if (isLoading) { + return ( + <SettingsLicenseContainer> + <LoaderContainer> + <LoadingSpinner /> + </LoaderContainer> + </SettingsLicenseContainer> + ); + } + + const isStoreManagedBilling = MetabaseSettings.isStoreManaged(); + const token = MetabaseSettings.token(); + + const isInvalid = !!error || (tokenStatus != null && !tokenStatus.isValid); + const description = getDescription(tokenStatus, !!token); + + 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 && ( + <SectionDescription> + {jt`To manage your billing preferences, please email ${( + <ExternalLink href="mailto:billing@metabase.com"> + billing@metabase.com + </ExternalLink> + )}`} + </SectionDescription> + )} + </> + + <SectionHeader>{t`License`}</SectionHeader> + + <SectionDescription>{description}</SectionDescription> + + <LicenseInput + invalid={isInvalid} + loading={isUpdating} + error={error} + token={token} + onUpdate={updateToken} + /> + </SettingsLicenseContainer> + ); +}; + +export default connect(null, { showLicenseAcceptedToast })( + LicenseAndBillingSettings, +); 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 new file mode 100644 index 00000000000..178522b5d02 --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/license/components/LicenseAndBillingSettings/LicenseAndBillingSettings.unit.spec.tsx @@ -0,0 +1,179 @@ +import { act, fireEvent, screen } from "@testing-library/react"; +import React from "react"; +import xhrMock from "xhr-mock"; +import { renderWithProviders } from "__support__/ui"; +import LicenseAndBillingSettings from "."; +import MetabaseSettings from "metabase/lib/settings"; + +const mockSettings = ( + isStoreManaged = true, + premiumEmbeddingToken?: string, +) => { + const original = MetabaseSettings.get.bind(MetabaseSettings); + const spy = jest.spyOn(MetabaseSettings, "get"); + spy.mockImplementation(key => { + if (key === "premium-embedding-token") { + return premiumEmbeddingToken; + } + + if (key === "metabase-store-managed") { + return isStoreManaged; + } + + return original(key); + }); +}; + +const mockTokenStatus = (valid: boolean) => { + xhrMock.get("/api/premium-features/token/status", { + body: JSON.stringify({ + valid, + "valid-thru": "2099-12-31T12:00:00", + }), + }); +}; + +const mockTokenNotExist = () => { + xhrMock.get("/api/premium-features/token/status", { + status: 404, + }); +}; + +const mockUpdateToken = (valid: boolean) => { + if (valid) { + xhrMock.put("/api/setting/premium-embedding-token", { + status: 200, + }); + } else { + xhrMock.put("/api/setting/premium-embedding-token", { + status: 400, + }); + } +}; + +describe("LicenseAndBilling", () => { + const originalLocation = window.location; + + beforeEach(() => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + delete window.location; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + window.location = { reload: jest.fn() }; + xhrMock.setup(); + }); + + afterEach(() => { + xhrMock.teardown(); + jest.restoreAllMocks(); + + window.location = originalLocation; + }); + + it("renders settings for store managed billing with a valid token", async () => { + mockTokenStatus(true); + mockSettings(true, "token"); + + renderWithProviders(<LicenseAndBillingSettings />); + + 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(); + }); + + it("renders settings for non-store-managed billing with a valid token", async () => { + mockTokenStatus(true); + mockSettings(false, "token"); + + renderWithProviders(<LicenseAndBillingSettings />); + + expect( + await screen.findByText( + "To manage your billing preferences, please email", + ), + ).toBeInTheDocument(); + expect(screen.getByText("billing@metabase.com")).toHaveAttribute( + "href", + "mailto:billing@metabase.com", + ); + + expect( + screen.getByText( + "Your license is active until Dec 31, 2099! Hope you’re enjoying it.", + ), + ).toBeInTheDocument(); + }); + + it("renders settings for unlicensed instances", async () => { + mockTokenNotExist(); + renderWithProviders(<LicenseAndBillingSettings />); + + expect( + await screen.findByText( + "Bought a license to unlock advanced functionality? Please enter it below.", + ), + ).toBeInTheDocument(); + }); + + it("shows an error when entered license is not valid", async () => { + mockTokenNotExist(); + mockUpdateToken(false); + renderWithProviders(<LicenseAndBillingSettings />); + + expect( + await screen.findByText( + "Bought a license to unlock advanced functionality? Please enter it below.", + ), + ).toBeInTheDocument(); + + const licenseInput = screen.getByTestId("license-input"); + const activateButton = screen.getByTestId("activate-button"); + + const token = "invalid"; + await act(async () => { + await fireEvent.change(licenseInput, { target: { value: token } }); + await fireEvent.click(activateButton); + }); + + expect( + await screen.findByText( + "This token doesn't seem to be valid. Double-check it, then contact support if you think it should be working.", + ), + ).toBeInTheDocument(); + }); + + it("refreshes the page when license is accepted", async () => { + window.location.reload = jest.fn(); + + mockTokenNotExist(); + mockUpdateToken(true); + renderWithProviders(<LicenseAndBillingSettings />); + + expect( + await screen.findByText( + "Bought a license to unlock advanced functionality? Please enter it below.", + ), + ).toBeInTheDocument(); + + const licenseInput = screen.getByTestId("license-input"); + const activateButton = screen.getByTestId("activate-button"); + + const token = "valid"; + await act(async () => { + await fireEvent.change(licenseInput, { target: { value: token } }); + await fireEvent.click(activateButton); + }); + + expect(window.location.reload).toHaveBeenCalled(); + }); +}); diff --git a/enterprise/frontend/src/metabase-enterprise/license/components/LicenseAndBillingSettings/index.ts b/enterprise/frontend/src/metabase-enterprise/license/components/LicenseAndBillingSettings/index.ts new file mode 100644 index 00000000000..4dc681802b3 --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/license/components/LicenseAndBillingSettings/index.ts @@ -0,0 +1 @@ +export { default } from "./LicenseAndBillingSettings"; diff --git a/enterprise/frontend/src/metabase-enterprise/license/index.ts b/enterprise/frontend/src/metabase-enterprise/license/index.ts new file mode 100644 index 00000000000..ff5be9ff92b --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/license/index.ts @@ -0,0 +1,4 @@ +import { PLUGIN_LICENSE_PAGE } from "metabase/plugins"; +import LicenseAndBillingSettings from "./components/LicenseAndBillingSettings"; + +PLUGIN_LICENSE_PAGE.LicenseAndBillingSettings = LicenseAndBillingSettings as any; diff --git a/enterprise/frontend/src/metabase-enterprise/store/lib/services.js b/enterprise/frontend/src/metabase-enterprise/license/services.ts similarity index 100% rename from enterprise/frontend/src/metabase-enterprise/store/lib/services.js rename to enterprise/frontend/src/metabase-enterprise/license/services.ts diff --git a/enterprise/frontend/src/metabase-enterprise/plugins.js b/enterprise/frontend/src/metabase-enterprise/plugins.js index ab5d012de73..ed722d11a44 100644 --- a/enterprise/frontend/src/metabase-enterprise/plugins.js +++ b/enterprise/frontend/src/metabase-enterprise/plugins.js @@ -15,10 +15,10 @@ import "./caching"; import "./collections"; import "./whitelabel"; import "./embedding"; -import "./store"; import "./snippets"; import "./sharing"; import "./moderation"; import "./advanced_config"; import "./advanced_permissions"; import "./audit_app"; +import "./license"; diff --git a/enterprise/frontend/src/metabase-enterprise/store/components/StoreIcon.jsx b/enterprise/frontend/src/metabase-enterprise/store/components/StoreIcon.jsx deleted file mode 100644 index b70fc33cd93..00000000000 --- a/enterprise/frontend/src/metabase-enterprise/store/components/StoreIcon.jsx +++ /dev/null @@ -1,30 +0,0 @@ -/* eslint-disable react/prop-types */ -import React from "react"; -import { Flex } from "grid-styled"; -import Icon from "metabase/components/Icon"; -import colors from "metabase/lib/colors"; - -const ICON_SIZE = 22; -const WRAPPER_SIZE = ICON_SIZE * 2.5; - -const StoreIconWrapper = ({ children, color }) => ( - <Flex - align="center" - justify="center" - p={2} - bg={color || colors["brand"]} - color="white" - width={WRAPPER_SIZE} - style={{ borderRadius: 99, height: WRAPPER_SIZE }} - > - {children} - </Flex> -); - -const StoreIcon = ({ color, name, ...props }) => ( - <StoreIconWrapper color={color}> - <Icon name={name} size={ICON_SIZE} /> - </StoreIconWrapper> -); - -export default StoreIcon; diff --git a/enterprise/frontend/src/metabase-enterprise/store/containers/StoreAccount.jsx b/enterprise/frontend/src/metabase-enterprise/store/containers/StoreAccount.jsx deleted file mode 100644 index ebd9add4f70..00000000000 --- a/enterprise/frontend/src/metabase-enterprise/store/containers/StoreAccount.jsx +++ /dev/null @@ -1,306 +0,0 @@ -/* eslint-disable react/prop-types */ -import React from "react"; -import { Box, Flex } from "grid-styled"; -import { t } from "ttag"; - -import _ from "underscore"; - -import colors from "metabase/lib/colors"; - -import StoreIcon from "../components/StoreIcon"; -import Card from "metabase/components/Card"; -import Link from "metabase/components/Link"; -import ExternalLink from "metabase/components/ExternalLink"; -import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper"; - -import fitViewport from "metabase/hoc/FitViewPort"; - -import moment from "moment"; - -import FEATURES from "../lib/features"; -import { StoreApi } from "../lib/services"; - -@fitViewport -export default class StoreAccount extends React.Component { - state = { - status: null, - error: null, - }; - - async UNSAFE_componentWillMount() { - try { - this.setState({ - status: await StoreApi.tokenStatus(), - }); - } catch (e) { - this.setState({ - error: e, - }); - } - } - - render() { - const { status, error } = this.state; - - const features = - status && - status.features && - _.object(status.features.map(f => [f, true])); - const expires = status && status.valid_thru && moment(status.valid_thru); - - return ( - <Flex - align="center" - justify="center" - flexDirection="column" - className={this.props.fitClassNames} - > - {error ? ( - error.status === 404 ? ( - <Unlicensed /> - ) : ( - <TokenError /> - ) - ) : ( - <LoadingAndErrorWrapper loading={!status} className="full"> - {() => - status.valid && !status.trial ? ( - <Active features={features} expires={expires} /> - ) : !status.valid && !status.trial ? ( - <Expired features={features} expires={expires} /> - ) : status.valid && status.trial ? ( - <TrialActive features={features} expires={expires} /> - ) : !status.valid && status.trial ? ( - <TrialExpired features={features} expires={expires} /> - ) : ( - <h2>{status.status}</h2> - ) - } - </LoadingAndErrorWrapper> - )} - </Flex> - ); - } -} - -const TokenError = () => ( - <Flex align="center" justify="center" flexDirection="column"> - <h2 className="text-error">{t`We're having trouble validating your token`}</h2> - <h4 className="mt2">{t`Please double-check that your instance can connect to Metabase's servers`}</h4> - <ExternalLink - className="Button Button--primary mt4" - href="mailto:support@metabase.com" - > - {t`Get help`} - </ExternalLink> - </Flex> -); - -const Unlicensed = () => ( - <AccountStatus - title={t`Get even more out of Metabase with the Enterprise Edition`} - subtitle={ - <h4 className="text-centered">{t`All the tools you need to quickly and easily provide reports for your customers, or to help you run and monitor Metabase in a large organization`}</h4> - } - preview - > - <Box m={4}> - <ExternalLink - className="Button Button--primary" - href={"http://metabase.com/enterprise/"} - > - {t`Learn more`} - </ExternalLink> - <Link className="Button ml2" to={"admin/store/activate"}> - {t`Activate a license`} - </Link> - </Box> - </AccountStatus> -); - -const TrialActive = ({ features, expires }) => ( - <AccountStatus - title={t`Your trial is active with these features`} - subtitle={expires && <h3>{t`Trial expires ${expires.fromNow()}`}</h3>} - features={features} - > - <CallToAction - title={t`Need help? Ready to buy?`} - buttonText={t`Talk to us`} - buttonLink={ - "mailto:support@metabase.com?Subject=Metabase Enterprise Edition" - } - /> - <Link - className="link" - to={"admin/store/activate"} - >{t`Activate a license`}</Link> - </AccountStatus> -); - -const TrialExpired = ({ features }) => ( - <AccountStatus title={t`Your trial has expired`} features={features} expired> - <CallToAction - title={t`Need more time? Ready to buy?`} - buttonText={t`Talk to us`} - buttonLink={ - "mailto:support@metabase.com?Subject=Expired Enterprise Trial" - } - /> - <Link - className="link" - to={"admin/store/activate"} - >{t`Activate a license`}</Link> - </AccountStatus> -); - -const Active = ({ features, expires }) => ( - <AccountStatus - title={t`Your features are active!`} - subtitle={ - expires && ( - <h3>{t`Your licence is valid through ${expires.format( - "MMMM D, YYYY", - )}`}</h3> - ) - } - features={features} - /> -); - -const Expired = ({ features, expires }) => ( - <AccountStatus - title={t`Your license has expired`} - subtitle={ - expires && <h3>{t`It expired on ${expires.format("MMMM D, YYYY")}`}</h3> - } - features={features} - expired - > - <CallToAction - title={t`Want to renew your license?`} - buttonText={t`Talk to us`} - buttonLink={ - "mailto:support@metabase.com?Subject=Renewing my Enterprise License" - } - /> - </AccountStatus> -); - -const AccountStatus = ({ - title, - subtitle, - features = {}, - expired, - preview, - children, - className, -}) => { - // put included features first - const [included, notIncluded] = _.partition( - Object.entries(FEATURES), - ([id, feature]) => features[id], - ); - const featuresOrdered = [...included, ...notIncluded]; - return ( - <Flex - align="center" - justify="center" - flexDirection="column" - className={className} - p={[2, 4]} - width="100%" - > - <Box> - <h2>{title}</h2> - </Box> - {subtitle && ( - <Box mt={2} color={colors["text-medium"]} style={{ maxWidth: 500 }}> - {subtitle} - </Box> - )} - <Flex mt={4} align="center" flexWrap="wrap" width="100%"> - {featuresOrdered.map(([id, feature]) => ( - <Feature - key={id} - feature={feature} - included={features[id]} - expired={expired} - preview={preview} - /> - ))} - </Flex> - {children} - </Flex> - ); -}; - -const CallToAction = ({ title, buttonText, buttonLink }) => ( - <Box className="rounded bg-medium m4 py3 px4 flex flex-column layout-centered"> - <h3 className="mb3">{title}</h3> - <ExternalLink className="Button Button--primary" href={buttonLink}> - {buttonText} - </ExternalLink> - </Box> -); - -const Feature = ({ feature, included, expired, preview }) => ( - <Box width={[1, 1 / 2, 1 / 4]} p={2}> - <Card - p={[1, 2]} - style={{ - opacity: expired ? 0.5 : 1, - width: "100%", - height: 260, - backgroundColor: included ? undefined : colors["bg-light"], - color: included ? colors["text-dark"] : colors["text-medium"], - }} - className="relative flex flex-column layout-centered" - > - <StoreIcon - name={feature.icon} - color={ - preview - ? colors["brand"] - : included - ? colors["success"] - : colors["text-medium"] - } - /> - - <Box my={2}> - <h3 className="text-dark">{feature.name}</h3> - </Box> - - {preview ? ( - <FeatureDescription feature={feature} /> - ) : included ? ( - <FeatureLinks - links={feature.docs} - defaultTitle={t`Learn how to use this`} - /> - ) : ( - <FeatureLinks links={feature.info} defaultTitle={t`Learn more`} /> - )} - - {!included && !preview && ( - <div className="spread text-centered pt2 pointer-events-none">{t`Not included in your current plan`}</div> - )} - </Card> - </Box> -); - -const FeatureDescription = ({ feature }) => ( - <div className="text-centered">{feature.description}</div> -); - -const FeatureLinks = ({ links, defaultTitle }) => ( - <Flex align="center"> - {links && - links.map(({ link, title }) => ( - <ExternalLink href={link} key={link} className="mx2 link"> - {title || defaultTitle} - </ExternalLink> - ))} - </Flex> -); diff --git a/enterprise/frontend/src/metabase-enterprise/store/containers/StoreActivate.jsx b/enterprise/frontend/src/metabase-enterprise/store/containers/StoreActivate.jsx deleted file mode 100644 index ad29d5b1213..00000000000 --- a/enterprise/frontend/src/metabase-enterprise/store/containers/StoreActivate.jsx +++ /dev/null @@ -1,98 +0,0 @@ -/* eslint-disable react/prop-types */ -import React from "react"; -import { Box, Flex } from "grid-styled"; -import { t } from "ttag"; - -import colors from "metabase/lib/colors"; - -import Button from "metabase/components/Button"; -import Link from "metabase/components/Link"; - -import ModalWithTrigger from "metabase/components/ModalWithTrigger"; - -import fitViewport from "metabase/hoc/FitViewPort"; - -import { SettingsApi } from "metabase/services"; - -@fitViewport -export default class Activate extends React.Component { - state = { - heading: t`Enter the token you received from the store`, - errorMessage: "", - showVerbose: false, - error: false, - }; - activate = async () => { - const value = this._input.value.trim(); - if (!value) { - return false; - } - try { - await SettingsApi.put({ key: "premium-embedding-token", value }); - // set window.location so we do a hard refresh - window.location = "/admin/store"; - } catch (e) { - console.error(e.data); - this.setState({ - error: true, - heading: e.data.message, - errorMessage: e.data["error-details"], - }); - } - }; - render() { - return ( - <Flex - align="center" - justify="center" - className={this.props.fitClassNames} - > - <Flex align="center" flexDirection="column"> - <Box my={3}> - <h2 - className="text-centered" - style={{ color: this.state.error ? colors["error"] : "inherit" }} - > - {this.state.heading} - </h2> - </Box> - <Box> - <input - ref={ref => (this._input = ref)} - type="text" - className="input" - placeholder="XXXX-XXXX-XXXX-XXXX" - /> - <Button ml={1} onClick={this.activate}>{t`Activate`}</Button> - </Box> - - {this.state.error && ( - <ModalWithTrigger - triggerElement={ - <Box mt={3}> - <Link - className="link" - onClick={() => this.setState({ showVerbose: true })} - >{t`Need help?`}</Link> - </Box> - } - onClose={() => this.setState({ showVerbose: false })} - title={t`More info about your problem.`} - open={this.state.showVerbose} - > - <Box>{this.state.errorMessage}</Box> - <Flex my={2}> - <a - className="ml-auto" - href={`mailto:support@metabase.com?Subject="Issue with token activation for token ${this._input.value}"&Body="${this.state.errorMessage}"`} - > - <Button primary>{t`Contact support`}</Button> - </a> - </Flex> - </ModalWithTrigger> - )} - </Flex> - </Flex> - ); - } -} diff --git a/enterprise/frontend/src/metabase-enterprise/store/index.js b/enterprise/frontend/src/metabase-enterprise/store/index.js deleted file mode 100644 index 6dc92113b55..00000000000 --- a/enterprise/frontend/src/metabase-enterprise/store/index.js +++ /dev/null @@ -1,9 +0,0 @@ -import { t } from "ttag"; -import { PLUGIN_ADMIN_NAV_ITEMS, PLUGIN_ADMIN_ROUTES } from "metabase/plugins"; -import MetabaseSettings from "metabase/lib/settings"; -import getRoutes from "./routes"; - -if (!MetabaseSettings.isHosted()) { - PLUGIN_ADMIN_NAV_ITEMS.push({ name: t`Enterprise`, path: "/admin/store" }); - PLUGIN_ADMIN_ROUTES.push(getRoutes); -} diff --git a/enterprise/frontend/src/metabase-enterprise/store/lib/features.js b/enterprise/frontend/src/metabase-enterprise/store/lib/features.js deleted file mode 100644 index 811a43b45c2..00000000000 --- a/enterprise/frontend/src/metabase-enterprise/store/lib/features.js +++ /dev/null @@ -1,52 +0,0 @@ -import { t } from "ttag"; -import MetabaseSettings from "metabase/lib/settings"; - -const FEATURES = { - sandboxes: { - name: t`Data sandboxes`, - description: t`Make sure you're showing the right people the right data with automatic and secure filters based on user attributes.`, - icon: "lock", - docs: [ - { - link: MetabaseSettings.docsUrl("enterprise-guide/data-sandboxes"), - }, - ], - }, - whitelabel: { - name: t`White labeling`, - description: t`Match Metabase to your brand with custom colors, your own logo and more.`, - icon: "star", - docs: [ - { - link: MetabaseSettings.docsUrl("enterprise-guide/whitelabeling"), - }, - ], - }, - "audit-app": { - name: t`Auditing`, - description: t`Keep an eye on performance and behavior with robust auditing tools.`, - icon: "clipboard", - info: [{ link: "https://metabase.com/enterprise/" }], - }, - sso: { - name: t`Single sign-on`, - description: t`Provide easy login that works with your exisiting authentication infrastructure.`, - icon: "group", - docs: [ - { - title: "SAML", - link: MetabaseSettings.docsUrl( - "enterprise-guide/authenticating-with-saml", - ), - }, - { - title: "JWT", - link: MetabaseSettings.docsUrl( - "enterprise-guide/authenticating-with-jwt", - ), - }, - ], - }, -}; - -export default FEATURES; diff --git a/enterprise/frontend/src/metabase-enterprise/store/routes.jsx b/enterprise/frontend/src/metabase-enterprise/store/routes.jsx deleted file mode 100644 index ef015eb5f1a..00000000000 --- a/enterprise/frontend/src/metabase-enterprise/store/routes.jsx +++ /dev/null @@ -1,17 +0,0 @@ -import React from "react"; -import { IndexRoute } from "react-router"; -import { t } from "ttag"; - -import { Route } from "metabase/hoc/Title"; - -import StoreActivate from "./containers/StoreActivate"; -import StoreAccount from "./containers/StoreAccount"; - -export default function getRoutes() { - return ( - <Route key="store" path="store" title={t`Store`}> - <IndexRoute component={StoreAccount} /> - <Route path="activate" component={StoreActivate} /> - </Route> - ); -} diff --git a/frontend/src/metabase/admin/routes.jsx b/frontend/src/metabase/admin/routes.jsx index ab0a6bb5c80..e19ac63c93f 100644 --- a/frontend/src/metabase/admin/routes.jsx +++ b/frontend/src/metabase/admin/routes.jsx @@ -20,6 +20,7 @@ import UserActivationModal from "metabase/admin/people/containers/UserActivation // Settings import SettingsEditorApp from "metabase/admin/settings/containers/SettingsEditorApp"; +import PremiumEmbeddingLicensePage from "metabase/admin/settings/containers/PremiumEmbeddingLicensePage"; // DB Add / list import DatabaseListApp from "metabase/admin/databases/containers/DatabaseListApp"; @@ -141,6 +142,10 @@ const getRoutes = (store, IsAdmin) => ( {/* SETTINGS */} <Route path="settings" title={t`Settings`}> <IndexRedirect to="setup" /> + <Route + path="premium-embedding-license" + component={PremiumEmbeddingLicensePage} + /> <Route path="*" component={SettingsEditorApp} /> </Route> diff --git a/frontend/src/metabase/admin/settings/components/LicenseInput/LicenseInput.styled.tsx b/frontend/src/metabase/admin/settings/components/LicenseInput/LicenseInput.styled.tsx new file mode 100644 index 00000000000..6281b0e0c87 --- /dev/null +++ b/frontend/src/metabase/admin/settings/components/LicenseInput/LicenseInput.styled.tsx @@ -0,0 +1,21 @@ +import TextInput from "metabase/components/TextInput"; +import { color } from "metabase/lib/colors"; +import styled from "styled-components"; + +export const LicenseInputContainer = styled.div` + display: flex; + flex-wrap: nowrap; + // min-width: 680px; + width: 680px; +`; + +export const LicenseTextInput = styled(TextInput)` + flex-grow: 1; + margin-right: 8px; +`; + +export const LicenseErrorMessage = styled.div` + margin-top: 8px; + white-space: nowrap; + color: ${color("error")}; +`; diff --git a/frontend/src/metabase/admin/settings/components/LicenseInput/LicenseInput.tsx b/frontend/src/metabase/admin/settings/components/LicenseInput/LicenseInput.tsx new file mode 100644 index 00000000000..6858cbe3173 --- /dev/null +++ b/frontend/src/metabase/admin/settings/components/LicenseInput/LicenseInput.tsx @@ -0,0 +1,62 @@ +import React, { useState } from "react"; +import { t } from "ttag"; +import Button from "metabase/components/Button"; +import { + LicenseErrorMessage, + LicenseTextInput, + LicenseInputContainer, +} from "./LicenseInput.styled"; + +export interface LicenseInputProps { + token?: string; + error?: string; + onUpdate: (license: string) => void; + loading?: boolean; + invalid?: boolean; + placeholder?: string; +} + +export const LicenseInput = ({ + token, + error, + onUpdate, + loading, + invalid, + placeholder, +}: LicenseInputProps) => { + const [value, setValue] = useState(token ?? ""); + + const handleChange = (value: string) => setValue(value); + + const handleActivate = () => { + onUpdate(value); + }; + + return ( + <> + <LicenseInputContainer> + <LicenseTextInput + invalid={invalid} + data-testid="license-input" + disabled={loading} + onChange={handleChange} + value={value} + placeholder={ + placeholder ?? + "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" + } + /> + <Button + disabled={loading} + data-testid="activate-button" + className="px2" + onClick={handleActivate} + > + {t`Activate`} + </Button> + </LicenseInputContainer> + + {error && <LicenseErrorMessage>{error}</LicenseErrorMessage>} + </> + ); +}; diff --git a/frontend/src/metabase/admin/settings/components/LicenseInput/index.ts b/frontend/src/metabase/admin/settings/components/LicenseInput/index.ts new file mode 100644 index 00000000000..6bfecdf1a76 --- /dev/null +++ b/frontend/src/metabase/admin/settings/components/LicenseInput/index.ts @@ -0,0 +1 @@ +export * from "./LicenseInput"; diff --git a/frontend/src/metabase/admin/settings/components/SettingsLicense/SettingsLicense.styled.tsx b/frontend/src/metabase/admin/settings/components/SettingsLicense/SettingsLicense.styled.tsx new file mode 100644 index 00000000000..ffca32d77d1 --- /dev/null +++ b/frontend/src/metabase/admin/settings/components/SettingsLicense/SettingsLicense.styled.tsx @@ -0,0 +1,50 @@ +import styled from "styled-components"; +import { color } from "metabase/lib/colors"; + +export const SectionHeader = styled.h4` + display: block; + color: ${color("text-medium")}; + font-weight: bold; + text-transform: uppercase; + margin-bottom: 8px; + + &:not(:first-child) { + margin-top: 40px; + } +`; + +export const SectionDescription = styled.p` + color: ${color("text-medium")}; + margin-top: 8px; + margin-bottom: 16px; + line-height: 1.7em; +`; + +export const SubHeader = styled.h4` + margin-top: 32px; +`; + +interface ExporePaidPlansContainerProps { + justifyContent?: string; +} + +export const ExporePaidPlansContainer = styled.div< + ExporePaidPlansContainerProps +>` + margin: 16px 0; + display: flex; + align-items: flex-start; + justify-content: ${props => props.justifyContent ?? "space-between"}; + border-bottom: 1px solid ${color("border")}; +`; + +export const SettingsLicenseContainer = styled.div` + width: 580px; + padding: 16px; +`; + +export const LoaderContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; +`; diff --git a/frontend/src/metabase/admin/settings/components/SettingsLicense/SettingsLicense.tsx b/frontend/src/metabase/admin/settings/components/SettingsLicense/SettingsLicense.tsx new file mode 100644 index 00000000000..7737e5519ac --- /dev/null +++ b/frontend/src/metabase/admin/settings/components/SettingsLicense/SettingsLicense.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import MetabaseSettings from "metabase/lib/settings"; +import { StarterContent } from "./content/StarterContent"; +import { UnlicensedContent } from "./content/UnlicensedContent"; +import { PLUGIN_LICENSE_PAGE } from "metabase/plugins"; +import { SettingsLicenseContainer } from "./SettingsLicense.styled"; + +const SettingsLicense = () => { + const isOss = + !MetabaseSettings.isHosted() && !MetabaseSettings.isEnterprise(); + + if (isOss) { + return ( + <SettingsLicenseContainer> + <UnlicensedContent /> + </SettingsLicenseContainer> + ); + } + + const isStarter = + MetabaseSettings.isHosted() && !MetabaseSettings.isEnterprise(); + + if (isStarter) { + return ( + <SettingsLicenseContainer> + <StarterContent /> + </SettingsLicenseContainer> + ); + } + + return <PLUGIN_LICENSE_PAGE.LicenseAndBillingSettings />; +}; + +export default SettingsLicense; diff --git a/frontend/src/metabase/admin/settings/components/SettingsLicense/content/ExplorePlansIllustration.tsx b/frontend/src/metabase/admin/settings/components/SettingsLicense/content/ExplorePlansIllustration.tsx new file mode 100644 index 00000000000..7f5b2b5bd3d --- /dev/null +++ b/frontend/src/metabase/admin/settings/components/SettingsLicense/content/ExplorePlansIllustration.tsx @@ -0,0 +1,155 @@ +import React from "react"; + +export const ExplorePlansIllustration = () => { + return ( + <svg + width="287" + height="243" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + <path + fillRule="evenodd" + clipRule="evenodd" + d="M249.785 100.5c-11.319 8.467-31.524 7.885-47.417-7.997-16.102-16.091-17.121-39.65-8.523-51.249-.388.4-.742.815-1.06 1.246-14.465 15.567-12.372 41.47 1.97 55.803 15.944 14.759 41.13 16.047 55.03 2.197Z" + fill="#EEF6FD" + /> + <path + d="m267.081 41.083.515-.213-.214-.514-.963-2.317v-.001a.757.757 0 0 1-.058-.29.762.762 0 0 1 1.124-.658.766.766 0 0 1 .345.375l.961 2.317.213.513.514-.212 2.317-.957a.766.766 0 0 1 .972.995.765.765 0 0 1-.387.41l-2.316.957-.515.213.214.514.964 2.32.004.008.004.009a.773.773 0 0 1 .015.594.75.75 0 0 1-.419.425.769.769 0 0 1-1.009-.438l-.004-.01-.004-.008-.964-2.32-.212-.51-.513.21-2.324.954a.77.77 0 0 1-1-.41.765.765 0 0 1 .415-.995l2.325-.961ZM143.081 58.083l.515-.213-.214-.514-.963-2.317v-.001a.757.757 0 0 1-.058-.29.762.762 0 0 1 1.124-.658.766.766 0 0 1 .345.375l.961 2.317.213.513.514-.212 2.317-.957a.766.766 0 0 1 .972.995.765.765 0 0 1-.387.41l-2.316.957-.515.213.214.514.964 2.32.004.008.004.009a.773.773 0 0 1 .015.594.75.75 0 0 1-.419.425.769.769 0 0 1-1.009-.438l-.004-.01-.004-.008-.964-2.32-.212-.51-.513.21-2.324.954a.77.77 0 0 1-1-.41.765.765 0 0 1 .415-.995l2.325-.961ZM210.081 143.083l.515-.213-.214-.515-.963-2.316v-.001a.758.758 0 0 1-.058-.29.759.759 0 0 1 .617-.734.768.768 0 0 1 .852.451l.961 2.317.213.513.514-.212 2.317-.957a.766.766 0 0 1 .967.425.764.764 0 0 1 .005.57.768.768 0 0 1-.387.41l-2.316.957-.515.213.214.514.964 2.319.004.009.004.009a.77.77 0 0 1 .015.594.754.754 0 0 1-.419.425.767.767 0 0 1-1.009-.438l-.004-.009-.004-.009-.964-2.319-.212-.512-.513.211-2.324.954a.768.768 0 0 1-1-.41.764.764 0 0 1 .415-.995l2.325-.961Z" + fill="#509EE3" + stroke="#509EE3" + /> + <path + d="M260.126 91.41c.798-1.267-.305-2.817-1.721-2.58a45.925 45.925 0 0 1-11.92.419c-25.367-2.402-44.906-25.017-42.5-50.437.144-1.52.359-3.015.643-4.483.272-1.414-1.254-2.55-2.539-1.783-11.169 6.662-19.131 18.411-20.455 32.394-2.2 23.234 14.762 43.857 37.902 46.048 16.315 1.545 32.355-6.493 40.59-19.577Z" + stroke="#509EE3" + strokeWidth="3" + /> + <path + d="M193.654 9.179a.826.826 0 0 1-.835.834c-4.141 0-7.515 3.371-7.515 7.51a.826.826 0 0 1-.835.834.838.838 0 0 1-.835-.834c0-4.139-3.373-7.51-7.514-7.51a.837.837 0 0 1-.835-.834c0-.468.384-.835.835-.835 4.141 0 7.514-3.37 7.514-7.51 0-.467.385-.834.835-.834.468 0 .835.367.835.834 0 4.14 3.374 7.51 7.515 7.51.468 0 .835.367.835.835ZM162.654 122.179a.826.826 0 0 1-.835.834c-4.141 0-7.515 3.371-7.515 7.51a.826.826 0 0 1-.835.834.838.838 0 0 1-.835-.834c0-4.139-3.373-7.51-7.514-7.51a.837.837 0 0 1-.835-.834c0-.468.384-.835.835-.835 4.141 0 7.514-3.371 7.514-7.51 0-.467.385-.834.835-.834.468 0 .835.367.835.834 0 4.139 3.374 7.51 7.515 7.51.468 0 .835.367.835.835ZM286.654 122.179a.826.826 0 0 1-.835.834c-4.141 0-7.515 3.371-7.515 7.51a.826.826 0 0 1-.835.834.838.838 0 0 1-.835-.834c0-4.139-3.373-7.51-7.514-7.51a.837.837 0 0 1-.835-.834c0-.468.384-.835.835-.835 4.141 0 7.514-3.371 7.514-7.51 0-.467.385-.834.835-.834.468 0 .835.367.835.834 0 4.139 3.374 7.51 7.515 7.51.468 0 .835.367.835.835Z" + fill="#E6F5FF" + /> + <rect + x="94.285" + y="127.788" + width="16.829" + height="9.963" + rx="3" + transform="rotate(-23.79 94.285 127.788)" + fill="#EEF6FD" + /> + <rect + x="1.285" + y="183.422" + width="24.349" + height="7.835" + rx="3" + transform="rotate(-23.79 1.285 183.422)" + fill="#EEF6FD" + /> + <path + fill="#EEF6FD" + d="m18.635 169.492 44.577-19.65 3.923 8.9-44.576 19.65z" + /> + <path + fill="#EEF6FD" + d="m58.775 147.087 39.567-17.443 4.445 10.083L63.22 157.17z" + /> + <rect + x="96.262" + y="128.556" + width="13.829" + height="37.945" + rx="1.5" + transform="rotate(-23.79 96.262 128.556)" + stroke="#509EE3" + strokeWidth="3" + /> + <rect + x="60.752" + y="147.855" + width="40.241" + height="31.741" + rx="1.5" + transform="rotate(-23.79 60.752 147.855)" + stroke="#509EE3" + strokeWidth="3" + /> + <rect + x="20.613" + y="170.26" + width="45.716" + height="23.651" + rx="1.5" + transform="rotate(-23.79 20.613 170.26)" + stroke="#509EE3" + strokeWidth="3" + /> + <rect + x="3.262" + y="184.19" + width="21.349" + height="13.164" + rx="1.5" + transform="rotate(-23.79 3.262 184.19)" + stroke="#509EE3" + strokeWidth="3" + /> + <rect + x="1.33" + y="181.685" + width="1.586" + height="19.132" + rx=".793" + transform="rotate(-23.79 1.33 181.685)" + stroke="#509EE3" + strokeWidth="1.586" + /> + <rect + x="72.311" + y="185.602" + width="1.586" + height="60.501" + rx=".793" + transform="rotate(-21.29 72.311 185.602)" + stroke="#509EE3" + strokeWidth="1.586" + /> + <rect + x="63.007" + y="184.024" + width="1.586" + height="60.501" + rx=".793" + transform="rotate(21.017 63.007 184.024)" + stroke="#509EE3" + strokeWidth="1.586" + /> + <rect + x="113.535" + y="240.75" + width="1.5" + height="90.5" + rx=".75" + transform="rotate(90 113.535 240.75)" + stroke="#509EE3" + strokeWidth="1.5" + /> + <circle + cx="68.285" + cy="175" + r="10.5" + fill="#fff" + stroke="#509EE3" + strokeWidth="3" + /> + <circle + cx="92.785" + cy="237.5" + r="4.3" + fill="#fff" + stroke="#509EE3" + strokeWidth="2.4" + /> + </svg> + ); +}; diff --git a/frontend/src/metabase/admin/settings/components/SettingsLicense/content/StarterContent.tsx b/frontend/src/metabase/admin/settings/components/SettingsLicense/content/StarterContent.tsx new file mode 100644 index 00000000000..92d596e6a40 --- /dev/null +++ b/frontend/src/metabase/admin/settings/components/SettingsLicense/content/StarterContent.tsx @@ -0,0 +1,44 @@ +import React from "react"; +import { t, jt } from "ttag"; +import ExternalLink from "metabase/components/ExternalLink"; +import MetabaseSettings from "metabase/lib/settings"; +import { ExplorePlansIllustration } from "./ExplorePlansIllustration"; +import { + ExporePaidPlansContainer, + SectionDescription, + SectionHeader, + SettingsLicenseContainer, +} from "../SettingsLicense.styled"; + +export const StarterContent = () => { + return ( + <SettingsLicenseContainer> + <SectionHeader>{t`Billing`}</SectionHeader> + + <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> + + <SectionHeader>{t`Looking for more?`}</SectionHeader> + + <SectionDescription> + {jt`You can get priority support, more tools to help you share your insights with your teams and powerful options to help you create seamless, interactive data experiences for your customers with ${( + <ExternalLink href={MetabaseSettings.upgradeUrl()}> + {t`our other paid plans.`} + </ExternalLink> + )}`} + </SectionDescription> + + <ExporePaidPlansContainer justifyContent="flex-end"> + <ExplorePlansIllustration /> + </ExporePaidPlansContainer> + </SettingsLicenseContainer> + ); +}; diff --git a/frontend/src/metabase/admin/settings/components/SettingsLicense/content/UnlicensedContent.tsx b/frontend/src/metabase/admin/settings/components/SettingsLicense/content/UnlicensedContent.tsx new file mode 100644 index 00000000000..66929c1b3af --- /dev/null +++ b/frontend/src/metabase/admin/settings/components/SettingsLicense/content/UnlicensedContent.tsx @@ -0,0 +1,35 @@ +import React from "react"; +import { t } from "ttag"; +import ExternalLink from "metabase/components/ExternalLink"; +import MetabaseSettings from "metabase/lib/settings"; +import { + ExporePaidPlansContainer, + SectionDescription, + SectionHeader, + SettingsLicenseContainer, + SubHeader, +} from "../SettingsLicense.styled"; +import { ExplorePlansIllustration } from "./ExplorePlansIllustration"; + +const description = t`Metabase is open source and will be free forever – but by upgrading you can have priority support, more tools to help you share your insights with your teams and powerful options to help you create seamless, interactive data experiences for your customers.`; + +export const UnlicensedContent = () => { + return ( + <> + <SectionHeader>{t`Looking for more?`}</SectionHeader> + + <SectionDescription>{description}</SectionDescription> + + <SubHeader>{t`Want to know more?`}</SubHeader> + + <ExporePaidPlansContainer> + <ExternalLink + className="Button Button--primary" + href={MetabaseSettings.upgradeUrl()} + >{t`Explore our paid plans`}</ExternalLink> + + <ExplorePlansIllustration /> + </ExporePaidPlansContainer> + </> + ); +}; diff --git a/frontend/src/metabase/admin/settings/components/SettingsLicense/index.ts b/frontend/src/metabase/admin/settings/components/SettingsLicense/index.ts new file mode 100644 index 00000000000..6ccb81f331c --- /dev/null +++ b/frontend/src/metabase/admin/settings/components/SettingsLicense/index.ts @@ -0,0 +1,2 @@ +export { default } from "./SettingsLicense"; +export * from "./SettingsLicense.styled"; diff --git a/frontend/src/metabase/admin/settings/components/widgets/EmbeddingCustomizationInfo.tsx b/frontend/src/metabase/admin/settings/components/widgets/EmbeddingCustomizationInfo.tsx index e77351dca71..be60c968231 100644 --- a/frontend/src/metabase/admin/settings/components/widgets/EmbeddingCustomizationInfo.tsx +++ b/frontend/src/metabase/admin/settings/components/widgets/EmbeddingCustomizationInfo.tsx @@ -11,7 +11,7 @@ export const EmbeddingCustomizationInfo = () => { <p style={{ maxWidth: "460px" }}> {jt`Looking to remove the “Powered by Metabase†logo, customize colors and make it your own? ${( - <ExternalLink href={MetabaseSettings.pricingUrl()}> + <ExternalLink href={MetabaseSettings.upgradeUrl()}> Explore our paid plans. </ExternalLink> )}`} diff --git a/frontend/src/metabase/admin/settings/components/widgets/PremiumEmbeddingLinkWidget/PremiumEmbeddingLinkWidget.styled.tsx b/frontend/src/metabase/admin/settings/components/widgets/PremiumEmbeddingLinkWidget/PremiumEmbeddingLinkWidget.styled.tsx new file mode 100644 index 00000000000..93be1e44751 --- /dev/null +++ b/frontend/src/metabase/admin/settings/components/widgets/PremiumEmbeddingLinkWidget/PremiumEmbeddingLinkWidget.styled.tsx @@ -0,0 +1,10 @@ +import styled from "styled-components"; +import { color } from "metabase/lib/colors"; + +export const PremiumEmbeddingLinkWidgetRoot = styled.div` + color: ${color("text-medium")}; + padding-top: 1rem; + width: 100%; + max-width: 38.75rem; + border-top: 1px solid ${color("border")}; +`; diff --git a/frontend/src/metabase/admin/settings/components/widgets/PremiumEmbeddingLinkWidget/PremiumEmbeddingLinkWidget.tsx b/frontend/src/metabase/admin/settings/components/widgets/PremiumEmbeddingLinkWidget/PremiumEmbeddingLinkWidget.tsx new file mode 100644 index 00000000000..f465f36faba --- /dev/null +++ b/frontend/src/metabase/admin/settings/components/widgets/PremiumEmbeddingLinkWidget/PremiumEmbeddingLinkWidget.tsx @@ -0,0 +1,16 @@ +import React from "react"; +import { t } from "ttag"; +import Link from "metabase/components/Link"; +import { PremiumEmbeddingLinkWidgetRoot } from "./PremiumEmbeddingLinkWidget.styled"; + +export const PremiumEmbeddingLinkWidget = () => { + return ( + <PremiumEmbeddingLinkWidgetRoot> + {t`Have a Premium Embedding license?`}{" "} + <Link + to="/admin/settings/premium-embedding-license" + className="link" + >{t`Activate it here.`}</Link> + </PremiumEmbeddingLinkWidgetRoot> + ); +}; diff --git a/frontend/src/metabase/admin/settings/components/widgets/PremiumEmbeddingLinkWidget/index.ts b/frontend/src/metabase/admin/settings/components/widgets/PremiumEmbeddingLinkWidget/index.ts new file mode 100644 index 00000000000..c0227b847b5 --- /dev/null +++ b/frontend/src/metabase/admin/settings/components/widgets/PremiumEmbeddingLinkWidget/index.ts @@ -0,0 +1 @@ +export * from "./PremiumEmbeddingLinkWidget"; diff --git a/frontend/src/metabase/admin/settings/containers/PremiumEmbeddingLicensePage/PremiumEmbeddingLicensePage.styled.tsx b/frontend/src/metabase/admin/settings/containers/PremiumEmbeddingLicensePage/PremiumEmbeddingLicensePage.styled.tsx new file mode 100644 index 00000000000..a54f278120a --- /dev/null +++ b/frontend/src/metabase/admin/settings/containers/PremiumEmbeddingLicensePage/PremiumEmbeddingLicensePage.styled.tsx @@ -0,0 +1,37 @@ +import LoadingSpinner from "metabase/components/LoadingSpinner"; +import { color } from "metabase/lib/colors"; +import styled from "styled-components"; + +export const PremiumEmbeddingLicensePageContent = styled.div` + display: flex; + align-items: stretch; + text-align: left; + justify-content: center; + flex-direction: column; + margin-left: 10%; + margin-top: 32px; + max-width: 640px; +`; + +export const PremiumEmbeddingHeading = styled.h1` + font-weight: 700; + font-size: 21px; + line-height: 25px; +`; + +export const PremiumEmbeddingDescription = styled.p` + color: ${color("text-medium")}; + margin-bottom: 2rem; + font-size: 14px; + line-height: 24px; +`; + +export const LicenseInputTitle = styled.div` + font-weight: 700; + margin-bottom: 1rem; +`; + +export const Loader = styled(LoadingSpinner)` + display: flex; + justify-content: center; +`; diff --git a/frontend/src/metabase/admin/settings/containers/PremiumEmbeddingLicensePage/PremiumEmbeddingLicensePage.tsx b/frontend/src/metabase/admin/settings/containers/PremiumEmbeddingLicensePage/PremiumEmbeddingLicensePage.tsx new file mode 100644 index 00000000000..42fdad59712 --- /dev/null +++ b/frontend/src/metabase/admin/settings/containers/PremiumEmbeddingLicensePage/PremiumEmbeddingLicensePage.tsx @@ -0,0 +1,124 @@ +import React, { useEffect } from "react"; +import { jt, t } from "ttag"; +import { connect } from "react-redux"; +import moment from "moment"; +import AdminLayout from "metabase/components/AdminLayout"; +import ExternalLink from "metabase/components/ExternalLink"; +import MetabaseSettings from "metabase/lib/settings"; +import { + LicenseInputTitle, + Loader, + PremiumEmbeddingDescription, + PremiumEmbeddingHeading, + PremiumEmbeddingLicensePageContent, +} from "./PremiumEmbeddingLicensePage.styled"; +import { LicenseInput } from "../../components/LicenseInput"; +import { initializeSettings } from "../../settings"; +import { getSettings } from "../../selectors"; +import { TokenStatus, useLicense } from "../../hooks/use-license"; + +const getDescription = (tokenStatus?: TokenStatus, hasToken?: boolean) => { + if (!hasToken) { + return t`Our Premium Embedding product has been discontinued, but if you already have a license you can activate it here. You’ll continue to receive support for the duration of your license.`; + } + + if (!tokenStatus || !tokenStatus.isValid) { + return ( + <> + {jt`Your Premium Embedding license isn’t valid anymore. ${( + <ExternalLink href={MetabaseSettings.upgradeUrl()}> + {t`Explore our paid plans.`} + </ExternalLink> + )}`} + </> + ); + } + + const validUntil = moment(tokenStatus.validUntil).format("MMM D, YYYY"); + + return t`Your Premium Embedding license is active until ${validUntil}.`; +}; + +const mapStateToProps = (state: any) => { + return { + settings: getSettings(state), + }; +}; + +const mapDispatchToProps = { + initializeSettings, +}; + +interface PremiumEmbeddingLicensePage { + initializeSettings: () => void; + settings: any[]; +} + +export const PremiumEmbeddingLicensePage = ({ + settings, + initializeSettings, +}: PremiumEmbeddingLicensePage) => { + const tokenSetting = settings.find( + setting => setting.key === "premium-embedding-token", + ); + const token = tokenSetting?.value; + + const { + isLoading, + error, + tokenStatus, + updateToken, + isUpdating, + } = useLicense(); + + useEffect(() => { + initializeSettings(); + }, [initializeSettings]); + + const hasSettings = settings.length > 0; + + if (isLoading || !hasSettings) { + return ( + <AdminLayout> + <PremiumEmbeddingLicensePageContent> + <Loader /> + </PremiumEmbeddingLicensePageContent> + </AdminLayout> + ); + } + + const isInvalid = !!error || (tokenStatus != null && !tokenStatus.isValid); + + const placeholder = tokenSetting.is_env_setting + ? t`Using ${tokenSetting.env_name}` + : undefined; + + return ( + <AdminLayout> + <PremiumEmbeddingLicensePageContent> + <PremiumEmbeddingHeading>Premium embedding</PremiumEmbeddingHeading> + <PremiumEmbeddingDescription> + {getDescription(tokenStatus, !!token)} + </PremiumEmbeddingDescription> + {!tokenStatus?.isValid && ( + <LicenseInputTitle> + {t`Enter the token you bought from the Metabase Store below.`} + </LicenseInputTitle> + )} + <LicenseInput + error={error} + loading={isUpdating} + token={token} + onUpdate={updateToken} + invalid={isInvalid} + placeholder={placeholder} + /> + </PremiumEmbeddingLicensePageContent> + </AdminLayout> + ); +}; + +export default connect( + mapStateToProps, + mapDispatchToProps, +)(PremiumEmbeddingLicensePage); diff --git a/frontend/src/metabase/admin/settings/containers/PremiumEmbeddingLicensePage/index.ts b/frontend/src/metabase/admin/settings/containers/PremiumEmbeddingLicensePage/index.ts new file mode 100644 index 00000000000..c6710761ddf --- /dev/null +++ b/frontend/src/metabase/admin/settings/containers/PremiumEmbeddingLicensePage/index.ts @@ -0,0 +1 @@ +export { default } from "./PremiumEmbeddingLicensePage"; diff --git a/frontend/src/metabase/admin/settings/hooks/use-license.ts b/frontend/src/metabase/admin/settings/hooks/use-license.ts new file mode 100644 index 00000000000..aa9ca387588 --- /dev/null +++ b/frontend/src/metabase/admin/settings/hooks/use-license.ts @@ -0,0 +1,81 @@ +import { SettingsApi, StoreApi } from "metabase/services"; +import { useCallback, useEffect, useState } from "react"; +import { t } from "ttag"; + +export const LICENSE_ACCEPTED_URL_HASH = "#activated"; + +const INVALID_TOKEN_ERROR = t`This token doesn't seem to be valid. Double-check it, then contact support if you think it should be working.`; +const UNABLE_TO_VALIDATE_TOKEN = t`We're having trouble validating your token. Please double-check that your instance can connect to Metabase's servers.`; + +export type TokenStatus = { + validUntil: Date; + isValid: boolean; + isTrial: boolean; + features: string[]; +}; + +export const useLicense = (onActivated?: () => void) => { + const [tokenStatus, setTokenStatus] = useState<TokenStatus>(); + const [isLoading, setIsLoading] = useState(true); + const [isUpdating, setIsUpdating] = useState(false); + const [error, setError] = useState<string>(); + + useEffect(() => { + if (window.location.hash === LICENSE_ACCEPTED_URL_HASH) { + history.pushState("", document.title, window.location.pathname); + onActivated?.(); + } + }, [onActivated]); + + const updateToken = useCallback(async (token: string) => { + try { + setError(undefined); + setIsUpdating(true); + await SettingsApi.put({ + key: "premium-embedding-token", + value: token, + }); + + // In order to apply pro and enterprise features we need to perform a full reload + const isValidTokenAccepted = token.trim().length > 0; + if (isValidTokenAccepted) { + window.location.href += LICENSE_ACCEPTED_URL_HASH; + } + window.location.reload(); + } catch { + setError(INVALID_TOKEN_ERROR); + } finally { + setIsUpdating(false); + } + }, []); + + useEffect(() => { + const fetchStatus = async () => { + try { + const response = await StoreApi.tokenStatus(); + setTokenStatus({ + validUntil: new Date(response["valid-thru"]), + isValid: response.valid, + isTrial: response.trial, + features: response.features, + }); + } catch (e) { + if ((e as any).status !== 404) { + setError(UNABLE_TO_VALIDATE_TOKEN); + } + } finally { + setIsLoading(false); + } + }; + + fetchStatus(); + }, []); + + return { + isUpdating, + error, + tokenStatus, + isLoading, + updateToken, + }; +}; diff --git a/frontend/src/metabase/admin/settings/selectors.js b/frontend/src/metabase/admin/settings/selectors.js index b1e6b2292ef..a00972ca564 100644 --- a/frontend/src/metabase/admin/settings/selectors.js +++ b/frontend/src/metabase/admin/settings/selectors.js @@ -3,6 +3,7 @@ import { createSelector } from "reselect"; import MetabaseSettings from "metabase/lib/settings"; import { t } from "ttag"; import CustomGeoJSONWidget from "./components/widgets/CustomGeoJSONWidget"; +import SettingsLicense from "./components/SettingsLicense"; import SiteUrlWidget from "./components/widgets/SiteUrlWidget"; import HttpsOnlyWidget from "./components/widgets/HttpsOnlyWidget"; import { EmbeddingCustomizationInfo } from "./components/widgets/EmbeddingCustomizationInfo"; @@ -15,6 +16,7 @@ import { import SecretKeyWidget from "./components/widgets/SecretKeyWidget"; import EmbeddingLegalese from "./components/widgets/EmbeddingLegalese"; import FormattingWidget from "./components/widgets/FormattingWidget"; +import { PremiumEmbeddingLinkWidget } from "./components/widgets/PremiumEmbeddingLinkWidget"; import SettingsUpdatesForm from "./components/SettingsUpdatesForm/SettingsUpdatesForm"; import SettingsEmailForm from "./components/SettingsEmailForm"; import SettingsSetupList from "./components/SettingsSetupList"; @@ -365,11 +367,22 @@ const SECTIONS = updateSectionsWithPlugins({ widget: EmbeddedQuestionListing, getHidden: settings => !settings["enable-embedding"], }, + { + widget: PremiumEmbeddingLinkWidget, + getHidden: settings => + !settings["enable-embedding"] || MetabaseSettings.isEnterprise(), + }, ], }, + license: { + name: MetabaseSettings.isPaidPlan() ? t`License and billing` : t`License`, + order: 11, + component: SettingsLicense, + settings: [], + }, caching: { name: t`Caching`, - order: 11, + order: 12, settings: [ { key: "enable-query-caching", diff --git a/frontend/src/metabase/admin/settings/settings.js b/frontend/src/metabase/admin/settings/settings.js index 808443e707e..a514b1a685a 100644 --- a/frontend/src/metabase/admin/settings/settings.js +++ b/frontend/src/metabase/admin/settings/settings.js @@ -4,9 +4,7 @@ import { handleActions, combineReducers, } from "metabase/lib/redux"; - import { SettingsApi, EmailApi, SlackApi, LdapApi } from "metabase/services"; - import { refreshSiteSettings } from "metabase/redux/settings"; // ACITON TYPES AND ACTION CREATORS diff --git a/frontend/src/metabase/components/TextInput/TextInput.styled.jsx b/frontend/src/metabase/components/TextInput/TextInput.styled.tsx similarity index 71% rename from frontend/src/metabase/components/TextInput/TextInput.styled.jsx rename to frontend/src/metabase/components/TextInput/TextInput.styled.tsx index e4c7e12911f..86a4c7b9e72 100644 --- a/frontend/src/metabase/components/TextInput/TextInput.styled.jsx +++ b/frontend/src/metabase/components/TextInput/TextInput.styled.tsx @@ -1,5 +1,6 @@ import styled, { css } from "styled-components"; import { color } from "metabase/lib/colors"; +import { ColorScheme, Size } from "./TextInput"; const PADDING = { sm: "0.5rem", @@ -17,9 +18,26 @@ const BORDER_COLOR = { transparent: () => "transparent", }; -export const Input = styled.input` +interface InputProps { + colorScheme: ColorScheme; + borderRadius: Size; + padding: Size; + hasClearButton?: boolean; + hasIcon?: boolean; + invalid?: boolean; +} + +const getBorderColor = (colorScheme: ColorScheme, invalid?: boolean) => { + if (invalid) { + return color("error"); + } + + return colorScheme === "transparent" ? "transparent" : color("border"); +}; + +export const Input = styled.input<InputProps>` border: 1px solid ${props => - props.colorScheme === "transparent" ? "transparent" : color("border")}; + getBorderColor(props.colorScheme, props.invalid)}; outline: none; width: 100%; font-size: 1.12em; @@ -29,6 +47,10 @@ export const Input = styled.input` background-color: ${props => props.colorScheme === "transparent" ? "transparent" : color("white")}; + &:disabled { + background-color: ${color("bg-light")}; + } + ${({ borderRadius, padding }) => css` border-radius: ${BORDER_RADIUS[borderRadius]}; padding: ${PADDING[padding]}; diff --git a/frontend/src/metabase/components/TextInput/TextInput.jsx b/frontend/src/metabase/components/TextInput/TextInput.tsx similarity index 65% rename from frontend/src/metabase/components/TextInput/TextInput.jsx rename to frontend/src/metabase/components/TextInput/TextInput.tsx index 66349a48d13..85b069714ae 100644 --- a/frontend/src/metabase/components/TextInput/TextInput.jsx +++ b/frontend/src/metabase/components/TextInput/TextInput.tsx @@ -1,5 +1,4 @@ import React, { forwardRef } from "react"; -import PropTypes from "prop-types"; import { t } from "ttag"; import Icon from "metabase/components/Icon"; @@ -11,21 +10,22 @@ import { Input, } from "./TextInput.styled"; -TextInput.propTypes = { - onChange: PropTypes.func.isRequired, - placeholder: PropTypes.string, - value: PropTypes.string, - type: PropTypes.string, - autoFocus: PropTypes.bool, - className: PropTypes.string, - hasClearButton: PropTypes.bool, - icon: PropTypes.node, - padding: PropTypes.oneOf(["sm", "md"]), - borderRadius: PropTypes.oneOf(["sm", "md"]), - colorScheme: PropTypes.oneOf(["default", "admin", "transparent"]), - hasBorder: PropTypes.bool, - innerRef: PropTypes.object, -}; +export type ColorScheme = "default" | "admin" | "transparent"; +export type Size = "sm" | "md"; + +type TextInputProps = { + value?: string; + placeholder?: string; + onChange: (value: string) => void; + hasClearButton?: boolean; + icon?: React.ReactNode; + colorScheme?: ColorScheme; + autoFocus?: boolean; + padding?: Size; + borderRadius?: Size; + innerRef?: any; + invalid?: boolean; +} & Omit<React.HTMLProps<HTMLInputElement>, "onChange">; function TextInput({ value = "", @@ -40,8 +40,10 @@ function TextInput({ padding = "md", borderRadius = "md", innerRef, + ref, + invalid, ...rest -}) { +}: TextInputProps) { const handleClearClick = () => { onChange(""); }; @@ -63,6 +65,7 @@ function TextInput({ onChange={e => onChange(e.target.value)} padding={padding} borderRadius={borderRadius} + invalid={invalid} {...rest} /> @@ -75,6 +78,8 @@ function TextInput({ ); } -export default forwardRef(function TextInputForwardRef(props, ref) { - return <TextInput {...props} innerRef={ref} />; -}); +export default forwardRef<HTMLInputElement, TextInputProps>( + function TextInputForwardRef(props, ref) { + return <TextInput {...props} innerRef={ref} />; + }, +); diff --git a/frontend/src/metabase/lib/settings.ts b/frontend/src/metabase/lib/settings.ts index e68c8aa3cc5..80b407151b1 100644 --- a/frontend/src/metabase/lib/settings.ts +++ b/frontend/src/metabase/lib/settings.ts @@ -88,7 +88,9 @@ export type SettingName = | "snowplow-enabled" | "snowplow-url" | "engine-deprecation-notice-version" - | "show-database-syncing-modal"; + | "show-database-syncing-modal" + | "premium-embedding-token" + | "metabase-store-managed"; type SettingsMap = Record<SettingName, any>; // provides access to Metabase application settings @@ -154,6 +156,10 @@ class Settings { return this.get("is-hosted?"); } + isStoreManaged(): boolean { + return this.get("metabase-store-managed"); + } + cloudGatewayIps(): string[] { return this.get("cloud-gateway-ips") || []; } @@ -202,6 +208,10 @@ class Settings { return this.currentVersion() !== this.engineDeprecationNoticeVersion(); } + token() { + return this.get("premium-embedding-token"); + } + formattingOptions() { const opts = this.get("custom-formatting"); return opts && opts["type/Temporal"] ? opts["type/Temporal"] : {}; @@ -257,8 +267,8 @@ class Settings { return `https://store.metabase.com/${path}`; } - pricingUrl() { - return "https://www.metabase.com/pricing/"; + upgradeUrl() { + return "https://www.metabase.com/upgrade/"; } newVersionAvailable() { diff --git a/frontend/src/metabase/plugins/index.js b/frontend/src/metabase/plugins/index.js index 37f04eb47b8..36f551313b0 100644 --- a/frontend/src/metabase/plugins/index.js +++ b/frontend/src/metabase/plugins/index.js @@ -107,3 +107,7 @@ export const PLUGIN_ADVANCED_PERMISSIONS = { addTablePermissionOptions: (permissions, _value) => permissions, isBlockPermission: _value => false, }; + +export const PLUGIN_LICENSE_PAGE = { + LicenseAndBillingSettings: PluginPlaceholder, +}; diff --git a/frontend/src/metabase/services.js b/frontend/src/metabase/services.js index a679a2ae083..bf007a454e3 100644 --- a/frontend/src/metabase/services.js +++ b/frontend/src/metabase/services.js @@ -23,6 +23,10 @@ export const GTAPApi = { attributes: GET("/api/mt/user/attributes"), }; +export const StoreApi = { + tokenStatus: GET("/api/premium-features/token/status"), +}; + // Pivot tables need extra data beyond what's described in the MBQL query itself. // To fetch that extra data we rely on specific APIs for pivot tables that mirrow the normal endpoints. // Those endpoints take the query along with `pivot_rows` and `pivot_cols` to return the subtotal data. diff --git a/package.json b/package.json index f406490f072..6a115ff926e 100644 --- a/package.json +++ b/package.json @@ -255,6 +255,7 @@ "dev": "yarn concurrently -n 'backend,frontend,cljs,docs' -c 'blue,green,yellow,magenta' 'clojure -M:run' 'yarn build-hot:js' 'yarn build-hot:cljs' 'yarn docs'", "dev-ee": "yarn concurrently -n 'backend,frontend,cljs,docs' -c 'blue,green,yellow,magenta' 'clojure -M:run:ee' 'MB_EDITION=ee yarn build-hot:js' 'MB_EDITION=ee yarn build-hot:cljs' 'yarn docs'", "type-check": "yarn && tsc --noEmit", + "remove-webpack-cache": "rm -rf ./node_modules/.cache", "lint": "yarn lint-eslint && yarn lint-prettier && yarn lint-docs-links && yarn lint-yaml && yarn type-check", "lint-eslint": "yarn build-quick:cljs && eslint --ext .js --ext .jsx --rulesdir frontend/lint/eslint-rules --max-warnings 0 enterprise/frontend/src frontend/src frontend/test", "lint-prettier": "yarn && prettier -l '{enterprise/,}frontend/**/*.{js,jsx,ts,tsx,css}' || (echo '\nThese files are not formatted correctly. Did you forget to \"yarn prettier\"?' && false)", -- GitLab