From 4652b33046d439fc609b3e6c0624bd3fbf612a4e Mon Sep 17 00:00:00 2001 From: Graham Langford <30706330+grahamlangford@users.noreply.github.com> Date: Fri, 13 Sep 2024 12:37:30 -0500 Subject: [PATCH] #9130: Slice 2 - disable all team mod activate buttons (#9147) * create useGetAllTeamScopes TrialAwareButton * implements TrialAwareButton * adds tests * only show tooltip for disabled team mods * remove unused import * update snapshots * remove unneeded export * remove extraneous period * disable the trial aware button while organizations are loading --- src/extensionConsole/App.tsx | 2 +- .../pages/activateMod/ActivateModCard.tsx | 12 +- .../ActivateModPage.test.tsx.snap | 210 +++++++++-------- src/extensionConsole/pages/mods/Status.tsx | 15 +- .../{ => teamTrials}/TeamTrialBanner.test.tsx | 2 +- .../{ => teamTrials}/TeamTrialBanner.tsx | 13 +- .../teamTrials/TrialAwareButton.test.tsx | 213 ++++++++++++++++++ .../pages/teamTrials/TrialAwareButton.tsx | 103 +++++++++ .../teamTrials/TrialCallToActionLink.tsx | 30 +++ .../pages/teamTrials/useGetAllTeamScopes.ts | 35 +++ .../{ => teamTrials}/useTeamTrialStatus.ts | 4 +- 11 files changed, 516 insertions(+), 123 deletions(-) rename src/extensionConsole/pages/{ => teamTrials}/TeamTrialBanner.test.tsx (98%) rename src/extensionConsole/pages/{ => teamTrials}/TeamTrialBanner.tsx (87%) create mode 100644 src/extensionConsole/pages/teamTrials/TrialAwareButton.test.tsx create mode 100644 src/extensionConsole/pages/teamTrials/TrialAwareButton.tsx create mode 100644 src/extensionConsole/pages/teamTrials/TrialCallToActionLink.tsx create mode 100644 src/extensionConsole/pages/teamTrials/useGetAllTeamScopes.ts rename src/extensionConsole/pages/{ => teamTrials}/useTeamTrialStatus.ts (91%) diff --git a/src/extensionConsole/App.tsx b/src/extensionConsole/App.tsx index 2cf02dd2a8..cdc06f7aa4 100644 --- a/src/extensionConsole/App.tsx +++ b/src/extensionConsole/App.tsx @@ -56,7 +56,7 @@ import DatabaseUnresponsiveBanner from "@/components/DatabaseUnresponsiveBanner" import ActivateModPage from "@/extensionConsole/pages/activateMod/ActivateModPage"; import { RestrictedFeatures } from "@/auth/featureFlags"; import { useLocation } from "react-router"; -import TeamTrialBanner from "@/extensionConsole/pages/TeamTrialBanner"; +import TeamTrialBanner from "@/extensionConsole/pages/teamTrials/TeamTrialBanner"; // Register the built-in bricks registerEditors(); diff --git a/src/extensionConsole/pages/activateMod/ActivateModCard.tsx b/src/extensionConsole/pages/activateMod/ActivateModCard.tsx index 3efb4328fe..5cea122835 100644 --- a/src/extensionConsole/pages/activateMod/ActivateModCard.tsx +++ b/src/extensionConsole/pages/activateMod/ActivateModCard.tsx @@ -18,7 +18,7 @@ import styles from "./ActivateModCard.module.scss"; import React, { useState } from "react"; -import { Button, Card } from "react-bootstrap"; +import { Card } from "react-bootstrap"; import useActivateModWizard from "@/activation/useActivateModWizard"; import BlockFormSubmissionViaEnterIfFirstChild from "@/components/BlockFormSubmissionViaEnterIfFirstChild"; import { useDispatch } from "react-redux"; @@ -44,6 +44,7 @@ import { assertNotNullish } from "@/utils/nullishUtils"; import { Milestones } from "@/data/model/UserMilestone"; import MarketplaceListingIcon from "@/components/MarketplaceListingIcon"; import castError from "@/utils/castError"; +import { TrialAwareButton } from "@/extensionConsole/pages/teamTrials/TrialAwareButton"; const WizardHeader: React.VoidFunctionComponent<{ mod: ModDefinition; @@ -69,10 +70,15 @@ const WizardHeader: React.VoidFunctionComponent<{
{mod.metadata.description}
- +
); diff --git a/src/extensionConsole/pages/activateMod/__snapshots__/ActivateModPage.test.tsx.snap b/src/extensionConsole/pages/activateMod/__snapshots__/ActivateModPage.test.tsx.snap index 15723ceec7..932b4b160c 100644 --- a/src/extensionConsole/pages/activateMod/__snapshots__/ActivateModPage.test.tsx.snap +++ b/src/extensionConsole/pages/activateMod/__snapshots__/ActivateModPage.test.tsx.snap @@ -135,27 +135,29 @@ exports[`ActivateModDefinitionPage activate mod definition permissions 1`] = `
- + + Activate + +
- + + Activate + +
- + + Activate + +
- + + Activate + +
- + + Activate + +
{ @@ -86,13 +87,15 @@ const Status: React.VoidFunctionComponent<{ }} > Activate - + ); } if (hasUpdate && showReactivate && !(sharingSource.type === "Deployment")) { return ( - + Update + ); } diff --git a/src/extensionConsole/pages/TeamTrialBanner.test.tsx b/src/extensionConsole/pages/teamTrials/TeamTrialBanner.test.tsx similarity index 98% rename from src/extensionConsole/pages/TeamTrialBanner.test.tsx rename to src/extensionConsole/pages/teamTrials/TeamTrialBanner.test.tsx index 42a3e4da20..f50d87087c 100644 --- a/src/extensionConsole/pages/TeamTrialBanner.test.tsx +++ b/src/extensionConsole/pages/teamTrials/TeamTrialBanner.test.tsx @@ -16,7 +16,7 @@ */ import React from "react"; -import TeamTrialBanner from "@/extensionConsole/pages/TeamTrialBanner"; +import TeamTrialBanner from "@/extensionConsole/pages/teamTrials/TeamTrialBanner"; import { render, screen, waitFor } from "@/extensionConsole/testHelpers"; import { organizationFactory } from "@/testUtils/factories/organizationFactories"; import { type Timestamp } from "@/types/stringTypes"; diff --git a/src/extensionConsole/pages/TeamTrialBanner.tsx b/src/extensionConsole/pages/teamTrials/TeamTrialBanner.tsx similarity index 87% rename from src/extensionConsole/pages/TeamTrialBanner.tsx rename to src/extensionConsole/pages/teamTrials/TeamTrialBanner.tsx index a9677d9494..86fdedddef 100644 --- a/src/extensionConsole/pages/TeamTrialBanner.tsx +++ b/src/extensionConsole/pages/teamTrials/TeamTrialBanner.tsx @@ -16,22 +16,13 @@ */ import Banner from "@/components/banner/Banner"; +import TrialCallToActionLink from "@/extensionConsole/pages/teamTrials/TrialCallToActionLink"; import useTeamTrialStatus, { TeamTrialStatus, -} from "@/extensionConsole/pages/useTeamTrialStatus"; +} from "@/extensionConsole/pages/teamTrials/useTeamTrialStatus"; import React from "react"; import { Collapse } from "react-bootstrap"; -const TrialCallToActionLink = () => ( - - here. - -); - const TeamTrialBanner: React.FunctionComponent = () => { const teamTrialStatus = useTeamTrialStatus(); diff --git a/src/extensionConsole/pages/teamTrials/TrialAwareButton.test.tsx b/src/extensionConsole/pages/teamTrials/TrialAwareButton.test.tsx new file mode 100644 index 0000000000..96db1ecd6f --- /dev/null +++ b/src/extensionConsole/pages/teamTrials/TrialAwareButton.test.tsx @@ -0,0 +1,213 @@ +/* + * Copyright (C) 2024 PixieBrix, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { render, screen, waitFor } from "@/extensionConsole/testHelpers"; +import { TrialAwareButton } from "@/extensionConsole/pages/teamTrials/TrialAwareButton"; +import { appApiMock } from "@/testUtils/appApiMock"; +import { registryIdFactory } from "@/testUtils/factories/stringFactories"; +import React from "react"; +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { API_PATHS } from "@/data/service/urlPaths"; +import { organizationFactory } from "@/testUtils/factories/organizationFactories"; +import { type Timestamp } from "@/types/stringTypes"; + +describe("TrialAwareButton", () => { + beforeEach(() => { + appApiMock.reset(); + }); + + it("renders", async () => { + appApiMock.onGet(API_PATHS.ORGANIZATIONS).reply(200, []); + render(); + + expect(screen.getByRole("button")).toBeInTheDocument(); + }); + + it("renders children", async () => { + appApiMock.onGet(API_PATHS.ORGANIZATIONS).reply(200, []); + render( + Hello, + ); + + expect(screen.getByRole("button")).toHaveTextContent("Hello"); + }); + + it("renders icon", async () => { + appApiMock.onGet(API_PATHS.ORGANIZATIONS).reply(200, []); + render( + + Hello + , + ); + + // Hidden because we don't show the icon to screen readers + expect(screen.getByRole("img", { hidden: true })).toBeInTheDocument(); + }); + + describe("no trial_end_timestamp", () => { + it("is enabled when the team does not have a trial_end_timestamp if the modId is not of the same scope as the organization", async () => { + const organizationScope = "@foo"; + const modId = registryIdFactory(); + expect(modId.startsWith(organizationScope)).toBeFalse(); + + appApiMock + .onGet(API_PATHS.ORGANIZATIONS) + .reply(200, [organizationFactory({ scope: organizationScope })]); + + render(); + + await waitFor(() => { + expect(appApiMock.history.get).toHaveLength(1); + }); + expect(screen.getByRole("button")).toBeEnabled(); + }); + + it("is enabled when the team does not have a trial_end_timestamp if the modId is of the same scope as the organization", async () => { + const organizationScope = "test"; + const modId = registryIdFactory(); + expect(modId.startsWith(organizationScope)).toBeTrue(); + + appApiMock + .onGet(API_PATHS.ORGANIZATIONS) + .reply(200, [organizationFactory({ scope: organizationScope })]); + + render(); + + await waitFor(() => { + expect(appApiMock.history.get).toHaveLength(1); + }); + expect(screen.getByRole("button")).toBeEnabled(); + }); + }); + + describe("trial_end_timestamp in the future", () => { + it("is enabled when the team has a trial_end_timestamp in the future and the modId is not of the same scope as the organization", async () => { + jest.useFakeTimers().setSystemTime(new Date("2023-01-01")); + + const organizationScope = "@foo"; + const modId = registryIdFactory(); + expect(modId.startsWith(organizationScope)).toBeFalse(); + + appApiMock.onGet(API_PATHS.ORGANIZATIONS).reply(200, [ + organizationFactory({ + trial_end_timestamp: "2023-01-02T00:00:00Z" as Timestamp, + scope: organizationScope, + }), + ]); + + render(); + + await waitFor(() => { + expect(appApiMock.history.get).toHaveLength(1); + }); + expect(screen.getByRole("button")).toBeEnabled(); + }); + + it("is enabled when the team has a trial_end_timestamp in the future and the modId is of the same scope as the organization", async () => { + jest.useFakeTimers().setSystemTime(new Date("2023-01-01")); + + const organizationScope = "test"; + const modId = registryIdFactory(); + expect(modId.startsWith(organizationScope)).toBeTrue(); + + appApiMock.onGet(API_PATHS.ORGANIZATIONS).reply(200, [ + organizationFactory({ + trial_end_timestamp: "2023-01-02T00:00:00Z" as Timestamp, + scope: organizationScope, + }), + ]); + + render(); + + await waitFor(() => { + expect(appApiMock.history.get).toHaveLength(1); + }); + expect(screen.getByRole("button")).toBeEnabled(); + }); + }); + + describe("trial_end_timestamp in the past", () => { + it("is enabled when the team has a trial_end_timestamp in the past and the modId is not of the same scope as the organization", async () => { + jest.useFakeTimers().setSystemTime(new Date("2023-01-02")); + + const organizationScope = "@foo"; + const modId = registryIdFactory(); + expect(modId.startsWith(organizationScope)).toBeFalse(); + + appApiMock.onGet(API_PATHS.ORGANIZATIONS).reply(200, [ + organizationFactory({ + trial_end_timestamp: "2023-01-01T00:00:00Z" as Timestamp, + }), + ]); + + render(); + + await waitFor(() => { + expect(appApiMock.history.get).toHaveLength(1); + }); + expect(screen.getByRole("button")).toBeEnabled(); + }); + + it("is disabled when the team has a trial_end_timestamp in the past and the modId is of the same scope as the organization", async () => { + jest.useFakeTimers().setSystemTime(new Date("2023-01-02")); + + const organizationScope = "test"; + const modId = registryIdFactory(); + expect(modId.startsWith(organizationScope)).toBeTrue(); + + appApiMock.onGet(API_PATHS.ORGANIZATIONS).reply(200, [ + organizationFactory({ + trial_end_timestamp: "2023-01-01T00:00:00Z" as Timestamp, + scope: organizationScope, + }), + ]); + + render(); + + await waitFor(() => { + expect(appApiMock.history.get).toHaveLength(1); + }); + expect(screen.getByRole("button")).toBeDisabled(); + }); + + it("is disabled when any team has a trial_end_timestamp in the past and the modId is of the same scope as any organization", async () => { + jest.useFakeTimers().setSystemTime(new Date("2023-01-02")); + + const organization1Scope = "test"; + const organization2Scope = "@foo"; + const modId = registryIdFactory(); + expect(modId.startsWith(organization1Scope)).toBeTrue(); + + appApiMock.onGet(API_PATHS.ORGANIZATIONS).reply(200, [ + organizationFactory({ + trial_end_timestamp: "2023-01-01T00:00:00Z" as Timestamp, + scope: organization2Scope, + }), + organizationFactory({ + scope: organization1Scope, + }), + ]); + + render(); + + await waitFor(() => { + expect(appApiMock.history.get).toHaveLength(1); + }); + expect(screen.getByRole("button")).toBeDisabled(); + }); + }); +}); diff --git a/src/extensionConsole/pages/teamTrials/TrialAwareButton.tsx b/src/extensionConsole/pages/teamTrials/TrialAwareButton.tsx new file mode 100644 index 0000000000..97b5c66794 --- /dev/null +++ b/src/extensionConsole/pages/teamTrials/TrialAwareButton.tsx @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2024 PixieBrix, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import TrialCallToActionLink from "@/extensionConsole/pages/teamTrials/TrialCallToActionLink"; +import useGetAllTeamScopes from "@/extensionConsole/pages/teamTrials/useGetAllTeamScopes"; +import useTeamTrialStatus, { + TeamTrialStatus, +} from "@/extensionConsole/pages/teamTrials/useTeamTrialStatus"; +import { type RegistryId } from "@/types/registryTypes"; +import { type IconProp } from "@fortawesome/fontawesome-svg-core"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import React, { useState } from "react"; +import { + type ButtonProps, + OverlayTrigger, + Button, + Tooltip, +} from "react-bootstrap"; + +const TrialExpiredTooltip = ( + +

+ Your Business Plan trial has ended. +

+

+ Talk to an onboarding specialist to keep using team features{" "} + +

+
+); + +function shouldDisableTeamButtons({ + isExpired, + modId, + teamScopes, +}: { + isExpired: boolean; + modId: RegistryId; + teamScopes: string[]; +}) { + return isExpired && teamScopes.some((scope) => modId.startsWith(scope)); +} + +export const TrialAwareButton = ({ + modId, + icon, + disabled: propsDisabled, + children, + ...buttonProps +}: ButtonProps & { icon?: IconProp; modId: RegistryId }) => { + const [show, setShow] = useState(false); + const isExpired = useTeamTrialStatus() === TeamTrialStatus.EXPIRED; + const { teamScopes, isLoading: isLoadingTeamScopes } = useGetAllTeamScopes(); + const disableTeamButtons = shouldDisableTeamButtons({ + isExpired, + modId, + teamScopes, + }); + const disabled = isLoadingTeamScopes || disableTeamButtons || propsDisabled; + + return ( + { + if (disableTeamButtons) { + setShow(nextShow); + } + }} + overlay={TrialExpiredTooltip} + > + {({ ref: overlayRef, ...overlayProps }) => ( + + + + )} + + ); +}; diff --git a/src/extensionConsole/pages/teamTrials/TrialCallToActionLink.tsx b/src/extensionConsole/pages/teamTrials/TrialCallToActionLink.tsx new file mode 100644 index 0000000000..345aeefc1b --- /dev/null +++ b/src/extensionConsole/pages/teamTrials/TrialCallToActionLink.tsx @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2024 PixieBrix, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import React from "react"; + +const TrialCallToActionLink = () => ( + + here. + +); + +export default TrialCallToActionLink; diff --git a/src/extensionConsole/pages/teamTrials/useGetAllTeamScopes.ts b/src/extensionConsole/pages/teamTrials/useGetAllTeamScopes.ts new file mode 100644 index 0000000000..28869d6d07 --- /dev/null +++ b/src/extensionConsole/pages/teamTrials/useGetAllTeamScopes.ts @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2024 PixieBrix, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { useGetOrganizationsQuery } from "@/data/service/api"; +import { useMemo } from "react"; + +function useGetAllTeamScopes() { + const { data: organizations = [], isLoading } = useGetOrganizationsQuery(); + + return useMemo( + () => ({ + teamScopes: organizations + .map((org) => org.scope) + .filter((x) => x != null), + isLoading, + }), + [organizations, isLoading], + ); +} + +export default useGetAllTeamScopes; diff --git a/src/extensionConsole/pages/useTeamTrialStatus.ts b/src/extensionConsole/pages/teamTrials/useTeamTrialStatus.ts similarity index 91% rename from src/extensionConsole/pages/useTeamTrialStatus.ts rename to src/extensionConsole/pages/teamTrials/useTeamTrialStatus.ts index 44c9b66765..bd0756b6a9 100644 --- a/src/extensionConsole/pages/useTeamTrialStatus.ts +++ b/src/extensionConsole/pages/teamTrials/useTeamTrialStatus.ts @@ -23,7 +23,9 @@ export const TeamTrialStatus = { EXPIRED: "EXPIRED", } as const; -function useTeamTrialStatus(): ValueOf | null { +export type TeamTrialStatusType = ValueOf; + +function useTeamTrialStatus(): TeamTrialStatusType | null { const { data: organizations = [] } = useGetOrganizationsQuery(); const trialEndTimestamps = organizations