snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
import { Box, Skeleton } from '@rocket.chat/fuselage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useActiveConnections } from '../../../../hooks/useActiveConnections';
|
||||
import type { CardProps } from '../FeatureUsageCard';
|
||||
import FeatureUsageCard from '../FeatureUsageCard';
|
||||
import FeatureUsageCardBody from '../FeatureUsageCardBody';
|
||||
import UpgradeButton from '../UpgradeButton';
|
||||
|
||||
const getLimits = ({ max, current }: { max: number; current: number }) => {
|
||||
const total = max || 0;
|
||||
const used = current || 0;
|
||||
const available = total - used;
|
||||
|
||||
const exceedLimit = used >= total;
|
||||
|
||||
return {
|
||||
total,
|
||||
used,
|
||||
available,
|
||||
exceedLimit,
|
||||
};
|
||||
};
|
||||
|
||||
const ActiveSessionsCard = () => {
|
||||
const { t } = useTranslation();
|
||||
const result = useActiveConnections();
|
||||
|
||||
const card: CardProps = {
|
||||
title: t('ActiveSessions'),
|
||||
infoText: t('ActiveSessions_InfoText'),
|
||||
};
|
||||
|
||||
if (result.isPending || result.isError) {
|
||||
return (
|
||||
<FeatureUsageCard card={card}>
|
||||
<FeatureUsageCardBody justifyContent='flex-start'>
|
||||
<Skeleton variant='rect' width='x112' height='x224' />
|
||||
</FeatureUsageCardBody>
|
||||
</FeatureUsageCard>
|
||||
);
|
||||
}
|
||||
|
||||
const { total, used, available, exceedLimit } = getLimits(result.data);
|
||||
|
||||
return (
|
||||
<FeatureUsageCard
|
||||
card={{
|
||||
...card,
|
||||
...(exceedLimit && {
|
||||
upgradeButton: (
|
||||
<UpgradeButton target='active-session-card' action='upgrade' small>
|
||||
{t('Upgrade')}
|
||||
</UpgradeButton>
|
||||
),
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<FeatureUsageCardBody justifyContent='flex-start'>
|
||||
<Box color='font-secondary-info' textAlign='center'>
|
||||
<Box fontScale='h1' color={exceedLimit ? 'font-danger' : 'font-default'} marginBlockEnd={12}>
|
||||
{used} / {total}
|
||||
</Box>
|
||||
{available} {t('ActiveSessions_available')}
|
||||
</Box>
|
||||
</FeatureUsageCardBody>
|
||||
</FeatureUsageCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActiveSessionsCard;
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Box, Skeleton } from '@rocket.chat/fuselage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useFormatDate } from '../../../../../hooks/useFormatDate';
|
||||
import { useStatistics } from '../../../../hooks/useStatistics';
|
||||
import type { CardProps } from '../FeatureUsageCard';
|
||||
import FeatureUsageCard from '../FeatureUsageCard';
|
||||
import FeatureUsageCardBody from '../FeatureUsageCardBody';
|
||||
import UpgradeButton from '../UpgradeButton';
|
||||
|
||||
const ActiveSessionsPeakCard = () => {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading } = useStatistics();
|
||||
const formatDate = useFormatDate();
|
||||
|
||||
const { maxMonthlyPeakConnections } = data || {};
|
||||
|
||||
const total = 200;
|
||||
const used = maxMonthlyPeakConnections || 0;
|
||||
|
||||
const exceedLimit = used >= total;
|
||||
|
||||
const card: CardProps = {
|
||||
title: t('ActiveSessionsPeak'),
|
||||
infoText: t('ActiveSessionsPeak_InfoText'),
|
||||
...(exceedLimit && {
|
||||
upgradeButton: (
|
||||
<UpgradeButton target='active-session-peak-card' action='upgrade' small>
|
||||
{t('Upgrade')}
|
||||
</UpgradeButton>
|
||||
),
|
||||
}),
|
||||
};
|
||||
|
||||
if (isLoading || maxMonthlyPeakConnections === undefined) {
|
||||
return (
|
||||
<FeatureUsageCard card={card}>
|
||||
<FeatureUsageCardBody justifyContent='flex-start'>
|
||||
<Skeleton variant='rect' width='x112' height='x112' />
|
||||
</FeatureUsageCardBody>
|
||||
</FeatureUsageCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FeatureUsageCard card={card}>
|
||||
<Box color='font-secondary-info' textAlign='center'>
|
||||
<Box fontScale='h1' color={exceedLimit ? 'font-danger' : 'font-default'} marginBlockEnd={12}>
|
||||
{used} / {total}
|
||||
</Box>
|
||||
{formatDate(new Date())}
|
||||
</Box>
|
||||
</FeatureUsageCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActiveSessionsPeakCard;
|
||||
@@ -0,0 +1,84 @@
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import AppsUsageCard from './AppsUsageCard';
|
||||
import { PRICING_LINK } from '../../../utils/links';
|
||||
|
||||
const appRoot = mockAppRoot().withTranslations('en', 'core', {
|
||||
Apps_InfoText_limited:
|
||||
'Community workspaces can enable up to {{marketplaceAppsMaxCount}} marketplace apps. Private apps can only be enabled in <1>premium plans</1>.',
|
||||
});
|
||||
|
||||
it('should render a skeleton if no data', () => {
|
||||
render(<AppsUsageCard />, { wrapper: appRoot.build() });
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Apps' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('presentation')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render data as progress bars', async () => {
|
||||
render(<AppsUsageCard privateAppsLimit={{ value: 1, max: 3 }} marketplaceAppsLimit={{ value: 2, max: 5 }} />, {
|
||||
wrapper: appRoot.build(),
|
||||
});
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Apps' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Click_here_for_more_info' })).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByRole('progressbar', { name: 'Marketplace_apps' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('progressbar', { name: 'Marketplace_apps' })).toHaveAttribute('aria-valuenow', '40');
|
||||
expect(screen.getByText('2 / 5')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByRole('progressbar', { name: 'Private_apps' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('progressbar', { name: 'Private_apps' })).toHaveAttribute('aria-valuenow', '33');
|
||||
expect(screen.getByText('1 / 3')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Click_here_for_more_info' }));
|
||||
|
||||
expect(screen.getByRole('link', { name: 'premium plans' })).toHaveAttribute('href', PRICING_LINK);
|
||||
|
||||
// TODO: discover how to automatically unmount all modals after each test
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Close' }));
|
||||
});
|
||||
|
||||
it('should render an upgrade button if marketplace apps reached 80% of the limit', async () => {
|
||||
render(<AppsUsageCard privateAppsLimit={{ value: 1, max: 3 }} marketplaceAppsLimit={{ value: 4, max: 5 }} />, {
|
||||
wrapper: appRoot.build(),
|
||||
});
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Apps' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Click_here_for_more_info' })).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Upgrade' })).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Click_here_for_more_info' }));
|
||||
|
||||
expect(screen.getByRole('link', { name: 'premium plans' })).toHaveAttribute('href', PRICING_LINK);
|
||||
|
||||
// TODO: discover how to automatically unmount all modals after each test
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Close' }));
|
||||
});
|
||||
|
||||
it('should render a full progress bar with private apps disabled', async () => {
|
||||
render(<AppsUsageCard privateAppsLimit={{ value: 0, max: 0 }} marketplaceAppsLimit={{ value: 2, max: 5 }} />, {
|
||||
wrapper: appRoot.build(),
|
||||
});
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Apps' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Click_here_for_more_info' })).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByRole('progressbar', { name: 'Marketplace_apps' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('progressbar', { name: 'Marketplace_apps' })).toHaveAttribute('aria-valuenow', '40');
|
||||
expect(screen.getByText('2 / 5')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByRole('progressbar', { name: 'Private_apps' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('progressbar', { name: 'Private_apps' })).toHaveAttribute('aria-valuenow', '100');
|
||||
expect(screen.getByText('0 / 0')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Click_here_for_more_info' }));
|
||||
|
||||
expect(screen.getByRole('link', { name: 'premium plans' })).toHaveAttribute('href', PRICING_LINK);
|
||||
|
||||
// TODO: discover how to automatically unmount all modals after each test
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Close' }));
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Skeleton } from '@rocket.chat/fuselage';
|
||||
import { ExternalLink } from '@rocket.chat/ui-client';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import AppsUsageCardSection from './AppsUsageCardSection';
|
||||
import { PRICING_LINK } from '../../../utils/links';
|
||||
import type { CardProps } from '../../FeatureUsageCard';
|
||||
import FeatureUsageCard from '../../FeatureUsageCard';
|
||||
import FeatureUsageCardBody from '../../FeatureUsageCardBody';
|
||||
import UpgradeButton from '../../UpgradeButton';
|
||||
|
||||
// Magic numbers
|
||||
const marketplaceAppsMaxCountFallback = 5;
|
||||
const privateAppsMaxCountFallback = 0;
|
||||
const defaultWarningThreshold = 80;
|
||||
|
||||
export type AppsUsageCardProps = {
|
||||
privateAppsLimit?: { value?: number; max: number };
|
||||
marketplaceAppsLimit?: { value?: number; max: number };
|
||||
};
|
||||
|
||||
const AppsUsageCard = ({ privateAppsLimit, marketplaceAppsLimit }: AppsUsageCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!privateAppsLimit || !marketplaceAppsLimit) {
|
||||
// FIXME: not accessible enough
|
||||
return (
|
||||
<FeatureUsageCard card={{ title: t('Apps') }}>
|
||||
<FeatureUsageCardBody justifyContent='flex-start'>
|
||||
<Skeleton variant='rect' width='x112' height='x224' role='presentation' />
|
||||
</FeatureUsageCardBody>
|
||||
</FeatureUsageCard>
|
||||
);
|
||||
}
|
||||
|
||||
const marketplaceAppsCount = marketplaceAppsLimit?.value || 0;
|
||||
const marketplaceAppsMaxCount = marketplaceAppsLimit?.max || marketplaceAppsMaxCountFallback;
|
||||
const marketplaceAppsPercentage = Math.round((marketplaceAppsCount / marketplaceAppsMaxCount) * 100) || 0;
|
||||
const marketplaceAppsAboveWarning = marketplaceAppsPercentage >= defaultWarningThreshold;
|
||||
|
||||
const privateAppsCount = privateAppsLimit?.value || 0;
|
||||
const privateAppsMaxCount = privateAppsLimit?.max || privateAppsMaxCountFallback;
|
||||
|
||||
const card: CardProps = {
|
||||
title: t('Apps'),
|
||||
infoText: (
|
||||
<Trans
|
||||
i18nKey='Apps_InfoText_limited'
|
||||
values={{ marketplaceAppsMaxCount }}
|
||||
components={{ 1: <ExternalLink to={PRICING_LINK}>premium plans</ExternalLink> }}
|
||||
/>
|
||||
),
|
||||
|
||||
...(marketplaceAppsAboveWarning && {
|
||||
upgradeButton: (
|
||||
<UpgradeButton target='app-usage-card' action='upgrade' small>
|
||||
{t('Upgrade')}
|
||||
</UpgradeButton>
|
||||
),
|
||||
}),
|
||||
};
|
||||
|
||||
return (
|
||||
<FeatureUsageCard card={card}>
|
||||
<FeatureUsageCardBody justifyContent='flex-start'>
|
||||
<AppsUsageCardSection
|
||||
title={t('Marketplace_apps')}
|
||||
appsCount={marketplaceAppsCount}
|
||||
appsMaxCount={marketplaceAppsMaxCount}
|
||||
warningThreshold={defaultWarningThreshold}
|
||||
/>
|
||||
|
||||
<AppsUsageCardSection
|
||||
title={t('Private_apps')}
|
||||
tip={privateAppsMaxCount === 0 ? t('Private_apps_premium_message') : undefined}
|
||||
appsCount={privateAppsCount}
|
||||
appsMaxCount={privateAppsMaxCount}
|
||||
warningThreshold={defaultWarningThreshold}
|
||||
/>
|
||||
</FeatureUsageCardBody>
|
||||
</FeatureUsageCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppsUsageCard;
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Box, ProgressBar } from '@rocket.chat/fuselage';
|
||||
import { useId, type ReactNode } from 'react';
|
||||
|
||||
export type AppsUsageCardSectionProps = {
|
||||
title: ReactNode;
|
||||
tip?: string;
|
||||
appsCount: number;
|
||||
appsMaxCount: number;
|
||||
warningThreshold: number;
|
||||
};
|
||||
|
||||
const AppsUsageCardSection = ({ title, tip, appsCount, appsMaxCount, warningThreshold }: AppsUsageCardSectionProps) => {
|
||||
const percentage = appsMaxCount === 0 ? 100 : Math.round((appsCount * 100) / appsMaxCount);
|
||||
const warningThresholdCrossed = percentage >= warningThreshold;
|
||||
const labelId = useId();
|
||||
|
||||
return (
|
||||
<Box fontScale='c1' marginBlock={12} title={tip} display='flex' flexDirection='column' width='100%'>
|
||||
<Box display='flex' flexGrow='1' justifyContent='space-between' marginBlockEnd={4}>
|
||||
<div id={labelId}>{title}</div>
|
||||
|
||||
<Box color={warningThresholdCrossed ? 'status-font-on-danger' : 'status-font-on-success'}>
|
||||
{appsCount} / {appsMaxCount}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<ProgressBar
|
||||
percentage={percentage}
|
||||
variant={warningThresholdCrossed ? 'danger' : 'success'}
|
||||
role='progressbar'
|
||||
aria-labelledby={labelId}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={percentage}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppsUsageCardSection;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './AppsUsageCard';
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Box, Icon } from '@rocket.chat/fuselage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import FeatureUsageCard from '../FeatureUsageCard';
|
||||
import FeatureUsageCardBody from '../FeatureUsageCardBody';
|
||||
|
||||
export type CountMACCardProps = { macsCount: number };
|
||||
|
||||
const CountMACCard = ({ macsCount }: CountMACCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<FeatureUsageCard
|
||||
card={{
|
||||
title: t('Monthly_active_contacts'),
|
||||
infoText: t('CountMAC_InfoText'),
|
||||
}}
|
||||
>
|
||||
<FeatureUsageCardBody>
|
||||
<Box fontScale='h1'>
|
||||
<Icon color='font-annotation' name='headset' size={40} marginInlineEnd={4} />
|
||||
{macsCount}
|
||||
</Box>
|
||||
</FeatureUsageCardBody>
|
||||
</FeatureUsageCard>
|
||||
);
|
||||
};
|
||||
export default CountMACCard;
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Box, Icon } from '@rocket.chat/fuselage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import FeatureUsageCard from '../FeatureUsageCard';
|
||||
import FeatureUsageCardBody from '../FeatureUsageCardBody';
|
||||
|
||||
export type CountSeatsCardProps = { activeUsers: number };
|
||||
|
||||
const CountSeatsCard = ({ activeUsers }: CountSeatsCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<FeatureUsageCard
|
||||
card={{
|
||||
title: t('Seats'),
|
||||
infoText: t('CountSeats_InfoText'),
|
||||
}}
|
||||
>
|
||||
<FeatureUsageCardBody>
|
||||
<Box fontScale='h1' display='flex' alignItems='center' justifyContent='center'>
|
||||
<Icon color='font-annotation' name='user' size={40} marginInlineEnd={4} />
|
||||
{activeUsers}
|
||||
</Box>
|
||||
</FeatureUsageCardBody>
|
||||
</FeatureUsageCard>
|
||||
);
|
||||
};
|
||||
export default CountSeatsCard;
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Box, Card, CardBody, CardControls, CardTitle, FramedIcon } from '@rocket.chat/fuselage';
|
||||
import { useMediaQuery } from '@rocket.chat/fuselage-hooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { PRICING_LINK } from '../../utils/links';
|
||||
import InfoTextIconModal from '../InfoTextIconModal';
|
||||
|
||||
type FeatureSet = {
|
||||
success?: boolean;
|
||||
neutral?: boolean;
|
||||
title: string;
|
||||
infoText?: string;
|
||||
};
|
||||
|
||||
export type FeaturesCardProps = {
|
||||
activeModules: string[];
|
||||
isEnterprise: boolean;
|
||||
};
|
||||
|
||||
const getFeatureSet = (modules: string[], isEnterprise: boolean): FeatureSet[] => {
|
||||
const featureSet: FeatureSet[] = [
|
||||
{
|
||||
success: isEnterprise,
|
||||
title: 'Premium_and_unlimited_apps',
|
||||
},
|
||||
{
|
||||
success: isEnterprise,
|
||||
title: 'Premium_omnichannel_capabilities',
|
||||
},
|
||||
{
|
||||
success: isEnterprise,
|
||||
title: 'Unlimited_push_notifications',
|
||||
},
|
||||
{
|
||||
success: modules.includes('videoconference-enterprise'),
|
||||
title: 'Video_call_manager',
|
||||
},
|
||||
{
|
||||
success: modules.includes('hide-watermark'),
|
||||
title: 'Remove_RocketChat_Watermark',
|
||||
infoText: 'Remove_RocketChat_Watermark_InfoText',
|
||||
},
|
||||
{
|
||||
success: modules.includes('scalability'),
|
||||
title: 'High_scalability',
|
||||
},
|
||||
{
|
||||
success: modules.includes('custom-roles'),
|
||||
title: 'Custom_roles',
|
||||
},
|
||||
{
|
||||
success: modules.includes('auditing'),
|
||||
title: 'Message_audit',
|
||||
},
|
||||
];
|
||||
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
return featureSet.sort(({ success: a }, { success: b }) => (a === b ? 0 : a ? -1 : 1));
|
||||
};
|
||||
|
||||
const FeaturesCard = ({ activeModules, isEnterprise }: FeaturesCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
const isSmall = useMediaQuery('(min-width: 1180px)');
|
||||
|
||||
return (
|
||||
<Card height='full'>
|
||||
<CardTitle>{!isEnterprise ? t('Unlock_premium_capabilities') : t('Includes')}</CardTitle>
|
||||
<CardBody>
|
||||
<Box display='flex' flexWrap='wrap' justifyContent='space-between' flexDirection={isSmall ? 'row' : 'column'}>
|
||||
{getFeatureSet(activeModules, isEnterprise).map(({ success, title, infoText }, index) => (
|
||||
<Box key={`feature_${index}`} display='flex' alignItems='center' marginBlockEnd={4} width={isSmall ? '50%' : 'full'}>
|
||||
<FramedIcon success={success} icon={success ? 'check' : 'lock'} />
|
||||
<Box is='p' fontScale='p2' marginInlineStart={12} marginInlineEnd={2} color='font-secondary-info'>
|
||||
{t(title)}
|
||||
</Box>
|
||||
{infoText && <InfoTextIconModal title={t(title)} infoText={t(infoText)} />}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</CardBody>
|
||||
<CardControls>
|
||||
<a target='_blank' rel='noopener noreferrer' href={PRICING_LINK}>
|
||||
{t('Compare_plans')}
|
||||
</a>
|
||||
</CardControls>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesCard;
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Palette } from '@rocket.chat/fuselage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { CardProps } from '../FeatureUsageCard';
|
||||
import FeatureUsageCard from '../FeatureUsageCard';
|
||||
import FeatureUsageCardBody from '../FeatureUsageCardBody';
|
||||
import UpgradeButton from '../UpgradeButton';
|
||||
import UsagePieGraph from '../UsagePieGraph';
|
||||
|
||||
export type MACCardProps = { value: number; max: number; hideManageSubscription?: boolean };
|
||||
|
||||
const MACCard = ({ value = 0, max, hideManageSubscription }: MACCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const pieGraph = {
|
||||
used: value,
|
||||
total: max,
|
||||
};
|
||||
|
||||
const nearLimit = pieGraph && pieGraph.used > 0 && pieGraph.used / pieGraph.total >= 0.8;
|
||||
|
||||
const macLeft = pieGraph.total - pieGraph.used;
|
||||
|
||||
const shouldShowBuyMore = !hideManageSubscription && nearLimit;
|
||||
const shouldShowUpgrade = hideManageSubscription && nearLimit;
|
||||
|
||||
const card: CardProps = {
|
||||
title: t('Monthly_active_contacts'),
|
||||
infoText: t('MAC_InfoText'),
|
||||
...(shouldShowBuyMore && {
|
||||
upgradeButton: (
|
||||
<UpgradeButton target='mac-card' action='buy_more' small>
|
||||
{t('Buy_more')}
|
||||
</UpgradeButton>
|
||||
),
|
||||
|
||||
...(shouldShowUpgrade && {
|
||||
upgradeButton: (
|
||||
<UpgradeButton target='mac-card' action='upgrade' small>
|
||||
{t('Upgrade')}
|
||||
</UpgradeButton>
|
||||
),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
const color = nearLimit ? Palette.statusColor['status-font-on-danger'].toString() : undefined;
|
||||
|
||||
const message = macLeft > 0 ? t('MAC_Available', { macLeft }) : undefined;
|
||||
|
||||
return (
|
||||
<FeatureUsageCard card={card}>
|
||||
<FeatureUsageCardBody>
|
||||
<UsagePieGraph label={message} used={pieGraph.used} total={pieGraph.total} color={color} />
|
||||
</FeatureUsageCardBody>
|
||||
</FeatureUsageCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default MACCard;
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { ILicenseV3 } from '@rocket.chat/core-typings';
|
||||
|
||||
import PlanCardCommunity from './PlanCard/PlanCardCommunity';
|
||||
import PlanCardPremium from './PlanCard/PlanCardPremium';
|
||||
import PlanCardTrial from './PlanCard/PlanCardTrial';
|
||||
|
||||
type LicenseLimits = {
|
||||
activeUsers: { max: number; value?: number };
|
||||
};
|
||||
|
||||
export type PlanCardProps = {
|
||||
license?: ILicenseV3;
|
||||
licenseLimits: LicenseLimits;
|
||||
};
|
||||
|
||||
const PlanCard = ({ license, licenseLimits }: PlanCardProps) => {
|
||||
const isTrial = license?.information.trial;
|
||||
|
||||
if (!license) {
|
||||
return <PlanCardCommunity />;
|
||||
}
|
||||
|
||||
return isTrial ? (
|
||||
<PlanCardTrial licenseInformation={license.information} />
|
||||
) : (
|
||||
<PlanCardPremium licenseInformation={license.information} licenseLimits={licenseLimits} />
|
||||
);
|
||||
};
|
||||
|
||||
export default PlanCard;
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Box, IconButton } from '@rocket.chat/fuselage';
|
||||
import { FilePreviewIcon } from '@rocket.chat/ui-client';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { getFileExtension } from '../../../../../../../../lib/utils/getFileExtension';
|
||||
import { formatBytes } from '../../../../../../../lib/utils/formatBytes';
|
||||
|
||||
const LicenseFilePreview = ({ selectedFile, handleRemoveFile }: { selectedFile: File; handleRemoveFile: () => void }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Box display='flex' alignItems='center' padding={4} marginBlockEnd={8} borderRadius={4} borderWidth={1} borderColor='extra-light'>
|
||||
<FilePreviewIcon format={getFileExtension(selectedFile.name)} />
|
||||
<Box flexGrow={1} withTruncatedText marginInlineStart={8} display='flex' flexDirection='column'>
|
||||
<Box fontScale='p2' color='info' withTruncatedText>
|
||||
{selectedFile.name}
|
||||
</Box>
|
||||
<Box fontScale='c1' color='hint' textTransform='uppercase'>
|
||||
{`${formatBytes(selectedFile.size, 2)} - ${getFileExtension(selectedFile.name)}`}
|
||||
</Box>
|
||||
</Box>
|
||||
<IconButton icon='cross' tiny title={t('Remove_file')} onClick={handleRemoveFile} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default LicenseFilePreview;
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Callout, Skeleton } from '@rocket.chat/fuselage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type LicenseStatusProps = {
|
||||
isValidating: boolean;
|
||||
isValid: boolean;
|
||||
invalidMessage: string;
|
||||
};
|
||||
|
||||
const LicenseStatus = ({ isValidating, isValid, invalidMessage }: LicenseStatusProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (isValidating) {
|
||||
return (
|
||||
<Callout role='status' icon='reload' type='info' title={`${t('Validating_license')}...`}>
|
||||
<Skeleton width='x320' />
|
||||
</Callout>
|
||||
);
|
||||
}
|
||||
|
||||
if (isValid) {
|
||||
return (
|
||||
<Callout role='status' type='success' title={t('Valid_license')}>
|
||||
{t('This_license_is_valid_and_ready_to_apply')}
|
||||
</Callout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Callout role='alert' type='danger' title={t('Invalid_license')}>
|
||||
{invalidMessage}
|
||||
</Callout>
|
||||
);
|
||||
};
|
||||
|
||||
export default LicenseStatus;
|
||||
@@ -0,0 +1,162 @@
|
||||
import type { BehaviorWithContext } from '@rocket.chat/core-typings';
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { composeStories } from '@storybook/react';
|
||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { axe } from 'jest-axe';
|
||||
|
||||
import ManageLicenseModal from './ManageLicenseModal';
|
||||
import * as stories from './ManageLicenseModal.stories';
|
||||
import createDeferredMockFn from '../../../../../../../../tests/mocks/utils/createDeferredMockFn';
|
||||
|
||||
// Long enough to pass isPlausibleLicense (>= 100 chars) so validation actually runs.
|
||||
const LICENSE = 'a'.repeat(120);
|
||||
const OTHER_LICENSE = 'b'.repeat(120);
|
||||
|
||||
const reasons = (...pairs: [string, string][]): BehaviorWithContext[] =>
|
||||
pairs.map(([behavior, reason]) => ({ behavior, reason }) as BehaviorWithContext);
|
||||
|
||||
// The endpoint is typed `void`; the REST client resolves it as `null`, so mocks must return null.
|
||||
const validationSuccess = () => null;
|
||||
const validationFailure = (fails: BehaviorWithContext[]) => () => Promise.reject({ reasons: fails });
|
||||
|
||||
// getByLabelText matches by label association regardless of visibility, so it reaches the display:none input.
|
||||
const fileInput = () => screen.getByLabelText('Upload_license_file');
|
||||
|
||||
const enterLicense = async (text: string) => {
|
||||
await userEvent.click(screen.getByRole('textbox'));
|
||||
await userEvent.paste(text);
|
||||
};
|
||||
|
||||
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
|
||||
|
||||
test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => {
|
||||
const { baseElement } = render(<Story />, { wrapper: mockAppRoot().build() });
|
||||
expect(baseElement).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => {
|
||||
const { container } = render(<Story />, { wrapper: mockAppRoot().build() });
|
||||
|
||||
const results = await axe(container);
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
|
||||
it('should show a success status for a valid license', async () => {
|
||||
render(<ManageLicenseModal enterpriseLicense='' onCancel={jest.fn()} />, {
|
||||
wrapper: mockAppRoot().withEndpoint('POST', '/v1/licenses.validate', validationSuccess).build(),
|
||||
});
|
||||
|
||||
await enterLicense(LICENSE);
|
||||
|
||||
expect(await screen.findByText('Valid_license')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should map a validation failure to a specific message', async () => {
|
||||
render(<ManageLicenseModal enterpriseLicense='' onCancel={jest.fn()} />, {
|
||||
wrapper: mockAppRoot()
|
||||
.withEndpoint('POST', '/v1/licenses.validate', validationFailure(reasons(['invalidate_license', 'period'])))
|
||||
.build(),
|
||||
});
|
||||
|
||||
await enterLicense(LICENSE);
|
||||
|
||||
expect(await screen.findByText('License_error_expired')).toBeInTheDocument();
|
||||
expect(screen.getByText('Invalid_license')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show a generic message when validation fails for a non-license reason', async () => {
|
||||
render(<ManageLicenseModal enterpriseLicense='' onCancel={jest.fn()} />, {
|
||||
wrapper: mockAppRoot()
|
||||
.withEndpoint('POST', '/v1/licenses.validate', () => Promise.reject(new Error('network down')))
|
||||
.build(),
|
||||
});
|
||||
|
||||
await enterLicense(LICENSE);
|
||||
|
||||
expect(await screen.findByText('License_error_generic')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show a validating status while the request is in flight', async () => {
|
||||
const { fn, resolve } = createDeferredMockFn<null>();
|
||||
|
||||
render(<ManageLicenseModal enterpriseLicense='' onCancel={jest.fn()} />, {
|
||||
wrapper: mockAppRoot().withEndpoint('POST', '/v1/licenses.validate', fn).build(),
|
||||
});
|
||||
|
||||
await enterLicense(LICENSE);
|
||||
|
||||
expect(await screen.findByText('Validating_license...')).toBeInTheDocument();
|
||||
|
||||
act(() => resolve(null));
|
||||
|
||||
expect(await screen.findByText('Valid_license')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should populate the preview and validate an uploaded .txt file', async () => {
|
||||
render(<ManageLicenseModal enterpriseLicense='' onCancel={jest.fn()} />, {
|
||||
wrapper: mockAppRoot().withEndpoint('POST', '/v1/licenses.validate', validationSuccess).build(),
|
||||
});
|
||||
|
||||
await userEvent.upload(fileInput(), new File([LICENSE], 'license.txt', { type: 'text/plain' }));
|
||||
|
||||
expect(await screen.findByText('license.txt')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Valid_license')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should reject an uploaded non-txt file with an error status', async () => {
|
||||
render(<ManageLicenseModal enterpriseLicense='' onCancel={jest.fn()} />, {
|
||||
wrapper: mockAppRoot().build(),
|
||||
});
|
||||
|
||||
// Bypass the input's `accept` filter so the guard in handleFile is what rejects the file.
|
||||
const user = userEvent.setup({ applyAccept: false });
|
||||
await user.upload(fileInput(), new File(['nope'], 'license.png', { type: 'image/png' }));
|
||||
|
||||
expect(await screen.findByText('Only_txt_license_files_are_supported')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply a valid license and close the modal', async () => {
|
||||
const onCancel = jest.fn();
|
||||
|
||||
render(<ManageLicenseModal enterpriseLicense='' onCancel={onCancel} />, {
|
||||
wrapper: mockAppRoot().withEndpoint('POST', '/v1/licenses.validate', validationSuccess).build(),
|
||||
});
|
||||
|
||||
await userEvent.upload(fileInput(), new File([OTHER_LICENSE], 'license.txt', { type: 'text/plain' }));
|
||||
|
||||
const applyButton = screen.getByRole('button', { name: 'Apply_license' });
|
||||
await waitFor(() => expect(applyButton).toBeEnabled());
|
||||
|
||||
await userEvent.click(applyButton);
|
||||
|
||||
await waitFor(() => expect(onCancel).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('should keep the apply button disabled after erasing a valid license', async () => {
|
||||
render(<ManageLicenseModal enterpriseLicense='' onCancel={jest.fn()} />, {
|
||||
wrapper: mockAppRoot().withEndpoint('POST', '/v1/licenses.validate', validationSuccess).build(),
|
||||
});
|
||||
|
||||
await enterLicense(LICENSE);
|
||||
|
||||
expect(await screen.findByText('Valid_license')).toBeInTheDocument();
|
||||
|
||||
const applyButton = screen.getByRole('button', { name: 'Apply_license' });
|
||||
await waitFor(() => expect(applyButton).toBeEnabled());
|
||||
|
||||
await userEvent.clear(screen.getByRole('textbox'));
|
||||
|
||||
await waitFor(() => expect(applyButton).toBeDisabled());
|
||||
});
|
||||
|
||||
it('should ask for confirmation before removing the current license', async () => {
|
||||
render(<ManageLicenseModal enterpriseLicense={LICENSE} onCancel={jest.fn()} />, {
|
||||
wrapper: mockAppRoot().withEndpoint('POST', '/v1/licenses.validate', validationSuccess).build(),
|
||||
});
|
||||
|
||||
// The remove action only shows when the entered license is the applied one.
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Remove_license' }));
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Remove_license_key' })).toBeInTheDocument();
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { BehaviorWithContext } from '@rocket.chat/core-typings';
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { action } from 'storybook/actions';
|
||||
import { screen, userEvent, expect } from 'storybook/test';
|
||||
|
||||
import ManageLicenseModal from './ManageLicenseModal';
|
||||
|
||||
const SAMPLE_LICENSE = 'RCLICENSE-'.padEnd(120, 'x');
|
||||
|
||||
const validationSuccess = () => null;
|
||||
const validationFailure = (reasons: BehaviorWithContext[]) => () => Promise.reject({ reasons });
|
||||
|
||||
const enterLicense = async (text: string) => {
|
||||
await userEvent.click(screen.getByRole('textbox'));
|
||||
await userEvent.paste(text);
|
||||
};
|
||||
|
||||
export default {
|
||||
component: ManageLicenseModal,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
args: {
|
||||
onCancel: action('onCancel'),
|
||||
},
|
||||
decorators: [
|
||||
(Story) => {
|
||||
const AppRoot = mockAppRoot().withEndpoint('POST', '/v1/licenses.validate', validationSuccess).build();
|
||||
|
||||
return (
|
||||
<AppRoot>
|
||||
<Story />
|
||||
</AppRoot>
|
||||
);
|
||||
},
|
||||
],
|
||||
} satisfies Meta<typeof ManageLicenseModal>;
|
||||
|
||||
type Story = StoryObj<typeof ManageLicenseModal>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
enterpriseLicense: '',
|
||||
},
|
||||
};
|
||||
|
||||
export const WithCurrentLicense: Story = {
|
||||
args: {
|
||||
enterpriseLicense: SAMPLE_LICENSE,
|
||||
},
|
||||
};
|
||||
|
||||
export const InvalidLicense: Story = {
|
||||
args: {
|
||||
enterpriseLicense: '',
|
||||
},
|
||||
decorators: [
|
||||
(Story) => {
|
||||
const AppRoot = mockAppRoot()
|
||||
.withEndpoint('POST', '/v1/licenses.validate', validationFailure([{ behavior: 'invalidate_license', reason: 'period' }]))
|
||||
.build();
|
||||
|
||||
return (
|
||||
<AppRoot>
|
||||
<Story />
|
||||
</AppRoot>
|
||||
);
|
||||
},
|
||||
],
|
||||
play: async () => {
|
||||
await enterLicense(SAMPLE_LICENSE);
|
||||
await expect(await screen.findByText('Invalid_license')).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,181 @@
|
||||
import { Box, Button, ButtonGroup } from '@rocket.chat/fuselage';
|
||||
import { FieldLabel, Field, FieldRow, TextAreaInput } from '@rocket.chat/fuselage-forms';
|
||||
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
|
||||
import { GenericModal, useInvalidateLicense } from '@rocket.chat/ui-client';
|
||||
import { useSettingSetValue, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import LicenseFilePreview from './LicenseFilePreview';
|
||||
import LicenseStatus from './LicenseStatus';
|
||||
import { getLicenseInvalidMessage } from './getLicenseInvalidMessage';
|
||||
import { useLicenseFileInput } from './useLicenseFileInput';
|
||||
import { isPlausibleLicense, useValidateLicense } from '../../../../hooks/useValidateLicense';
|
||||
|
||||
type ManageLicenseModalProps = {
|
||||
enterpriseLicense: string;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
const ManageLicenseModal = ({ enterpriseLicense, onCancel }: ManageLicenseModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const dispatchToastMessage = useToastMessageDispatch();
|
||||
const setEnterpriseLicense = useSettingSetValue('Enterprise_License');
|
||||
const invalidateLicense = useInvalidateLicense();
|
||||
|
||||
const [isConfirmingRemoval, setIsConfirmingRemoval] = useState(false);
|
||||
|
||||
const {
|
||||
selectedFile,
|
||||
fileError,
|
||||
license,
|
||||
isDragOver,
|
||||
handleRemoveFile,
|
||||
handleFile,
|
||||
handleDrop,
|
||||
handleDragLeave,
|
||||
handleDragOver,
|
||||
handleTextChange,
|
||||
} = useLicenseFileInput(enterpriseLicense);
|
||||
|
||||
const trimmedLicense = license.trim();
|
||||
const debouncedLicense = useDebouncedValue(trimmedLicense, 500);
|
||||
|
||||
const isEmpty = trimmedLicense === '';
|
||||
const isPlausible = isPlausibleLicense(trimmedLicense);
|
||||
const isCurrentLicense = !isEmpty && trimmedLicense === enterpriseLicense.trim();
|
||||
|
||||
const { data: validation, isPending, isError } = useValidateLicense(debouncedLicense, !isCurrentLicense);
|
||||
|
||||
const isValidating = isPlausible && !isCurrentLicense && (trimmedLicense !== debouncedLicense || isPending);
|
||||
|
||||
const isLicenseValid = isPlausible && !fileError && !isError && validation?.valid === true;
|
||||
|
||||
const invalidMessage = (() => {
|
||||
if (fileError) {
|
||||
return fileError;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return t('License_error_generic');
|
||||
}
|
||||
|
||||
return t(getLicenseInvalidMessage(validation?.reasons ?? []));
|
||||
})();
|
||||
|
||||
const showStatus = !isCurrentLicense && (isPlausible || Boolean(fileError));
|
||||
|
||||
const handleApply = async () => {
|
||||
try {
|
||||
await setEnterpriseLicense(license);
|
||||
invalidateLicense(100);
|
||||
onCancel();
|
||||
dispatchToastMessage({ type: 'success', message: t('Cloud_License_applied_successfully') });
|
||||
} catch (err) {
|
||||
dispatchToastMessage({ type: 'error', message: err });
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async () => {
|
||||
try {
|
||||
await setEnterpriseLicense('');
|
||||
invalidateLicense(100);
|
||||
onCancel();
|
||||
dispatchToastMessage({ type: 'success', message: t('License_removed_successfully') });
|
||||
} catch (err) {
|
||||
dispatchToastMessage({ type: 'error', message: err });
|
||||
}
|
||||
};
|
||||
|
||||
if (isConfirmingRemoval) {
|
||||
return (
|
||||
<GenericModal
|
||||
variant='secondary-danger'
|
||||
title={t('Remove_license_key')}
|
||||
confirmText={t('Apply_community_license')}
|
||||
onConfirm={handleRemove}
|
||||
onCancel={() => setIsConfirmingRemoval(false)}
|
||||
>
|
||||
<Box display='flex' flexDirection='column' gap={16}>
|
||||
<Box>{t('Remove_license_confirmation')}</Box>
|
||||
<Box>{t('Are_you_sure_you_want_to_continue_question')}</Box>
|
||||
<Box fontScale='c1' color='secondary-info'>
|
||||
{t('Remove_license_disclaimer')}
|
||||
</Box>
|
||||
</Box>
|
||||
</GenericModal>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<GenericModal
|
||||
icon={null}
|
||||
variant='warning'
|
||||
title={t('Manage_license')}
|
||||
confirmText={t('Apply_license')}
|
||||
confirmDisabled={!isLicenseValid || isCurrentLicense || isValidating}
|
||||
onConfirm={handleApply}
|
||||
onCancel={onCancel}
|
||||
>
|
||||
<Box fontScale='p2' marginBlockEnd={8}>
|
||||
{t('Manage_license_description')}
|
||||
</Box>
|
||||
<Box
|
||||
is='input'
|
||||
ref={inputRef}
|
||||
type='file'
|
||||
accept='.txt,text/plain'
|
||||
aria-label={t('Upload_license_file')}
|
||||
display='none'
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => {
|
||||
void handleFile(event.currentTarget.files?.[0]);
|
||||
// Reset so selecting the same file again still fires onChange.
|
||||
event.currentTarget.value = '';
|
||||
}}
|
||||
/>
|
||||
{selectedFile && <LicenseFilePreview selectedFile={selectedFile} handleRemoveFile={handleRemoveFile} />}
|
||||
<Field>
|
||||
<FieldLabel>{t('License_key')}</FieldLabel>
|
||||
<FieldRow>
|
||||
<TextAreaInput
|
||||
withRichContent
|
||||
marginBlockEnd={8}
|
||||
width='100%'
|
||||
backgroundColor={isDragOver ? 'tint' : undefined}
|
||||
borderStyle={isDragOver ? 'dashed' : 'solid'}
|
||||
borderColor={isDragOver ? 'stroke-highlight' : 'stroke-light'}
|
||||
style={{ fontFamily: 'monospace' }}
|
||||
rows={4}
|
||||
placeholder={t('Drag_and_drop_license_placeholder')}
|
||||
value={license}
|
||||
onChange={handleTextChange}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnter={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
/>
|
||||
</FieldRow>
|
||||
</Field>
|
||||
<ButtonGroup>
|
||||
<Button icon='upload' small onClick={() => inputRef.current?.click()}>
|
||||
{t('Upload_license_file')}
|
||||
</Button>
|
||||
{isCurrentLicense && (
|
||||
<Button icon='trash' small secondary danger onClick={() => setIsConfirmingRemoval(true)}>
|
||||
{t('Remove_license')}
|
||||
</Button>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
{showStatus && (
|
||||
<Box marginBlockStart={16}>
|
||||
<LicenseStatus isValidating={isValidating} isValid={isLicenseValid} invalidMessage={invalidMessage} />
|
||||
</Box>
|
||||
)}
|
||||
</GenericModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ManageLicenseModal;
|
||||
@@ -0,0 +1,451 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
|
||||
exports[`renders Default without crashing 1`] = `
|
||||
<body>
|
||||
<div>
|
||||
<dialog
|
||||
aria-labelledby="_r_0_-title"
|
||||
aria-modal="true"
|
||||
class="rcx-box rcx-box--full rcx-modal"
|
||||
open=""
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__inner rcx-css-qtwduz"
|
||||
>
|
||||
<header
|
||||
class="rcx-box rcx-box--full rcx-modal__header"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__header-inner"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__header-text rcx-css-trljwa rcx-css-lma364"
|
||||
>
|
||||
<h2
|
||||
class="rcx-box rcx-box--full rcx-modal__title"
|
||||
id="_r_0_-title"
|
||||
>
|
||||
Manage_license
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Close"
|
||||
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-trljwa rcx-css-lma364"
|
||||
tabindex="-1"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-icon--name-cross rcx-icon rcx-css-4pvxx3"
|
||||
>
|
||||
|
||||
</i>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__content rcx-css-1vw7itl"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__content-wrapper rcx-css-r1bpeb"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-rwaa9d"
|
||||
>
|
||||
Manage_license_description
|
||||
</div>
|
||||
<input
|
||||
accept=".txt,text/plain"
|
||||
aria-label="Upload_license_file"
|
||||
class="rcx-box rcx-box--full rcx-css-1jvv3p7"
|
||||
type="file"
|
||||
/>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-field"
|
||||
>
|
||||
<label
|
||||
class="rcx-box rcx-box--full rcx-field__label rcx-label"
|
||||
for="react-aria-_r_1_-label"
|
||||
>
|
||||
License_key
|
||||
</label>
|
||||
<span
|
||||
class="rcx-box rcx-box--full rcx-field__row"
|
||||
>
|
||||
<textarea
|
||||
aria-describedby=""
|
||||
aria-invalid="false"
|
||||
class="rcx-box rcx-box--full rcx-box--with-block-elements rcx-box--with-inline-elements rcx-box--animated rcx-input-box--type-textarea rcx-input-box rcx-css-1v59bga"
|
||||
cols="1"
|
||||
id="react-aria-_r_1_-label"
|
||||
placeholder="Drag_and_drop_license_placeholder"
|
||||
rows="4"
|
||||
size="1"
|
||||
style="font-family: monospace;"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="rcx-button-group rcx-button-group--align-start"
|
||||
role="group"
|
||||
>
|
||||
<button
|
||||
class="rcx-box rcx-box--full rcx-button--small rcx-button"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="rcx-button--content"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-icon--name-upload rcx-icon rcx-css-1hdf9ok"
|
||||
>
|
||||
|
||||
</i>
|
||||
Upload_license_file
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__footer rcx-css-17mu816"
|
||||
>
|
||||
<div
|
||||
class="rcx-button-group rcx-button-group--align-end"
|
||||
role="group"
|
||||
>
|
||||
<button
|
||||
class="rcx-box rcx-box--full rcx-button--secondary rcx-button"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="rcx-button--content"
|
||||
>
|
||||
Cancel
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="rcx-box rcx-box--full rcx-button--primary rcx-button"
|
||||
disabled=""
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="rcx-button--content"
|
||||
>
|
||||
Apply_license
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
|
||||
exports[`renders InvalidLicense without crashing 1`] = `
|
||||
<body>
|
||||
<div>
|
||||
<dialog
|
||||
aria-labelledby="_r_2_-title"
|
||||
aria-modal="true"
|
||||
class="rcx-box rcx-box--full rcx-modal"
|
||||
open=""
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__inner rcx-css-qtwduz"
|
||||
>
|
||||
<header
|
||||
class="rcx-box rcx-box--full rcx-modal__header"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__header-inner"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__header-text rcx-css-trljwa rcx-css-lma364"
|
||||
>
|
||||
<h2
|
||||
class="rcx-box rcx-box--full rcx-modal__title"
|
||||
id="_r_2_-title"
|
||||
>
|
||||
Manage_license
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Close"
|
||||
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-trljwa rcx-css-lma364"
|
||||
tabindex="-1"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-icon--name-cross rcx-icon rcx-css-4pvxx3"
|
||||
>
|
||||
|
||||
</i>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__content rcx-css-1vw7itl"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__content-wrapper rcx-css-r1bpeb"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-rwaa9d"
|
||||
>
|
||||
Manage_license_description
|
||||
</div>
|
||||
<input
|
||||
accept=".txt,text/plain"
|
||||
aria-label="Upload_license_file"
|
||||
class="rcx-box rcx-box--full rcx-css-1jvv3p7"
|
||||
type="file"
|
||||
/>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-field"
|
||||
>
|
||||
<label
|
||||
class="rcx-box rcx-box--full rcx-field__label rcx-label"
|
||||
for="react-aria-_r_3_-label"
|
||||
>
|
||||
License_key
|
||||
</label>
|
||||
<span
|
||||
class="rcx-box rcx-box--full rcx-field__row"
|
||||
>
|
||||
<textarea
|
||||
aria-describedby=""
|
||||
aria-invalid="false"
|
||||
class="rcx-box rcx-box--full rcx-box--with-block-elements rcx-box--with-inline-elements rcx-box--animated rcx-input-box--type-textarea rcx-input-box rcx-css-1v59bga"
|
||||
cols="1"
|
||||
id="react-aria-_r_3_-label"
|
||||
placeholder="Drag_and_drop_license_placeholder"
|
||||
rows="4"
|
||||
size="1"
|
||||
style="font-family: monospace;"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="rcx-button-group rcx-button-group--align-start"
|
||||
role="group"
|
||||
>
|
||||
<button
|
||||
class="rcx-box rcx-box--full rcx-button--small rcx-button"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="rcx-button--content"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-icon--name-upload rcx-icon rcx-css-1hdf9ok"
|
||||
>
|
||||
|
||||
</i>
|
||||
Upload_license_file
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__footer rcx-css-17mu816"
|
||||
>
|
||||
<div
|
||||
class="rcx-button-group rcx-button-group--align-end"
|
||||
role="group"
|
||||
>
|
||||
<button
|
||||
class="rcx-box rcx-box--full rcx-button--secondary rcx-button"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="rcx-button--content"
|
||||
>
|
||||
Cancel
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="rcx-box rcx-box--full rcx-button--primary rcx-button"
|
||||
disabled=""
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="rcx-button--content"
|
||||
>
|
||||
Apply_license
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
|
||||
exports[`renders WithCurrentLicense without crashing 1`] = `
|
||||
<body>
|
||||
<div>
|
||||
<dialog
|
||||
aria-labelledby="_r_4_-title"
|
||||
aria-modal="true"
|
||||
class="rcx-box rcx-box--full rcx-modal"
|
||||
open=""
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__inner rcx-css-qtwduz"
|
||||
>
|
||||
<header
|
||||
class="rcx-box rcx-box--full rcx-modal__header"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__header-inner"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__header-text rcx-css-trljwa rcx-css-lma364"
|
||||
>
|
||||
<h2
|
||||
class="rcx-box rcx-box--full rcx-modal__title"
|
||||
id="_r_4_-title"
|
||||
>
|
||||
Manage_license
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Close"
|
||||
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-trljwa rcx-css-lma364"
|
||||
tabindex="-1"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-icon--name-cross rcx-icon rcx-css-4pvxx3"
|
||||
>
|
||||
|
||||
</i>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__content rcx-css-1vw7itl"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__content-wrapper rcx-css-r1bpeb"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-rwaa9d"
|
||||
>
|
||||
Manage_license_description
|
||||
</div>
|
||||
<input
|
||||
accept=".txt,text/plain"
|
||||
aria-label="Upload_license_file"
|
||||
class="rcx-box rcx-box--full rcx-css-1jvv3p7"
|
||||
type="file"
|
||||
/>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-field"
|
||||
>
|
||||
<label
|
||||
class="rcx-box rcx-box--full rcx-field__label rcx-label"
|
||||
for="react-aria-_r_5_-label"
|
||||
>
|
||||
License_key
|
||||
</label>
|
||||
<span
|
||||
class="rcx-box rcx-box--full rcx-field__row"
|
||||
>
|
||||
<textarea
|
||||
aria-describedby=""
|
||||
aria-invalid="false"
|
||||
class="rcx-box rcx-box--full rcx-box--with-block-elements rcx-box--with-inline-elements rcx-box--animated rcx-input-box--type-textarea rcx-input-box rcx-css-1v59bga"
|
||||
cols="1"
|
||||
id="react-aria-_r_5_-label"
|
||||
placeholder="Drag_and_drop_license_placeholder"
|
||||
rows="4"
|
||||
size="1"
|
||||
style="font-family: monospace;"
|
||||
>
|
||||
RCLICENSE-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
</textarea>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="rcx-button-group rcx-button-group--align-start"
|
||||
role="group"
|
||||
>
|
||||
<button
|
||||
class="rcx-box rcx-box--full rcx-button--small rcx-button"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="rcx-button--content"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-icon--name-upload rcx-icon rcx-css-1hdf9ok"
|
||||
>
|
||||
|
||||
</i>
|
||||
Upload_license_file
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="rcx-box rcx-box--full rcx-button--small rcx-button--secondary-danger rcx-button"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="rcx-button--content"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-icon--name-trash rcx-icon rcx-css-1hdf9ok"
|
||||
>
|
||||
|
||||
</i>
|
||||
Remove_license
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-modal__footer rcx-css-17mu816"
|
||||
>
|
||||
<div
|
||||
class="rcx-button-group rcx-button-group--align-end"
|
||||
role="group"
|
||||
>
|
||||
<button
|
||||
class="rcx-box rcx-box--full rcx-button--secondary rcx-button"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="rcx-button--content"
|
||||
>
|
||||
Cancel
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="rcx-box rcx-box--full rcx-button--primary rcx-button"
|
||||
disabled=""
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="rcx-button--content"
|
||||
>
|
||||
Apply_license
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { BehaviorWithContext } from '@rocket.chat/core-typings';
|
||||
|
||||
import { getLicenseInvalidMessage } from './getLicenseInvalidMessage';
|
||||
|
||||
const reason = (behavior: string, reason: string) => ({ behavior, reason }) as BehaviorWithContext;
|
||||
|
||||
it('should treat an empty reason list as an undecodable license', () => {
|
||||
expect(getLicenseInvalidMessage([])).toBe('License_error_format_invalid');
|
||||
});
|
||||
|
||||
it('should map an invalidate_license/url reason to a workspace URL mismatch', () => {
|
||||
expect(getLicenseInvalidMessage([reason('invalidate_license', 'url')])).toBe('License_error_url_mismatch');
|
||||
});
|
||||
|
||||
it('should map an invalidate_license/period reason to an expired license', () => {
|
||||
expect(getLicenseInvalidMessage([reason('invalidate_license', 'period')])).toBe('License_error_expired');
|
||||
});
|
||||
|
||||
it('should map a prevent_installation/period reason to an out-of-date-range license', () => {
|
||||
expect(getLicenseInvalidMessage([reason('prevent_installation', 'period')])).toBe('License_error_outside_date_range');
|
||||
});
|
||||
|
||||
it('should map a limit reason from either behavior to exceeded limits', () => {
|
||||
expect(getLicenseInvalidMessage([reason('invalidate_license', 'limit')])).toBe('License_error_limits_exceeded');
|
||||
expect(getLicenseInvalidMessage([reason('prevent_installation', 'limit')])).toBe('License_error_limits_exceeded');
|
||||
});
|
||||
|
||||
it('should prioritize a URL mismatch over other reasons', () => {
|
||||
expect(getLicenseInvalidMessage([reason('invalidate_license', 'period'), reason('invalidate_license', 'url')])).toBe(
|
||||
'License_error_url_mismatch',
|
||||
);
|
||||
});
|
||||
|
||||
it('should fall back to a generic message for unrecognized reasons', () => {
|
||||
expect(getLicenseInvalidMessage([reason('invalidate_license', 'something_new')])).toBe('License_error_generic');
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { BehaviorWithContext, LicenseBehavior } from '@rocket.chat/core-typings';
|
||||
import type { TranslationKey } from '@rocket.chat/ui-contexts';
|
||||
|
||||
export const getLicenseInvalidMessage = (reasons: BehaviorWithContext[]): TranslationKey => {
|
||||
const hasErrorReason = (behavior: LicenseBehavior, reason: string) => reasons.some((r) => r.behavior === behavior && r.reason === reason);
|
||||
|
||||
if (reasons.length === 0) {
|
||||
return 'License_error_format_invalid';
|
||||
}
|
||||
|
||||
if (hasErrorReason('invalidate_license', 'url')) {
|
||||
return 'License_error_url_mismatch';
|
||||
}
|
||||
|
||||
if (hasErrorReason('invalidate_license', 'period')) {
|
||||
return 'License_error_expired';
|
||||
}
|
||||
|
||||
if (hasErrorReason('prevent_installation', 'period')) {
|
||||
return 'License_error_outside_date_range';
|
||||
}
|
||||
|
||||
if (hasErrorReason('invalidate_license', 'limit') || hasErrorReason('prevent_installation', 'limit')) {
|
||||
return 'License_error_limits_exceeded';
|
||||
}
|
||||
|
||||
return 'License_error_generic';
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './ManageLicenseModal';
|
||||
@@ -0,0 +1,108 @@
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import type { DragEvent } from 'react';
|
||||
|
||||
import { useLicenseFileInput } from './useLicenseFileInput';
|
||||
|
||||
const CURRENT_LICENSE = 'current-license-key';
|
||||
|
||||
const txtFile = (content: string, name = 'license.txt') => new File([content], name, { type: 'text/plain' });
|
||||
|
||||
const dragEvent = (types: string[], file?: File) =>
|
||||
({
|
||||
preventDefault: jest.fn(),
|
||||
stopPropagation: jest.fn(),
|
||||
dataTransfer: { types, dropEffect: '', files: file ? [file] : [] },
|
||||
}) as unknown as DragEvent<HTMLElement>;
|
||||
|
||||
const renderInput = (initial = CURRENT_LICENSE) => renderHook(() => useLicenseFileInput(initial), { wrapper: mockAppRoot().build() });
|
||||
|
||||
it('should seed the license with the current license', () => {
|
||||
const { result } = renderInput();
|
||||
|
||||
expect(result.current.license).toBe(CURRENT_LICENSE);
|
||||
expect(result.current.selectedFile).toBeUndefined();
|
||||
expect(result.current.fileError).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should read a .txt file into the license and expose the selected file', async () => {
|
||||
const { result } = renderInput('');
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleFile(txtFile('license-from-file'));
|
||||
});
|
||||
|
||||
expect(result.current.license).toBe('license-from-file');
|
||||
expect(result.current.selectedFile?.name).toBe('license.txt');
|
||||
expect(result.current.fileError).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reject a non-txt file without touching the license', async () => {
|
||||
const { result } = renderInput(CURRENT_LICENSE);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleFile(new File(['nope'], 'license.png', { type: 'image/png' }));
|
||||
});
|
||||
|
||||
expect(result.current.fileError).toBe('Only_txt_license_files_are_supported');
|
||||
expect(result.current.selectedFile).toBeUndefined();
|
||||
expect(result.current.license).toBe(CURRENT_LICENSE);
|
||||
});
|
||||
|
||||
it('should clear file state and license when the file is removed', async () => {
|
||||
const { result } = renderInput('');
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleFile(txtFile('license-from-file'));
|
||||
});
|
||||
act(() => result.current.handleRemoveFile());
|
||||
|
||||
expect(result.current.license).toBe('');
|
||||
expect(result.current.selectedFile).toBeUndefined();
|
||||
expect(result.current.fileError).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should detach the selected file when the text is edited by hand', async () => {
|
||||
const { result } = renderInput('');
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleFile(txtFile('license-from-file'));
|
||||
});
|
||||
act(() => {
|
||||
result.current.handleTextChange({ currentTarget: { value: 'typed-by-hand' } } as any);
|
||||
});
|
||||
|
||||
expect(result.current.license).toBe('typed-by-hand');
|
||||
expect(result.current.selectedFile).toBeUndefined();
|
||||
expect(result.current.fileError).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should flag drag-over only for file drags and set the copy drop effect', () => {
|
||||
const { result } = renderInput();
|
||||
|
||||
const fileDrag = dragEvent(['Files']);
|
||||
act(() => result.current.handleDragOver(fileDrag));
|
||||
expect(result.current.isDragOver).toBe(true);
|
||||
expect(fileDrag.preventDefault).toHaveBeenCalled();
|
||||
expect(fileDrag.dataTransfer.dropEffect).toBe('copy');
|
||||
});
|
||||
|
||||
it('should ignore text drags so the textarea handles them natively', () => {
|
||||
const { result } = renderInput();
|
||||
|
||||
const textDrag = dragEvent(['text/plain']);
|
||||
act(() => result.current.handleDragOver(textDrag));
|
||||
|
||||
expect(result.current.isDragOver).toBe(false);
|
||||
expect(textDrag.preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should read the dropped file and clear the drag-over state', async () => {
|
||||
const { result } = renderInput('');
|
||||
|
||||
act(() => result.current.handleDragOver(dragEvent(['Files'])));
|
||||
act(() => result.current.handleDrop(dragEvent(['Files'], txtFile('dropped-license'))));
|
||||
|
||||
expect(result.current.isDragOver).toBe(false);
|
||||
await waitFor(() => expect(result.current.license).toBe('dropped-license'));
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { ChangeEvent, DragEvent } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const isTxtFile = (file: File): boolean => file.type === 'text/plain' || file.name.toLowerCase().endsWith('.txt');
|
||||
|
||||
const readFileAsText = (file: File): Promise<string> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const result = event.target?.result;
|
||||
if (typeof result === 'string') {
|
||||
resolve(result);
|
||||
return;
|
||||
}
|
||||
reject(new Error('Failed to read file'));
|
||||
};
|
||||
reader.onerror = () => reject(reader.error ?? new Error('Failed to read file'));
|
||||
reader.readAsText(file);
|
||||
});
|
||||
|
||||
const isFileDrag = (event: DragEvent<HTMLElement>) => event.dataTransfer.types.includes('Files');
|
||||
|
||||
export const useLicenseFileInput = (enterpriseLicense: string) => {
|
||||
const { t } = useTranslation();
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState<File>();
|
||||
const [fileError, setFileError] = useState<string>();
|
||||
const [license, setLicense] = useState(enterpriseLicense);
|
||||
|
||||
const handleFile = async (selected: File | undefined) => {
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isTxtFile(selected)) {
|
||||
setSelectedFile(undefined);
|
||||
setFileError(t('Only_txt_license_files_are_supported'));
|
||||
return;
|
||||
}
|
||||
|
||||
setFileError(undefined);
|
||||
setSelectedFile(selected);
|
||||
try {
|
||||
setLicense(await readFileAsText(selected));
|
||||
} catch {
|
||||
setSelectedFile(undefined);
|
||||
setLicense('');
|
||||
setFileError(t('Failed_to_read_file'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveFile = () => {
|
||||
setSelectedFile(undefined);
|
||||
setFileError(undefined);
|
||||
setLicense('');
|
||||
};
|
||||
|
||||
const handleDrop = (event: DragEvent<HTMLElement>) => {
|
||||
if (!isFileDrag(event)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
void handleFile(event.dataTransfer.files?.[0]);
|
||||
};
|
||||
|
||||
const handleDragLeave = (event: DragEvent<HTMLElement>) => {
|
||||
if (!isDragOver) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
};
|
||||
|
||||
const handleDragOver = (event: DragEvent<HTMLElement>) => {
|
||||
if (!isFileDrag(event)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.dataTransfer.dropEffect = 'copy';
|
||||
setIsDragOver(true);
|
||||
};
|
||||
|
||||
const handleTextChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setSelectedFile(undefined);
|
||||
setFileError(undefined);
|
||||
setLicense(event.currentTarget.value);
|
||||
};
|
||||
|
||||
return {
|
||||
selectedFile,
|
||||
fileError,
|
||||
license,
|
||||
isDragOver,
|
||||
handleFile,
|
||||
handleRemoveFile,
|
||||
handleDrop,
|
||||
handleDragLeave,
|
||||
handleDragOver,
|
||||
handleTextChange,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Box, Card, CardBody, Icon } from '@rocket.chat/fuselage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import PlanCardHeader from './PlanCardHeader';
|
||||
import PlanCardLicenseDetails from './PlanCardLicenseDetails';
|
||||
|
||||
const PlanCardCommunity = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Card height='full'>
|
||||
<PlanCardHeader name={t('Community')} />
|
||||
<CardBody flexDirection='column'>
|
||||
<Box display='flex' flexDirection='row' alignItems='center'>
|
||||
<Icon name='card' size={24} marginInlineEnd={12} /> {t('free_per_month_user')}
|
||||
</Box>
|
||||
<Box display='flex' flexDirection='row' alignItems='center'>
|
||||
<Icon name='cloud-plus' size={24} marginInlineEnd={12} /> {t('Self_managed_hosting')}
|
||||
</Box>
|
||||
<PlanCardLicenseDetails />
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlanCardCommunity;
|
||||
@@ -0,0 +1,14 @@
|
||||
import { CardTitle, Icon, Palette, CardHeader } from '@rocket.chat/fuselage';
|
||||
|
||||
export type PlanCardHeaderProps = { name: string };
|
||||
|
||||
const PlanCardHeader = ({ name }: PlanCardHeaderProps) => {
|
||||
return (
|
||||
<CardHeader>
|
||||
<Icon name='rocketchat' color={Palette.badge['badge-background-level-4'].toString()} size='x28' marginInlineEnd={4} />
|
||||
<CardTitle variant='h3'>{name}</CardTitle>
|
||||
</CardHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlanCardHeader;
|
||||
@@ -0,0 +1,66 @@
|
||||
import { IconButton, Divider, Box } from '@rocket.chat/fuselage';
|
||||
import { ActionLink } from '@rocket.chat/layout';
|
||||
import { usePermission, useSetModal, useSetting } from '@rocket.chat/ui-contexts';
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import ManageLicenseModal from './ManageLicenseModal';
|
||||
import useClipboardWithToast from '../../../../../../hooks/useClipboardWithToast';
|
||||
import { useServerInfo } from '../../../../../../hooks/useWorkspaceInfo';
|
||||
|
||||
const PlanCardLicenseDetails = () => {
|
||||
const { t } = useTranslation();
|
||||
const setModal = useSetModal();
|
||||
const hasPermission = usePermission('edit-privileged-setting');
|
||||
const siteURL = useSetting('Site_Url', '');
|
||||
const enterpriseLicense = useSetting('Enterprise_License', '');
|
||||
const { data: serverInfo } = useServerInfo();
|
||||
|
||||
const hashedSiteURL = serverInfo?.hashedWorkspaceUrl ?? '';
|
||||
const { copy: copySiteURL, hasCopied: hasCopiedSiteURL } = useClipboardWithToast(siteURL);
|
||||
const { copy: copyHashed, hasCopied: hasCopiedHashed } = useClipboardWithToast(hashedSiteURL);
|
||||
|
||||
const handleOpenModal = useCallback(
|
||||
() => setModal(<ManageLicenseModal enterpriseLicense={enterpriseLicense} onCancel={() => setModal(null)} />),
|
||||
[enterpriseLicense, setModal],
|
||||
);
|
||||
|
||||
if (!hasPermission) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Divider />
|
||||
<Box>
|
||||
<Box display='flex'>
|
||||
<Box marginInlineEnd={4}>{t('Site_Url')}</Box>
|
||||
{hasCopiedSiteURL ? (
|
||||
<IconButton success icon='check' mini />
|
||||
) : (
|
||||
<IconButton title={t('Copy')} icon='clipboard' mini onClick={() => !hasCopiedSiteURL && copySiteURL()} />
|
||||
)}
|
||||
</Box>
|
||||
<Box withTruncatedText fontScale='p2'>
|
||||
{siteURL}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box>
|
||||
<Box display='flex'>
|
||||
<Box marginInlineEnd={4}>{t('Hashed_Site_Url')}</Box>
|
||||
{hasCopiedHashed ? (
|
||||
<IconButton success icon='check' mini />
|
||||
) : (
|
||||
<IconButton title={t('Copy')} icon='clipboard' mini onClick={() => !hasCopiedHashed && copyHashed()} />
|
||||
)}
|
||||
</Box>
|
||||
<Box withTruncatedText fontScale='p2'>
|
||||
{hashedSiteURL}
|
||||
</Box>
|
||||
</Box>
|
||||
<ActionLink onClick={handleOpenModal}>{t('Manage_license')}</ActionLink>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlanCardLicenseDetails;
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { ILicenseV3 } from '@rocket.chat/core-typings';
|
||||
import { Box, Card, CardBody, Icon, Skeleton } from '@rocket.chat/fuselage';
|
||||
import { ExternalLink, useLicenseName } from '@rocket.chat/ui-client';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import PlanCardHeader from './PlanCardHeader';
|
||||
import PlanCardLicenseDetails from './PlanCardLicenseDetails';
|
||||
import { useFormatDate } from '../../../../../../hooks/useFormatDate';
|
||||
import { useIsSelfHosted } from '../../../../../../hooks/useIsSelfHosted';
|
||||
import { CONTACT_SALES_LINK } from '../../../utils/links';
|
||||
|
||||
type LicenseLimits = {
|
||||
activeUsers: { max: number; value?: number };
|
||||
};
|
||||
|
||||
type PlanCardProps = {
|
||||
licenseInformation: ILicenseV3['information'];
|
||||
licenseLimits: LicenseLimits;
|
||||
};
|
||||
|
||||
const PlanCardPremium = ({ licenseInformation, licenseLimits }: PlanCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { isSelfHosted, isLoading } = useIsSelfHosted();
|
||||
const formatDate = useFormatDate();
|
||||
|
||||
const planName = useLicenseName();
|
||||
|
||||
const isAutoRenew = licenseInformation.autoRenew;
|
||||
const { visualExpiration } = licenseInformation;
|
||||
|
||||
return (
|
||||
<Card height='full'>
|
||||
<PlanCardHeader name={planName.data ?? ''} />
|
||||
<CardBody flexDirection='column'>
|
||||
{licenseLimits?.activeUsers.max === Infinity && (
|
||||
<Box display='flex' alignItems='center'>
|
||||
<Icon name='lightning' size='x24' marginInlineEnd={12} />
|
||||
{t('Unlimited_seats')}
|
||||
</Box>
|
||||
)}
|
||||
{visualExpiration && (
|
||||
<Box display='flex' alignItems='center'>
|
||||
<Icon name='calendar' size='x24' marginInlineEnd={12} />
|
||||
<span>
|
||||
{isAutoRenew ? (
|
||||
t('Renews_DATE', { date: formatDate(visualExpiration || '') })
|
||||
) : (
|
||||
<Trans i18nKey='Contact_sales_renew_date'>
|
||||
<ExternalLink to={CONTACT_SALES_LINK}>Contact sales</ExternalLink> to check plan renew date.
|
||||
</Trans>
|
||||
)}
|
||||
</span>
|
||||
</Box>
|
||||
)}
|
||||
{!isLoading ? (
|
||||
<Box display='flex' alignItems='center'>
|
||||
<Icon name='cloud-plus' size='x24' marginInlineEnd={12} /> {isSelfHosted ? t('Self_managed_hosting') : t('Cloud_hosting')}
|
||||
</Box>
|
||||
) : (
|
||||
<Skeleton />
|
||||
)}
|
||||
<PlanCardLicenseDetails />
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlanCardPremium;
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { ILicenseV3 } from '@rocket.chat/core-typings';
|
||||
import { Box, Card, CardBody, CardControls, CardRow, Tag } from '@rocket.chat/fuselage';
|
||||
import { ExternalLink, useLicenseName } from '@rocket.chat/ui-client';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import PlanCardHeader from './PlanCardHeader';
|
||||
import PlanCardLicenseDetails from './PlanCardLicenseDetails';
|
||||
import { DOWNGRADE_LINK, TRIAL_LINK } from '../../../utils/links';
|
||||
import UpgradeButton from '../../UpgradeButton';
|
||||
|
||||
type PlanCardProps = {
|
||||
licenseInformation: ILicenseV3['information'];
|
||||
};
|
||||
|
||||
const PlanCardTrial = ({ licenseInformation }: PlanCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const planName = useLicenseName();
|
||||
const isSalesAssisted = licenseInformation.grantedBy?.method !== 'self-service' || true;
|
||||
const { visualExpiration } = licenseInformation;
|
||||
|
||||
return (
|
||||
<Card height='full'>
|
||||
<PlanCardHeader name={planName.data ?? ''} />
|
||||
<CardBody flexDirection='column'>
|
||||
{visualExpiration && (
|
||||
<CardRow>
|
||||
<Box fontScale='p2b' marginInlineEnd={8}>
|
||||
{t('Trial_active')}
|
||||
</Box>
|
||||
<Tag>{t('n_days_left', { n: differenceInDays(new Date(visualExpiration), new Date()) })}</Tag>
|
||||
</CardRow>
|
||||
)}
|
||||
<CardRow>
|
||||
<span>
|
||||
{isSalesAssisted ? (
|
||||
<Trans i18nKey='Contact_sales_trial'>
|
||||
Contact sales to finish your purchase and avoid
|
||||
<ExternalLink to={DOWNGRADE_LINK}>downgrade consequences.</ExternalLink>
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans i18nKey='Finish_your_purchase_trial'>
|
||||
Finish your purchase to avoid <ExternalLink to={DOWNGRADE_LINK}>downgrade consequences.</ExternalLink>
|
||||
</Trans>
|
||||
)}
|
||||
</span>
|
||||
</CardRow>
|
||||
<CardRow>
|
||||
<Trans i18nKey='Why_has_a_trial_been_applied_to_this_workspace'>
|
||||
<ExternalLink to={TRIAL_LINK}>Why has a trial been applied to this workspace?</ExternalLink>
|
||||
</Trans>
|
||||
</CardRow>
|
||||
<PlanCardLicenseDetails />
|
||||
</CardBody>
|
||||
<CardControls>
|
||||
<UpgradeButton
|
||||
target='plan_card_trial'
|
||||
action={isSalesAssisted ? 'finish_purchase' : 'contact_sales'}
|
||||
primary
|
||||
marginBlockStart='auto'
|
||||
width='full'
|
||||
>
|
||||
{isSalesAssisted ? t('Finish_purchase') : t('Contact_sales')}
|
||||
</UpgradeButton>
|
||||
</CardControls>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlanCardTrial;
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Palette } from '@rocket.chat/fuselage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { CardProps } from '../FeatureUsageCard';
|
||||
import FeatureUsageCard from '../FeatureUsageCard';
|
||||
import FeatureUsageCardBody from '../FeatureUsageCardBody';
|
||||
import UpgradeButton from '../UpgradeButton';
|
||||
import UsagePieGraph from '../UsagePieGraph';
|
||||
|
||||
export type SeatsCardProps = {
|
||||
value: number;
|
||||
max: number;
|
||||
hideManageSubscription?: boolean;
|
||||
};
|
||||
|
||||
const SeatsCard = ({ value, max, hideManageSubscription }: SeatsCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const pieGraph = {
|
||||
used: value,
|
||||
total: max,
|
||||
};
|
||||
|
||||
const nearLimit = pieGraph && pieGraph.used / pieGraph.total >= 0.8;
|
||||
|
||||
const card: CardProps = {
|
||||
title: t('Seats'),
|
||||
infoText: t('Seats_InfoText'),
|
||||
...(!hideManageSubscription &&
|
||||
nearLimit && {
|
||||
upgradeButton: (
|
||||
<UpgradeButton target='seats-card' action='buy_more' small>
|
||||
{t('Buy_more')}
|
||||
</UpgradeButton>
|
||||
),
|
||||
}),
|
||||
};
|
||||
|
||||
const seatsLeft = pieGraph.total - pieGraph.used;
|
||||
const color = pieGraph.used / pieGraph.total >= 0.8 ? Palette.statusColor['status-font-on-danger'].toString() : undefined;
|
||||
|
||||
const message = seatsLeft > 0 ? t('Seats_Available', { seatsLeft }) : undefined;
|
||||
return (
|
||||
<FeatureUsageCard card={card}>
|
||||
<FeatureUsageCardBody>
|
||||
<UsagePieGraph label={message} used={pieGraph.used} total={pieGraph.total} color={color} />
|
||||
</FeatureUsageCardBody>
|
||||
</FeatureUsageCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default SeatsCard;
|
||||
Reference in New Issue
Block a user