diff --git a/.changeset/twelve-horses-suffer.md b/.changeset/twelve-horses-suffer.md new file mode 100644 index 000000000000..bc7f7d5b3ba4 --- /dev/null +++ b/.changeset/twelve-horses-suffer.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/i18n': minor +'@rocket.chat/meteor': minor +--- + +Adds a confirmation modal to the cancel subscription action diff --git a/apps/meteor/client/views/admin/subscription/SubscriptionPage.tsx b/apps/meteor/client/views/admin/subscription/SubscriptionPage.tsx index 4ba6bd677ea9..e6f5beffe7ba 100644 --- a/apps/meteor/client/views/admin/subscription/SubscriptionPage.tsx +++ b/apps/meteor/client/views/admin/subscription/SubscriptionPage.tsx @@ -19,7 +19,7 @@ import MACCard from './components/cards/MACCard'; import PlanCard from './components/cards/PlanCard'; import PlanCardCommunity from './components/cards/PlanCard/PlanCardCommunity'; import SeatsCard from './components/cards/SeatsCard'; -import { useRemoveLicense } from './hooks/useRemoveLicense'; +import { useCancelSubscriptionModal } from './hooks/useCancelSubscriptionModal'; import { useWorkspaceSync } from './hooks/useWorkspaceSync'; import { Page, PageHeader, PageScrollableContentWithShadow } from '../../../components/Page'; import { useIsEnterprise } from '../../../hooks/useIsEnterprise'; @@ -70,6 +70,8 @@ const SubscriptionPage = () => { const macLimit = getKeyLimit('monthlyActiveContacts'); const seatsLimit = getKeyLimit('activeUsers'); + const { isLoading: isCancelSubscriptionLoading, open: openCancelSubscriptionModal } = useCancelSubscriptionModal(); + const handleSyncLicenseUpdate = useCallback(() => { syncLicenseUpdate.mutate(undefined, { onSuccess: () => invalidateLicenseQuery(100), @@ -95,8 +97,6 @@ const SubscriptionPage = () => { } }, [handleSyncLicenseUpdate, router, subscriptionSuccess, syncLicenseUpdate.isIdle]); - const removeLicense = useRemoveLicense(); - return ( @@ -177,7 +177,7 @@ const SubscriptionPage = () => { {Boolean(licensesData?.license?.information.cancellable) && ( - )} diff --git a/apps/meteor/client/views/admin/subscription/components/CancelSubscriptionModal.spec.tsx b/apps/meteor/client/views/admin/subscription/components/CancelSubscriptionModal.spec.tsx new file mode 100644 index 000000000000..f35abc841b18 --- /dev/null +++ b/apps/meteor/client/views/admin/subscription/components/CancelSubscriptionModal.spec.tsx @@ -0,0 +1,69 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { CancelSubscriptionModal } from './CancelSubscriptionModal'; +import { DOWNGRADE_LINK } from '../utils/links'; + +it('should display plan name in the title', async () => { + const confirmFn = jest.fn(); + render(, { + wrapper: mockAppRoot() + .withTranslations('en', 'core', { + Cancel__planName__subscription: 'Cancel {{planName}} subscription', + }) + .build(), + legacyRoot: true, + }); + + expect(screen.getByText('Cancel Starter subscription')).toBeInTheDocument(); +}); + +it('should have link to downgrade docs', async () => { + render(, { + wrapper: mockAppRoot() + .withTranslations('en', 'core', { + Cancel__planName__subscription: 'Cancel {{planName}} subscription', + Cancel_subscription_message: + 'This workspace will downgrage to Community and lose free access to premium capabilities.

While you can keep using Rocket.Chat, your team will lose access to unlimited mobile push notifications, read receipts, marketplace apps <4>and other capabilities.', + }) + .build(), + legacyRoot: true, + }); + + expect(screen.getByRole('link', { name: 'and other capabilities' })).toHaveAttribute('href', DOWNGRADE_LINK); +}); + +it('should call onConfirm when confirm button is clicked', async () => { + const confirmFn = jest.fn(); + render(, { + wrapper: mockAppRoot().build(), + legacyRoot: true, + }); + + await userEvent.click(screen.getByRole('button', { name: 'Cancel_subscription' })); + expect(confirmFn).toHaveBeenCalled(); +}); + +it('should call onCancel when "Dont cancel" button is clicked', async () => { + const cancelFn = jest.fn(); + render(, { + wrapper: mockAppRoot().build(), + legacyRoot: true, + }); + + await userEvent.click(screen.getByRole('button', { name: 'Dont_cancel' })); + expect(cancelFn).toHaveBeenCalled(); +}); + +it('should call onCancel when close button is clicked', async () => { + const cancelFn = jest.fn(); + render(, { + wrapper: mockAppRoot().build(), + legacyRoot: true, + }); + + await userEvent.click(screen.getByRole('button', { name: 'Close' })); + expect(cancelFn).toHaveBeenCalled(); +}); diff --git a/apps/meteor/client/views/admin/subscription/components/CancelSubscriptionModal.tsx b/apps/meteor/client/views/admin/subscription/components/CancelSubscriptionModal.tsx new file mode 100644 index 000000000000..b717d5b370a6 --- /dev/null +++ b/apps/meteor/client/views/admin/subscription/components/CancelSubscriptionModal.tsx @@ -0,0 +1,36 @@ +import { ExternalLink } from '@rocket.chat/ui-client'; +import React from 'react'; +import { Trans, useTranslation } from 'react-i18next'; + +import GenericModal from '../../../../components/GenericModal'; +import { DOWNGRADE_LINK } from '../utils/links'; + +type CancelSubscriptionModalProps = { + planName: string; + onConfirm(): void; + onCancel(): void; +}; + +export const CancelSubscriptionModal = ({ planName, onCancel, onConfirm }: CancelSubscriptionModalProps) => { + const { t } = useTranslation(); + + return ( + + + This workspace will downgrade to Community and lose free access to premium capabilities. +
+
+ While you can keep using Rocket.Chat, your team will lose access to unlimited mobile push notifications, read receipts, marketplace + apps and other capabilities. +
+
+ ); +}; diff --git a/apps/meteor/client/views/admin/subscription/hooks/useCancelSubscriptionModal.spec.tsx b/apps/meteor/client/views/admin/subscription/hooks/useCancelSubscriptionModal.spec.tsx new file mode 100644 index 000000000000..f97234398a6d --- /dev/null +++ b/apps/meteor/client/views/admin/subscription/hooks/useCancelSubscriptionModal.spec.tsx @@ -0,0 +1,72 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { act, renderHook, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { useCancelSubscriptionModal } from './useCancelSubscriptionModal'; +import createDeferredMockFn from '../../../../../tests/mocks/utils/createDeferredMockFn'; + +jest.mock('../../../../hooks/useLicense', () => ({ + ...jest.requireActual('../../../../hooks/useLicense'), + useLicenseName: () => ({ data: 'Starter' }), +})); + +it('should open modal when open method is called', () => { + const { result } = renderHook(() => useCancelSubscriptionModal(), { + wrapper: mockAppRoot() + .withTranslations('en', 'core', { + Cancel__planName__subscription: 'Cancel {{planName}} subscription', + }) + .build(), + legacyRoot: true, + }); + + expect(screen.queryByText('Cancel Starter subscription')).not.toBeInTheDocument(); + + act(() => result.current.open()); + + expect(screen.getByText('Cancel Starter subscription')).toBeInTheDocument(); +}); + +it('should close modal cancel is clicked', async () => { + const { result } = renderHook(() => useCancelSubscriptionModal(), { + wrapper: mockAppRoot() + .withTranslations('en', 'core', { + Cancel__planName__subscription: 'Cancel {{planName}} subscription', + }) + .build(), + legacyRoot: true, + }); + + act(() => result.current.open()); + expect(screen.getByText('Cancel Starter subscription')).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: 'Dont_cancel' })); + + expect(screen.queryByText('Cancel Starter subscription')).not.toBeInTheDocument(); +}); + +it('should call remove license endpoint when confirm is clicked', async () => { + const { fn: removeLicenseEndpoint, resolve } = createDeferredMockFn<{ success: boolean }>(); + + const { result } = renderHook(() => useCancelSubscriptionModal(), { + wrapper: mockAppRoot() + .withEndpoint('POST', '/v1/cloud.removeLicense', removeLicenseEndpoint) + .withTranslations('en', 'core', { + Cancel__planName__subscription: 'Cancel {{planName}} subscription', + }) + .build(), + legacyRoot: true, + }); + + act(() => result.current.open()); + expect(result.current.isLoading).toBeFalsy(); + expect(screen.getByText('Cancel Starter subscription')).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: 'Cancel_subscription' })); + expect(result.current.isLoading).toBeTruthy(); + await act(() => resolve({ success: true })); + await waitFor(() => expect(result.current.isLoading).toBeFalsy()); + + expect(removeLicenseEndpoint).toHaveBeenCalled(); + expect(screen.queryByText('Cancel Starter subscription')).not.toBeInTheDocument(); +}); diff --git a/apps/meteor/client/views/admin/subscription/hooks/useCancelSubscriptionModal.tsx b/apps/meteor/client/views/admin/subscription/hooks/useCancelSubscriptionModal.tsx new file mode 100644 index 000000000000..3e810cab45e5 --- /dev/null +++ b/apps/meteor/client/views/admin/subscription/hooks/useCancelSubscriptionModal.tsx @@ -0,0 +1,28 @@ +import { useSetModal } from '@rocket.chat/ui-contexts'; +import React, { useCallback } from 'react'; + +import { useRemoveLicense } from './useRemoveLicense'; +import { useLicenseName } from '../../../../hooks/useLicense'; +import { CancelSubscriptionModal } from '../components/CancelSubscriptionModal'; + +export const useCancelSubscriptionModal = () => { + const { data: planName = '' } = useLicenseName(); + const removeLicense = useRemoveLicense(); + const setModal = useSetModal(); + + const open = useCallback(() => { + const closeModal = () => setModal(null); + + const handleConfirm = () => { + removeLicense.mutateAsync(); + closeModal(); + }; + + setModal(); + }, [removeLicense, planName, setModal]); + + return { + open, + isLoading: removeLicense.isLoading, + }; +}; diff --git a/apps/meteor/tests/mocks/utils/createDeferredMockFn.ts b/apps/meteor/tests/mocks/utils/createDeferredMockFn.ts new file mode 100644 index 000000000000..d70e0083c0be --- /dev/null +++ b/apps/meteor/tests/mocks/utils/createDeferredMockFn.ts @@ -0,0 +1,19 @@ +function createDeferredPromise() { + let resolve!: (value: R | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + return { promise, resolve, reject }; +} + +function createDeferredMockFn() { + const deferred = createDeferredPromise(); + const fn = jest.fn(() => deferred.promise); + return { ...deferred, fn }; +} + +export default createDeferredMockFn; diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 07f68de6fc15..42b91117d66a 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -937,6 +937,8 @@ "Cancel_message_input": "Cancel", "Canceled": "Canceled", "Cancel_subscription": "Cancel subscription", + "Cancel__planName__subscription": "Cancel {{planName}} subscription", + "Cancel_subscription_message": "This workspace will downgrage to Community and lose free access to premium capabilities.

While you can keep using Rocket.Chat, your team will lose access to unlimited mobile push notifications, read receipts, marketplace apps <4>and other capabilities.", "Canned_Response_Created": "Canned Response created", "Canned_Response_Updated": "Canned Response updated", "Canned_Response_Delete_Warning": "Deleting a canned response cannot be undone.", @@ -1791,6 +1793,7 @@ "Done": "Done", "Dont_ask_me_again": "Don't ask me again!", "Dont_ask_me_again_list": "Don't ask me again list", + "Dont_cancel": "Don't cancel", "Download": "Download", "Download_Destkop_App": "Download Desktop App", "Download_Disabled": "Download disabled", diff --git a/packages/i18n/src/locales/pt-BR.i18n.json b/packages/i18n/src/locales/pt-BR.i18n.json index f9ace637d8eb..cc4bb7de666a 100644 --- a/packages/i18n/src/locales/pt-BR.i18n.json +++ b/packages/i18n/src/locales/pt-BR.i18n.json @@ -773,6 +773,9 @@ "Cancel": "Cancelar", "Cancel_message_input": "Cancelar", "Canceled": "Cancelado", + "Cancel_subscription": "Cancelar assinatura", + "Cancel__planName__subscription": "Cancelar assinatura do plano {{planName}}", + "Cancel_subscription_message": "Este workspace será migrado para a versão Community, perdendo acesso gratuito a recursos premium.

Ainda será possível usar o Rocket.Chat, mas sua equipe perderá acesso a integrações e notificações push ilimitadas, confirmação de leitura de mensagens <4>e outras funcionalidades.", "Canned_Response_Created": "Resposta modelo criada", "Canned_Response_Updated": "Resposta modelo atualizada", "Canned_Response_Delete_Warning": "A exclusão de uma resposta modelo não pode ser desfeita.", @@ -1505,6 +1508,7 @@ "Domains_allowed_to_embed_the_livechat_widget": "Lista de domínios separados por vírgulas permitidos a incorporar o widget do Livechat. Deixe em branco para permitir todos os domínios.", "Dont_ask_me_again": "Não perguntar de novo!", "Dont_ask_me_again_list": "Lista Não perguntar de novo", + "Dont_cancel": "Não cancelar", "Download": "Baixar", "Download_Destkop_App": "Baixar aplicativo para desktop", "Download_Info": "Baixar informações",