Skip to content

Commit

Permalink
feat: cancel subscription confirmation modal (#33814)
Browse files Browse the repository at this point in the history
  • Loading branch information
aleksandernsilva authored Nov 18, 2024
1 parent aed431c commit 6166555
Show file tree
Hide file tree
Showing 9 changed files with 241 additions and 4 deletions.
6 changes: 6 additions & 0 deletions .changeset/twelve-horses-suffer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@rocket.chat/i18n': minor
'@rocket.chat/meteor': minor
---

Adds a confirmation modal to the cancel subscription action
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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),
Expand All @@ -95,8 +97,6 @@ const SubscriptionPage = () => {
}
}, [handleSyncLicenseUpdate, router, subscriptionSuccess, syncLicenseUpdate.isIdle]);

const removeLicense = useRemoveLicense();

return (
<Page bg='tint'>
<PageHeader title={t('Subscription')}>
Expand Down Expand Up @@ -177,7 +177,7 @@ const SubscriptionPage = () => {
</Grid>
<UpgradeToGetMore activeModules={activeModules} isEnterprise={isEnterprise}>
{Boolean(licensesData?.license?.information.cancellable) && (
<Button loading={removeLicense.isLoading} secondary danger onClick={() => removeLicense.mutate()}>
<Button loading={isCancelSubscriptionLoading} secondary danger onClick={openCancelSubscriptionModal}>
{t('Cancel_subscription')}
</Button>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -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(<CancelSubscriptionModal planName='Starter' onConfirm={confirmFn} onCancel={jest.fn()} />, {
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(<CancelSubscriptionModal planName='Starter' onConfirm={jest.fn()} onCancel={jest.fn()} />, {
wrapper: mockAppRoot()
.withTranslations('en', 'core', {
Cancel__planName__subscription: 'Cancel {{planName}} subscription',
Cancel_subscription_message:
'<strong>This workspace will downgrage to Community and lose free access to premium capabilities.</strong><br/><br/> 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</4>.',
})
.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(<CancelSubscriptionModal planName='Starter' onConfirm={confirmFn} onCancel={jest.fn()} />, {
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(<CancelSubscriptionModal planName='Starter' onConfirm={jest.fn()} onCancel={cancelFn} />, {
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(<CancelSubscriptionModal planName='Starter' onConfirm={jest.fn()} onCancel={cancelFn} />, {
wrapper: mockAppRoot().build(),
legacyRoot: true,
});

await userEvent.click(screen.getByRole('button', { name: 'Close' }));
expect(cancelFn).toHaveBeenCalled();
});
Original file line number Diff line number Diff line change
@@ -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 (
<GenericModal
variant='danger'
title={t('Cancel__planName__subscription', { planName })}
icon={null}
confirmText={t('Cancel_subscription')}
cancelText={t('Dont_cancel')}
onConfirm={onConfirm}
onCancel={onCancel}
>
<Trans i18nKey='Cancel_subscription_message' t={t}>
<strong>This workspace will downgrade to Community and lose free access to premium capabilities.</strong>
<br />
<br />
While you can keep using Rocket.Chat, your team will lose access to unlimited mobile push notifications, read receipts, marketplace
apps and <ExternalLink to={DOWNGRADE_LINK}>other capabilities</ExternalLink>.
</Trans>
</GenericModal>
);
};
Original file line number Diff line number Diff line change
@@ -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();
});
Original file line number Diff line number Diff line change
@@ -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(<CancelSubscriptionModal planName={planName} onConfirm={handleConfirm} onCancel={closeModal} />);
}, [removeLicense, planName, setModal]);

return {
open,
isLoading: removeLicense.isLoading,
};
};
19 changes: 19 additions & 0 deletions apps/meteor/tests/mocks/utils/createDeferredMockFn.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
function createDeferredPromise<R = void>() {
let resolve!: (value: R | PromiseLike<R>) => void;
let reject!: (reason?: unknown) => void;

const promise = new Promise<R>((res, rej) => {
resolve = res;
reject = rej;
});

return { promise, resolve, reject };
}

function createDeferredMockFn<R = void>() {
const deferred = createDeferredPromise<R>();
const fn = jest.fn(() => deferred.promise);
return { ...deferred, fn };
}

export default createDeferredMockFn;
3 changes: 3 additions & 0 deletions packages/i18n/src/locales/en.i18n.json
Original file line number Diff line number Diff line change
Expand Up @@ -937,6 +937,8 @@
"Cancel_message_input": "Cancel",
"Canceled": "Canceled",
"Cancel_subscription": "Cancel subscription",
"Cancel__planName__subscription": "Cancel {{planName}} subscription",
"Cancel_subscription_message": "<strong>This workspace will downgrage to Community and lose free access to premium capabilities.</strong><br/><br/> 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</4>.",
"Canned_Response_Created": "Canned Response created",
"Canned_Response_Updated": "Canned Response updated",
"Canned_Response_Delete_Warning": "Deleting a canned response cannot be undone.",
Expand Down Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions packages/i18n/src/locales/pt-BR.i18n.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<strong>Este workspace será migrado para a versão Community, perdendo acesso gratuito a recursos premium.</strong><br/><br/> 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</4>.",
"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.",
Expand Down Expand Up @@ -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",
Expand Down

0 comments on commit 6166555

Please sign in to comment.