{
@@ -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