Skip to content

Commit

Permalink
Merge branch 'develop' into fix/wrong-network-permission-name
Browse files Browse the repository at this point in the history
  • Loading branch information
kodiakhq[bot] authored Nov 18, 2024
2 parents 4b776d9 + 6166555 commit aa42598
Show file tree
Hide file tree
Showing 18 changed files with 341 additions and 46 deletions.
5 changes: 5 additions & 0 deletions .changeset/seven-otters-fold.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@rocket.chat/meteor': patch
---

Sends server statistics only once a day despite multiple instance being started at different times.
5 changes: 5 additions & 0 deletions .changeset/swift-suns-perform.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@rocket.chat/livechat": patch
---

Fixes livechat popout mode not working correctly in cross domain situations
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
64 changes: 43 additions & 21 deletions apps/meteor/app/statistics/server/functions/sendUsageReport.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { IStats } from '@rocket.chat/core-typings';
import type { Logger } from '@rocket.chat/logger';
import { Statistics } from '@rocket.chat/models';
import { serverFetch as fetch } from '@rocket.chat/server-fetch';
Expand All @@ -7,32 +8,53 @@ import { Meteor } from 'meteor/meteor';
import { statistics } from '..';
import { getWorkspaceAccessToken } from '../../../cloud/server';

export async function sendUsageReport(logger: Logger): Promise<string | undefined> {
return tracerSpan('generateStatistics', {}, async () => {
const cronStatistics = await statistics.save();
async function sendStats(logger: Logger, cronStatistics: IStats): Promise<string | undefined> {
try {
const token = await getWorkspaceAccessToken();
const headers = { ...(token && { Authorization: `Bearer ${token}` }) };

try {
const token = await getWorkspaceAccessToken();
const headers = { ...(token && { Authorization: `Bearer ${token}` }) };
const response = await fetch('https://collector.rocket.chat/', {
method: 'POST',
body: {
...cronStatistics,
host: Meteor.absoluteUrl(),
},
headers,
});

const { statsToken } = await response.json();

if (statsToken != null) {
await Statistics.updateOne({ _id: cronStatistics._id }, { $set: { statsToken } });
return statsToken;
}
} catch (err) {
logger.error({ msg: 'Failed to send usage report', err });
}
}

const response = await fetch('https://collector.rocket.chat/', {
method: 'POST',
body: {
...cronStatistics,
host: Meteor.absoluteUrl(),
},
headers,
});
export async function sendUsageReport(logger: Logger): Promise<string | undefined> {
return tracerSpan('generateStatistics', {}, async () => {
const last = await Statistics.findLast();
if (last) {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);

const { statsToken } = await response.json();
// if the last data we have has less than 24h and was not sent to yet, send it
if (last.createdAt > yesterday) {
// but if it has the confirmation token, we can skip
if (last.statsToken) {
return last.statsToken;
}

if (statsToken != null) {
await Statistics.updateOne({ _id: cronStatistics._id }, { $set: { statsToken } });
return statsToken;
// if it doesn't it means the request failed, so we try sending again with the same data
return sendStats(logger, last);
}
} catch (error) {
/* error*/
logger.warn('Failed to send usage report');
}

// if our latest stats has more than 24h, it is time to generate a new one and send it
const cronStatistics = await statistics.save();

return sendStats(logger, cronStatistics);
});
}
18 changes: 18 additions & 0 deletions apps/meteor/client/sidebarv2/hooks/useRoomList.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -293,3 +293,21 @@ it('should add to unread group when has thread unread, even if alert is false',
const unreadGroup = result.current.roomList.splice(0, result.current.groupsCount[0]);
expect(unreadGroup.find((room) => room.name === fakeRoom.name)).toBeDefined();
});

it('should not add room to unread group if thread unread is an empty array', async () => {
const fakeRoom = {
...createFakeSubscription({ ...emptyUnread, tunread: [] }),
} as unknown as SubscriptionWithRoom;

const { result } = renderHook(() => useRoomList({ collapsedGroups: [] }), {
legacyRoot: true,
wrapper: getWrapperSettings({
sidebarGroupByType: true,
sidebarShowUnread: true,
fakeRoom,
}).build(),
});

const unreadGroup = result.current.roomList.splice(0, result.current.groupsCount[0]);
expect(unreadGroup.find((room) => room.name === fakeRoom.name)).toBeUndefined();
});
2 changes: 1 addition & 1 deletion apps/meteor/client/sidebarv2/hooks/useRoomList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ export const useRoomList = ({ collapsedGroups }: { collapsedGroups?: string[] })
return incomingCall.add(room);
}

if (sidebarShowUnread && (room.alert || room.unread || room.tunread) && !room.hideUnreadStatus) {
if (sidebarShowUnread && (room.alert || room.unread || room.tunread?.length) && !room.hideUnreadStatus) {
return unread.add(room);
}

Expand Down
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;
Loading

0 comments on commit aa42598

Please sign in to comment.