Skip to content

Commit

Permalink
feat: New Private apps limitations (#33316)
Browse files Browse the repository at this point in the history
* feat: New empty state for upgrading private Apps

* chore: Change Marketplace info modal text (#33239)

* feat: New tooltips and color behavior for private apps bar (#33243)

* feat: New tooltips and behavior for private apps bar

* Create brown-pants-press.md

* feat: new modal on Private Apps install (#33275)

* feat: new modal on Private Apps install

* add more variations

* Create eleven-rockets-hug.md

* chore: change grandfathered modal text (#33291)

* chore: Use apps provider to check maxPrivateApps

* fix: adds minor fixes to UI and changes requested on review

* Update changeset

* Replace negative boolean

* Refactor `AppsUsageCard`

* Add unit test for `AppsUsageCard`

* Add unit test for `PrivateEmptyState`

* Add unit test for `EnabledAppsCount`

* Move tooltip logic away from `useAppsCountQuery`

---------

Co-authored-by: Lucas Pelegrino <[email protected]>
Co-authored-by: Tasso <[email protected]>
  • Loading branch information
3 people authored and ggazzo committed Oct 17, 2024
1 parent a44b4c7 commit 2e6dfee
Show file tree
Hide file tree
Showing 31 changed files with 582 additions and 129 deletions.
6 changes: 6 additions & 0 deletions .changeset/brown-pants-press.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@rocket.chat/meteor": major
"@rocket.chat/i18n": major
---

Changes some displays to reflect new rules for private apps and adds a new modal before uploading a private app
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Box, ProgressBar } from '@rocket.chat/fuselage';
import { useUniqueId } from '@rocket.chat/fuselage-hooks';
import type { ReactNode } from 'react';
import React from 'react';

Expand All @@ -10,6 +11,7 @@ const GenericResourceUsage = ({
threshold = 80,
variant = percentage < threshold ? 'success' : 'danger',
subTitle,
tooltip,
...props
}: {
title: string;
Expand All @@ -19,17 +21,40 @@ const GenericResourceUsage = ({
percentage: number;
threshold?: number;
variant?: 'warning' | 'danger' | 'success';
tooltip?: string;
}) => {
const labelId = useUniqueId();

return (
<Box w='x180' h='x40' mi={8} fontScale='c1' display='flex' flexDirection='column' justifyContent='space-around' {...props}>
<Box
title={tooltip}
w='x180'
h='x40'
mi={8}
fontScale='c1'
display='flex'
flexDirection='column'
justifyContent='space-around'
{...props}
>
<Box display='flex' justifyContent='space-between'>
<Box color='default'>{title}</Box>
<Box color='default' id={labelId}>
{title}
</Box>
{subTitle && <Box color='hint'>{subTitle}</Box>}
<Box color='hint'>
{value}/{max}
</Box>
</Box>
<ProgressBar percentage={percentage} variant={variant} />
<ProgressBar
percentage={percentage}
variant={variant}
role='progressbar'
aria-labelledby={labelId}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={percentage}
/>
</Box>
);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { Box, Skeleton } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';
import React from 'react';

const GenericResourceUsageSkeleton = ({ title, ...props }: { title?: string }) => {
type GenericResourceUsageSkeletonProps = {
title?: string;
} & ComponentProps<typeof Box>;

const GenericResourceUsageSkeleton = ({ title, ...props }: GenericResourceUsageSkeletonProps) => {
return (
<Box w='x180' h='x40' mi={8} fontScale='c1' display='flex' flexDirection='column' justifyContent='space-around' {...props}>
{title ? <Box color='default'>{title}</Box> : <Skeleton w='full' />}
Expand Down
2 changes: 2 additions & 0 deletions apps/meteor/client/contexts/AppsContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export type AppsContextValue = {
privateApps: AsyncState<{ apps: App[] }>;
reload: () => Promise<void>;
orchestrator?: IAppsOrchestrator;
privateAppsEnabled: boolean;
};

export const AppsContext = createContext<AppsContextValue>({
Expand All @@ -52,4 +53,5 @@ export const AppsContext = createContext<AppsContextValue>({
},
reload: () => Promise.resolve(),
orchestrator: undefined,
privateAppsEnabled: false,
});
3 changes: 2 additions & 1 deletion apps/meteor/client/providers/AppsProvider/AppsProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ const AppsProvider = ({ children }: AppsProviderProps) => {

const queryClient = useQueryClient();

const { isLoading: isLicenseInformationLoading, data: { license } = {} } = useLicense({ loadValues: true });
const { isLoading: isLicenseInformationLoading, data: { license, limits } = {} } = useLicense({ loadValues: true });
const isEnterprise = isLicenseInformationLoading ? undefined : !!license;

const [marketplaceError, setMarketplaceError] = useState<Error>();
Expand Down Expand Up @@ -132,6 +132,7 @@ const AppsProvider = ({ children }: AppsProviderProps) => {
await Promise.all([queryClient.invalidateQueries(['marketplace'])]);
},
orchestrator: AppClientOrchestratorInstance,
privateAppsEnabled: (limits?.privateApps?.max ?? 0) > 0,
}}
/>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ type FeatureUsageCardProps = {

export type CardProps = {
title: string;
infoText?: string;
infoText?: ReactNode;
upgradeButton?: ReactNode;
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { IconButton } from '@rocket.chat/fuselage';
import { useSetModal } from '@rocket.chat/ui-contexts';
import type { ReactElement } from 'react';
import type { ReactElement, ReactNode } from 'react';
import React, { memo } from 'react';
import { useTranslation } from 'react-i18next';

import GenericModal from '../../../../components/GenericModal';

export type InfoTextIconModalProps = {
title: string;
infoText: string;
infoText: ReactNode;
};

const InfoTextIconModal = ({ title, infoText }: InfoTextIconModalProps): ReactElement => {
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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 React from 'react';

import AppsUsageCard from './AppsUsageCard';

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>.',
Apps_InfoText:
'Community allows up to {{privateAppsMaxCount}} private apps and {{marketplaceAppsMaxCount}} marketplace apps to be enabled',
});

it('should render a skeleton if no data', () => {
render(<AppsUsageCard />, { wrapper: appRoot.build(), legacyRoot: true });

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(),
legacyRoot: true,
});

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.getByText('Community workspaces can enable up to 5 marketplace apps. Private apps can only be enabled in premium plans.'),
).toBeInTheDocument();
});

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(),
legacyRoot: true,
});

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.getByText('Community workspaces can enable up to 5 marketplace apps. Private apps can only be enabled in premium plans.'),
).toBeInTheDocument();
});

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(),
legacyRoot: true,
});

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.getByText('Community allows up to 0 private apps and 5 marketplace apps to be enabled')).toBeInTheDocument();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { Box, Skeleton } from '@rocket.chat/fuselage';
import type { ReactElement } from 'react';
import React from 'react';
import { Trans, useTranslation } from 'react-i18next';

import type { CardProps } from '../../FeatureUsageCard';
import FeatureUsageCard from '../../FeatureUsageCard';
import UpgradeButton from '../../UpgradeButton';
import AppsUsageCardSection from './AppsUsageCardSection';

// Magic numbers
const marketplaceAppsMaxCountFallback = 5;
const privateAppsMaxCountFallback = 0;
const defaultWarningThreshold = 80;

type AppsUsageCardProps = {
privateAppsLimit?: { value?: number; max: number };
marketplaceAppsLimit?: { value?: number; max: number };
};

const AppsUsageCard = ({ privateAppsLimit, marketplaceAppsLimit }: AppsUsageCardProps): ReactElement => {
const { t } = useTranslation();

if (!privateAppsLimit || !marketplaceAppsLimit) {
// FIXME: not accessible enough
return (
<FeatureUsageCard card={{ title: t('Apps') }}>
<Skeleton variant='rect' width='x112' height='x112' role='presentation' />
</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:
privateAppsCount > 0 ? (
<Trans i18nKey='Apps_InfoText_limited' tOptions={{ marketplaceAppsMaxCount }}>
Community workspaces can enable up to {{ marketplaceAppsMaxCount }} marketplace apps. Private apps can only be enabled in{' '}
<Box is='a' href='https://www.rocket.chat/pricing' target='_blank' color='info'>
premium plans
</Box>
.
</Trans>
) : (
t('Apps_InfoText', { privateAppsMaxCount, marketplaceAppsMaxCount })
),
...(marketplaceAppsAboveWarning && {
upgradeButton: (
<UpgradeButton target='app-usage-card' action='upgrade' small>
{t('Upgrade')}
</UpgradeButton>
),
}),
};

return (
<FeatureUsageCard card={card}>
<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}
/>
</FeatureUsageCard>
);
};

export default AppsUsageCard;
Loading

0 comments on commit 2e6dfee

Please sign in to comment.