diff --git a/.changeset/brown-pants-press.md b/.changeset/brown-pants-press.md new file mode 100644 index 0000000000000..d65eff3db6891 --- /dev/null +++ b/.changeset/brown-pants-press.md @@ -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 diff --git a/apps/meteor/client/components/GenericResourceUsage/GenericResourceUsage.tsx b/apps/meteor/client/components/GenericResourceUsage/GenericResourceUsage.tsx index c226139b94893..80c40183655b1 100644 --- a/apps/meteor/client/components/GenericResourceUsage/GenericResourceUsage.tsx +++ b/apps/meteor/client/components/GenericResourceUsage/GenericResourceUsage.tsx @@ -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'; @@ -10,6 +11,7 @@ const GenericResourceUsage = ({ threshold = 80, variant = percentage < threshold ? 'success' : 'danger', subTitle, + tooltip, ...props }: { title: string; @@ -19,17 +21,40 @@ const GenericResourceUsage = ({ percentage: number; threshold?: number; variant?: 'warning' | 'danger' | 'success'; + tooltip?: string; }) => { + const labelId = useUniqueId(); + return ( - + - {title} + + {title} + {subTitle && {subTitle}} {value}/{max} - + ); }; diff --git a/apps/meteor/client/components/GenericResourceUsage/GenericResourceUsageSkeleton.tsx b/apps/meteor/client/components/GenericResourceUsage/GenericResourceUsageSkeleton.tsx index 2820379e7469c..8c82a66486741 100644 --- a/apps/meteor/client/components/GenericResourceUsage/GenericResourceUsageSkeleton.tsx +++ b/apps/meteor/client/components/GenericResourceUsage/GenericResourceUsageSkeleton.tsx @@ -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; + +const GenericResourceUsageSkeleton = ({ title, ...props }: GenericResourceUsageSkeletonProps) => { return ( {title ? {title} : } diff --git a/apps/meteor/client/contexts/AppsContext.tsx b/apps/meteor/client/contexts/AppsContext.tsx index 2be8e74c2d672..ee3d2e46a45a0 100644 --- a/apps/meteor/client/contexts/AppsContext.tsx +++ b/apps/meteor/client/contexts/AppsContext.tsx @@ -32,6 +32,7 @@ export type AppsContextValue = { privateApps: Omit, 'error'>; reload: () => Promise; orchestrator?: IAppsOrchestrator; + privateAppsEnabled: boolean; }; export const AppsContext = createContext({ @@ -49,4 +50,5 @@ export const AppsContext = createContext({ }, reload: () => Promise.resolve(), orchestrator: undefined, + privateAppsEnabled: false, }); diff --git a/apps/meteor/client/providers/AppsProvider/AppsProvider.tsx b/apps/meteor/client/providers/AppsProvider/AppsProvider.tsx index cf1d4d671d94b..b2145a07fb7ad 100644 --- a/apps/meteor/client/providers/AppsProvider/AppsProvider.tsx +++ b/apps/meteor/client/providers/AppsProvider/AppsProvider.tsx @@ -6,8 +6,7 @@ import React, { useEffect } from 'react'; import { AppClientOrchestratorInstance } from '../../apps/orchestrator'; import { AppsContext } from '../../contexts/AppsContext'; -import { useIsEnterprise } from '../../hooks/useIsEnterprise'; -import { useInvalidateLicense } from '../../hooks/useLicense'; +import { useInvalidateLicense, useLicense } from '../../hooks/useLicense'; import type { AsyncState } from '../../lib/asyncState'; import { AsyncStatePhase } from '../../lib/asyncState'; import { useInvalidateAppsCountQueryCallback } from '../../views/marketplace/hooks/useAppsCountQuery'; @@ -36,8 +35,8 @@ const AppsProvider = ({ children }: AppsProviderProps) => { const queryClient = useQueryClient(); - const { data } = useIsEnterprise(); - const isEnterprise = !!data?.isEnterprise; + const { isLoading: isLicenseInformationLoading, data: { license, limits } = {} } = useLicense({ loadValues: true }); + const isEnterprise = isLicenseInformationLoading ? undefined : !!license; const invalidateAppsCountQuery = useInvalidateAppsCountQueryCallback(); const invalidateLicenseQuery = useInvalidateLicense(); @@ -95,25 +94,29 @@ const AppsProvider = ({ children }: AppsProviderProps) => { }, ); - const store = useQuery(['marketplace', 'apps-stored', instance.data, marketplace.data], () => storeQueryFunction(marketplace, instance), { - enabled: marketplace.isFetched && instance.isFetched, - keepPreviousData: true, - }); + const { isLoading: isMarketplaceDataLoading, data: marketplaceData } = useQuery( + ['marketplace', 'apps-stored', instance.data, marketplace.data], + () => storeQueryFunction(marketplace, instance), + { + enabled: marketplace.isFetched && instance.isFetched, + keepPreviousData: true, + }, + ); - const [marketplaceAppsData, installedAppsData, privateAppsData] = store.data || []; - const { isLoading } = store; + const [marketplaceAppsData, installedAppsData, privateAppsData] = marketplaceData || []; return ( { await Promise.all([queryClient.invalidateQueries(['marketplace'])]); }, orchestrator: AppClientOrchestratorInstance, + privateAppsEnabled: (limits?.privateApps?.max ?? 0) > 0, }} /> ); diff --git a/apps/meteor/client/views/admin/subscription/components/FeatureUsageCard.tsx b/apps/meteor/client/views/admin/subscription/components/FeatureUsageCard.tsx index 3e736062bd7d4..34a9746b1b530 100644 --- a/apps/meteor/client/views/admin/subscription/components/FeatureUsageCard.tsx +++ b/apps/meteor/client/views/admin/subscription/components/FeatureUsageCard.tsx @@ -11,7 +11,7 @@ type FeatureUsageCardProps = { export type CardProps = { title: string; - infoText?: string; + infoText?: ReactNode; upgradeButton?: ReactNode; }; diff --git a/apps/meteor/client/views/admin/subscription/components/InfoTextIconModal.tsx b/apps/meteor/client/views/admin/subscription/components/InfoTextIconModal.tsx index 9316949dcb66c..1f6fb6db19094 100644 --- a/apps/meteor/client/views/admin/subscription/components/InfoTextIconModal.tsx +++ b/apps/meteor/client/views/admin/subscription/components/InfoTextIconModal.tsx @@ -1,6 +1,6 @@ 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'; @@ -8,7 +8,7 @@ import GenericModal from '../../../../components/GenericModal'; export type InfoTextIconModalProps = { title: string; - infoText: string; + infoText: ReactNode; }; const InfoTextIconModal = ({ title, infoText }: InfoTextIconModalProps): ReactElement => { diff --git a/apps/meteor/client/views/admin/subscription/components/cards/AppsUsageCard.tsx b/apps/meteor/client/views/admin/subscription/components/cards/AppsUsageCard.tsx deleted file mode 100644 index dbd402ef2c7f2..0000000000000 --- a/apps/meteor/client/views/admin/subscription/components/cards/AppsUsageCard.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { Box, ProgressBar, Skeleton } from '@rocket.chat/fuselage'; -import type { ReactElement } from 'react'; -import React from 'react'; -import { useTranslation } from 'react-i18next'; - -import type { CardProps } from '../FeatureUsageCard'; -import FeatureUsageCard from '../FeatureUsageCard'; -import UpgradeButton from '../UpgradeButton'; - -type AppsUsageCardProps = { - privateAppsLimit?: { value?: number; max: number }; - marketplaceAppsLimit?: { value?: number; max: number }; -}; - -const AppsUsageCard = ({ privateAppsLimit, marketplaceAppsLimit }: AppsUsageCardProps): ReactElement => { - const { t } = useTranslation(); - - const marketplaceAppsEnabled = marketplaceAppsLimit?.value || 0; - const marketplaceAppsLimitCount = marketplaceAppsLimit?.max || 5; - const marketplaceAppsPercentage = Math.round((marketplaceAppsEnabled / marketplaceAppsLimitCount) * 100); - - const privateAppsEnabled = privateAppsLimit?.value || 0; - const privateAppsLimitCount = privateAppsLimit?.max || 3; - const privateAppsPercentage = Math.round((privateAppsEnabled / privateAppsLimitCount) * 100); - - const card: CardProps = { - title: t('Apps'), - infoText: t('Apps_InfoText'), - ...((marketplaceAppsPercentage || 0) >= 80 && { - upgradeButton: ( - - {t('Upgrade')} - - ), - }), - }; - - if (!privateAppsLimit || !marketplaceAppsLimit) { - return ( - - - - ); - } - - return ( - - - -
{t('Marketplace_apps')}
- = 80 ? 'font-danger' : 'status-font-on-success'}> - {marketplaceAppsEnabled} / {marketplaceAppsLimitCount} - -
- - = 80 ? 'danger' : 'success'} /> -
- - -
{t('Private_apps')}
- = 80 ? 'font-danger' : 'status-font-on-success'}> - {privateAppsEnabled} / {privateAppsLimitCount} - -
- - = 80 ? 'danger' : 'success'} /> -
-
- ); -}; -export default AppsUsageCard; diff --git a/apps/meteor/client/views/admin/subscription/components/cards/AppsUsageCard/AppsUsageCard.spec.tsx b/apps/meteor/client/views/admin/subscription/components/cards/AppsUsageCard/AppsUsageCard.spec.tsx new file mode 100644 index 0000000000000..b5d44e3bf942c --- /dev/null +++ b/apps/meteor/client/views/admin/subscription/components/cards/AppsUsageCard/AppsUsageCard.spec.tsx @@ -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.', + 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(, { 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(, { + 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(, { + 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(, { + 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(); +}); diff --git a/apps/meteor/client/views/admin/subscription/components/cards/AppsUsageCard/AppsUsageCard.tsx b/apps/meteor/client/views/admin/subscription/components/cards/AppsUsageCard/AppsUsageCard.tsx new file mode 100644 index 0000000000000..aa8e91c0f8575 --- /dev/null +++ b/apps/meteor/client/views/admin/subscription/components/cards/AppsUsageCard/AppsUsageCard.tsx @@ -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 ( + + + + ); + } + + 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 ? ( + + Community workspaces can enable up to {{ marketplaceAppsMaxCount }} marketplace apps. Private apps can only be enabled in{' '} + + premium plans + + . + + ) : ( + t('Apps_InfoText', { privateAppsMaxCount, marketplaceAppsMaxCount }) + ), + ...(marketplaceAppsAboveWarning && { + upgradeButton: ( + + {t('Upgrade')} + + ), + }), + }; + + return ( + + + + + + ); +}; + +export default AppsUsageCard; diff --git a/apps/meteor/client/views/admin/subscription/components/cards/AppsUsageCard/AppsUsageCardSection.tsx b/apps/meteor/client/views/admin/subscription/components/cards/AppsUsageCard/AppsUsageCardSection.tsx new file mode 100644 index 0000000000000..ac17957eda01c --- /dev/null +++ b/apps/meteor/client/views/admin/subscription/components/cards/AppsUsageCard/AppsUsageCardSection.tsx @@ -0,0 +1,42 @@ +import { Box, ProgressBar } from '@rocket.chat/fuselage'; +import { useUniqueId } from '@rocket.chat/fuselage-hooks'; +import type { ReactNode } from 'react'; +import React from 'react'; + +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 = useUniqueId(); + + return ( + + +
{title}
+ + + {appsCount} / {appsMaxCount} + +
+ + +
+ ); +}; + +export default AppsUsageCardSection; diff --git a/apps/meteor/client/views/admin/subscription/components/cards/AppsUsageCard/index.ts b/apps/meteor/client/views/admin/subscription/components/cards/AppsUsageCard/index.ts new file mode 100644 index 0000000000000..2a076ecba05e1 --- /dev/null +++ b/apps/meteor/client/views/admin/subscription/components/cards/AppsUsageCard/index.ts @@ -0,0 +1 @@ +export { default } from './AppsUsageCard'; diff --git a/apps/meteor/client/views/marketplace/AppsPage/PrivateEmptyState.spec.tsx b/apps/meteor/client/views/marketplace/AppsPage/PrivateEmptyState.spec.tsx new file mode 100644 index 0000000000000..c2d309d6102fb --- /dev/null +++ b/apps/meteor/client/views/marketplace/AppsPage/PrivateEmptyState.spec.tsx @@ -0,0 +1,63 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { AppsContext } from '../../../contexts/AppsContext'; +import { asyncState } from '../../../lib/asyncState'; +import PrivateEmptyState from './PrivateEmptyState'; + +describe('with private apps enabled', () => { + const appRoot = mockAppRoot() + .withTranslations('en', 'core', { + Private_apps_upgrade_empty_state_title: 'Upgrade to unlock private apps', + No_private_apps_installed: 'No private apps installed', + }) + .wrap((children) => ( + Promise.resolve(), + orchestrator: undefined, + privateAppsEnabled: true, + }} + > + {children} + + )); + + it('should offer to upgrade to unlock private apps', () => { + render(, { wrapper: appRoot.build(), legacyRoot: true }); + + expect(screen.getByRole('heading', { name: 'No private apps installed' })).toBeInTheDocument(); + }); +}); + +describe('without private apps enabled', () => { + const appRoot = mockAppRoot() + .withTranslations('en', 'core', { + Private_apps_upgrade_empty_state_title: 'Upgrade to unlock private apps', + No_private_apps_installed: 'No private apps installed', + }) + .wrap((children) => ( + Promise.resolve(), + orchestrator: undefined, + privateAppsEnabled: false, + }} + > + {children} + + )); + + it('should offer to upgrade to unlock private apps', () => { + render(, { wrapper: appRoot.build(), legacyRoot: true }); + + expect(screen.getByRole('heading', { name: 'Upgrade to unlock private apps' })).toBeInTheDocument(); + }); +}); diff --git a/apps/meteor/client/views/marketplace/AppsPage/PrivateEmptyState.tsx b/apps/meteor/client/views/marketplace/AppsPage/PrivateEmptyState.tsx index b7fec778401ab..aaa2be18ee3f8 100644 --- a/apps/meteor/client/views/marketplace/AppsPage/PrivateEmptyState.tsx +++ b/apps/meteor/client/views/marketplace/AppsPage/PrivateEmptyState.tsx @@ -1,19 +1,14 @@ -import { States, StatesIcon, StatesTitle, StatesSubtitle, Box } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; +import { Box } from '@rocket.chat/fuselage'; import React from 'react'; +import { usePrivateAppsEnabled } from '../hooks/usePrivateAppsEnabled'; +import PrivateEmptyStateDefault from './PrivateEmptyStateDefault'; +import PrivateEmptyStateUpgrade from './PrivateEmptyStateUpgrade'; + const PrivateEmptyState = () => { - const t = useTranslation(); + const privateAppsEnabled = usePrivateAppsEnabled(); - return ( - - - - {t('No_private_apps_installed')} - {t('Private_apps_are_side-loaded')} - - - ); + return {privateAppsEnabled ? : }; }; export default PrivateEmptyState; diff --git a/apps/meteor/client/views/marketplace/AppsPage/PrivateEmptyStateDefault.tsx b/apps/meteor/client/views/marketplace/AppsPage/PrivateEmptyStateDefault.tsx new file mode 100644 index 0000000000000..0c8bb909de570 --- /dev/null +++ b/apps/meteor/client/views/marketplace/AppsPage/PrivateEmptyStateDefault.tsx @@ -0,0 +1,17 @@ +import { States, StatesIcon, StatesTitle, StatesSubtitle } from '@rocket.chat/fuselage'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +const PrivateEmptyStateDefault = () => { + const { t } = useTranslation(); + + return ( + + + {t('No_private_apps_installed')} + {t('Private_apps_upgrade_empty_state_description')} + + ); +}; + +export default PrivateEmptyStateDefault; diff --git a/apps/meteor/client/views/marketplace/AppsPage/PrivateEmptyStateUpgrade.tsx b/apps/meteor/client/views/marketplace/AppsPage/PrivateEmptyStateUpgrade.tsx new file mode 100644 index 0000000000000..e3ae7d0e31974 --- /dev/null +++ b/apps/meteor/client/views/marketplace/AppsPage/PrivateEmptyStateUpgrade.tsx @@ -0,0 +1,28 @@ +import { States, StatesIcon, StatesTitle, StatesSubtitle, StatesActions } from '@rocket.chat/fuselage'; +import { usePermission } from '@rocket.chat/ui-contexts'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import UpgradeButton from '../../admin/subscription/components/UpgradeButton'; + +const PrivateEmptyStateUpgrade = () => { + const { t } = useTranslation(); + const isAdmin = usePermission('manage-apps'); + + return ( + + + {t('Private_apps_upgrade_empty_state_title')} + {t('Private_apps_upgrade_empty_state_description')} + {isAdmin && ( + + + {t('Upgrade')} + + + )} + + ); +}; + +export default PrivateEmptyStateUpgrade; diff --git a/apps/meteor/client/views/marketplace/components/EnabledAppsCount.spec.tsx b/apps/meteor/client/views/marketplace/components/EnabledAppsCount.spec.tsx new file mode 100644 index 0000000000000..584f6e10fb8ac --- /dev/null +++ b/apps/meteor/client/views/marketplace/components/EnabledAppsCount.spec.tsx @@ -0,0 +1,62 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import EnabledAppsCount from './EnabledAppsCount'; + +describe('in private context', () => { + const context = 'private'; + + it('should work under the limit', async () => { + render(, { + wrapper: mockAppRoot() + .withTranslations('en', 'core', { + Private_Apps_Count_Enabled_one: '{{count}} private app enabled', + Private_Apps_Count_Enabled_other: '{{count}} private apps enabled', + }) + .build(), + legacyRoot: true, + }); + + expect(screen.getByText('1 private app enabled')).toBeInTheDocument(); + expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuemin', '0'); + expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '50'); + expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuemax', '100'); + }); + + it('should work with private apps disabled', async () => { + render(, { + wrapper: mockAppRoot() + .withTranslations('en', 'core', { + Private_Apps_Count_Enabled_one: '{{count}} private app enabled', + Private_Apps_Count_Enabled_other: '{{count}} private apps enabled', + }) + .build(), + legacyRoot: true, + }); + + expect(screen.getByText('0 private apps enabled')).toBeInTheDocument(); + expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuemin', '0'); + expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '100'); + expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuemax', '100'); + }); +}); + +describe.each(['explore', 'installed', 'premium', 'requested'] as const)('in %s context', (context) => { + it('should work', async () => { + render(, { + wrapper: mockAppRoot() + .withTranslations('en', 'core', { + Apps_Count_Enabled_one: '{{count}} app enabled', + Apps_Count_Enabled_other: '{{count}} apps enabled', + }) + .build(), + legacyRoot: true, + }); + + expect(screen.getByText('1 app enabled')).toBeInTheDocument(); + expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuemin', '0'); + expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '50'); + expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuemax', '100'); + }); +}); diff --git a/apps/meteor/client/views/marketplace/components/EnabledAppsCount.tsx b/apps/meteor/client/views/marketplace/components/EnabledAppsCount.tsx index 9578d49961c8f..3aeac42fe6378 100644 --- a/apps/meteor/client/views/marketplace/components/EnabledAppsCount.tsx +++ b/apps/meteor/client/views/marketplace/components/EnabledAppsCount.tsx @@ -1,24 +1,36 @@ import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; -import React from 'react'; +import React, { useMemo } from 'react'; import { GenericResourceUsage } from '../../../components/GenericResourceUsage'; const EnabledAppsCount = ({ - variant, - percentage, limit, enabled, context, + tooltip, }: { - variant: 'warning' | 'danger' | 'success'; - percentage: number; limit: number; enabled: number; context: 'private' | 'explore' | 'installed' | 'premium' | 'requested'; + tooltip?: string; }): ReactElement | null => { const t = useTranslation(); + const variant = useMemo(() => { + if (enabled + 1 === limit) { + return 'warning'; + } + + if (limit === 0 || enabled >= limit) { + return 'danger'; + } + + return 'success'; + }, [enabled, limit]); + + const percentage = limit === 0 ? 100 : Math.round((enabled * 100) / limit); + return ( ); }; diff --git a/apps/meteor/client/views/marketplace/components/MarketplaceHeader.tsx b/apps/meteor/client/views/marketplace/components/MarketplaceHeader.tsx index dfc3033e9812f..07e024ef93473 100644 --- a/apps/meteor/client/views/marketplace/components/MarketplaceHeader.tsx +++ b/apps/meteor/client/views/marketplace/components/MarketplaceHeader.tsx @@ -1,36 +1,62 @@ -import { Button, ButtonGroup } from '@rocket.chat/fuselage'; -import { usePermission, useRoute, useRouteParameter, useSetModal, useTranslation } from '@rocket.chat/ui-contexts'; +import { Button, ButtonGroup, Margins } from '@rocket.chat/fuselage'; +import { usePermission, useRoute, useRouteParameter, useSetModal } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; -import React, { useCallback } from 'react'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; import { GenericResourceUsageSkeleton } from '../../../components/GenericResourceUsage'; import { PageHeader } from '../../../components/Page'; +import UpgradeButton from '../../admin/subscription/components/UpgradeButton'; import UnlimitedAppsUpsellModal from '../UnlimitedAppsUpsellModal'; import { useAppsCountQuery } from '../hooks/useAppsCountQuery'; +import { usePrivateAppsEnabled } from '../hooks/usePrivateAppsEnabled'; import EnabledAppsCount from './EnabledAppsCount'; +import PrivateAppInstallModal from './PrivateAppInstallModal/PrivateAppInstallModal'; const MarketplaceHeader = ({ title }: { title: string }): ReactElement | null => { - const t = useTranslation(); + const { t } = useTranslation(); const isAdmin = usePermission('manage-apps'); const context = (useRouteParameter('context') || 'explore') as 'private' | 'explore' | 'installed' | 'premium' | 'requested'; const route = useRoute('marketplace'); const setModal = useSetModal(); - const result = useAppsCountQuery(context); + const { isLoading, isError, isSuccess, data } = useAppsCountQuery(context); - const handleUploadButtonClick = useCallback((): void => { + const privateAppsEnabled = usePrivateAppsEnabled(); + + const handleProceed = (): void => { + setModal(null); route.push({ context, page: 'install' }); - }, [context, route]); + }; + + const handleClickPrivate = () => { + if (!privateAppsEnabled) { + setModal( setModal(null)} onProceed={handleProceed} />); + return; + } - if (result.isError) { + route.push({ context, page: 'install' }); + }; + + if (isError) { return null; } return ( + {isLoading && } + + {isSuccess && !data.hasUnlimitedApps && ( + + + + )} + - {result.isLoading && } - {result.isSuccess && !result.data.hasUnlimitedApps && } - {isAdmin && result.isSuccess && !result.data.hasUnlimitedApps && ( + {isAdmin && isSuccess && !data.hasUnlimitedApps && context !== 'private' && ( )} - {isAdmin && context === 'private' && } + + {isAdmin && context === 'private' && } + + {isAdmin && isSuccess && !privateAppsEnabled && context === 'private' && ( + + {t('Upgrade')} + + )} ); diff --git a/apps/meteor/client/views/marketplace/components/PrivateAppInstallModal/PrivateAppInstallModal.tsx b/apps/meteor/client/views/marketplace/components/PrivateAppInstallModal/PrivateAppInstallModal.tsx new file mode 100644 index 0000000000000..5205ccbfc6a2a --- /dev/null +++ b/apps/meteor/client/views/marketplace/components/PrivateAppInstallModal/PrivateAppInstallModal.tsx @@ -0,0 +1,56 @@ +import { Box, Button, Modal } from '@rocket.chat/fuselage'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useExternalLink } from '../../../../hooks/useExternalLink'; +import { useCheckoutUrl } from '../../../admin/subscription/hooks/useCheckoutUrl'; +import { PRICING_LINK } from '../../../admin/subscription/utils/links'; + +type PrivateAppInstallModalProps = { + onClose: () => void; + onProceed: () => void; +}; + +const PrivateAppInstallModal = ({ onClose, onProceed }: PrivateAppInstallModalProps) => { + const { t } = useTranslation(); + + const openExternalLink = useExternalLink(); + const manageSubscriptionUrl = useCheckoutUrl()({ target: 'private-apps-page', action: 'upgrade' }); + + const goToManageSubscriptionPage = (): void => { + openExternalLink(manageSubscriptionUrl); + onClose(); + }; + + return ( + + + + {t('Private_app_install_modal_title')} + + + + + + {t('Private_app_install_modal_content')} + {t('Upgrade_subscription_to_enable_private_apps')} + + + + + + {t('Compare_plans')} + + + + + + + + + ); +}; + +export default PrivateAppInstallModal; diff --git a/apps/meteor/client/views/marketplace/components/UninstallGrandfatheredAppModal/UninstallGrandfatheredAppModal.tsx b/apps/meteor/client/views/marketplace/components/UninstallGrandfatheredAppModal/UninstallGrandfatheredAppModal.tsx index ce87f0d4673a6..13d71cc2108b1 100644 --- a/apps/meteor/client/views/marketplace/components/UninstallGrandfatheredAppModal/UninstallGrandfatheredAppModal.tsx +++ b/apps/meteor/client/views/marketplace/components/UninstallGrandfatheredAppModal/UninstallGrandfatheredAppModal.tsx @@ -4,6 +4,7 @@ import React from 'react'; import MarkdownText from '../../../../components/MarkdownText'; import type { MarketplaceRouteContext } from '../../hooks/useAppsCountQuery'; +import { usePrivateAppsEnabled } from '../../hooks/usePrivateAppsEnabled'; type UninstallGrandfatheredAppModalProps = { context: MarketplaceRouteContext; @@ -15,6 +16,12 @@ type UninstallGrandfatheredAppModalProps = { const UninstallGrandfatheredAppModal = ({ context, limit, appName, handleUninstall, handleClose }: UninstallGrandfatheredAppModalProps) => { const t = useTranslation(); + const privateAppsEnabled = usePrivateAppsEnabled(); + + const modalContent = + context === 'private' && !privateAppsEnabled + ? t('App_will_lose_grandfathered_status_private') + : t('App_will_lose_grandfathered_status', { limit }); return ( @@ -25,7 +32,7 @@ const UninstallGrandfatheredAppModal = ({ context, limit, appName, handleUninsta - + diff --git a/apps/meteor/client/views/marketplace/hooks/useAppsCountQuery.ts b/apps/meteor/client/views/marketplace/hooks/useAppsCountQuery.ts index b23c19a2df403..712bcf9da9411 100644 --- a/apps/meteor/client/views/marketplace/hooks/useAppsCountQuery.ts +++ b/apps/meteor/client/views/marketplace/hooks/useAppsCountQuery.ts @@ -2,15 +2,6 @@ import { useEndpoint } from '@rocket.chat/ui-contexts'; import { useQueryClient, useQuery } from '@tanstack/react-query'; import { useCallback } from 'react'; -type Variant = 'success' | 'warning' | 'danger'; - -const getProgressBarValues = (numberOfEnabledApps: number, enabledAppsLimit: number): { variant: Variant; percentage: number } => ({ - variant: 'success', - ...(numberOfEnabledApps + 1 === enabledAppsLimit && { variant: 'warning' }), - ...(numberOfEnabledApps >= enabledAppsLimit && { variant: 'danger' }), - percentage: Math.round((numberOfEnabledApps / enabledAppsLimit) * 100), -}); - export type MarketplaceRouteContext = 'private' | 'explore' | 'installed' | 'premium' | 'requested' | 'details'; export function isMarketplaceRouteContext(context: string): context is MarketplaceRouteContext { @@ -28,11 +19,12 @@ export const useAppsCountQuery = (context: MarketplaceRouteContext) => { const numberOfEnabledApps = context === 'private' ? data.totalPrivateEnabled : data.totalMarketplaceEnabled; const enabledAppsLimit = context === 'private' ? data.maxPrivateApps : data.maxMarketplaceApps; const hasUnlimitedApps = enabledAppsLimit === -1; + return { hasUnlimitedApps, enabled: numberOfEnabledApps, limit: enabledAppsLimit, - ...getProgressBarValues(numberOfEnabledApps, enabledAppsLimit), + // tooltip, }; }, { staleTime: 10_000 }, diff --git a/apps/meteor/client/views/marketplace/hooks/usePrivateAppsEnabled.ts b/apps/meteor/client/views/marketplace/hooks/usePrivateAppsEnabled.ts new file mode 100644 index 0000000000000..49050960b4df9 --- /dev/null +++ b/apps/meteor/client/views/marketplace/hooks/usePrivateAppsEnabled.ts @@ -0,0 +1,9 @@ +import { useContext } from 'react'; + +import { AppsContext } from '../../../contexts/AppsContext'; + +export const usePrivateAppsEnabled = () => { + const { privateAppsEnabled } = useContext(AppsContext); + + return privateAppsEnabled; +}; diff --git a/apps/meteor/tests/mocks/client/marketplace.tsx b/apps/meteor/tests/mocks/client/marketplace.tsx index f0147509cb12d..3cad0aad502b4 100644 --- a/apps/meteor/tests/mocks/client/marketplace.tsx +++ b/apps/meteor/tests/mocks/client/marketplace.tsx @@ -62,6 +62,7 @@ export const mockedAppsContext = (children: ReactNode) => ( }, reload: () => Promise.resolve(), orchestrator: mockAppsOrchestrator(), + privateAppsEnabled: false, }} > {children} diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index f7eb8d638420b..eae11406ed5e1 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -561,6 +561,9 @@ "Apps_Count_Enabled_other": "{{count}} apps enabled", "Private_Apps_Count_Enabled_one": "{{count}} private app enabled", "Private_Apps_Count_Enabled_other": "{{count}} private apps enabled", + "Private_apps_premium_message": "Private apps can only be enabled in premium plans", + "Private_apps_upgrade_empty_state_title": "Upgrade to unlock private apps", + "Private_apps_upgrade_empty_state_description": "Tailor Rocket.Chat according to your needs with private apps.", "Apps_Count_Enabled_tooltip": "Community workspaces can enable up to {{number}} {{context}} apps", "Apps_disabled_when_Premium_trial_ended": "Apps disabled when Premium plan trial ended", "Apps_disabled_when_Premium_trial_ended_description": "Workspaces on Community can have up to 5 marketplace apps and 3 private apps enabled. Ask your workspace admin to reenable apps.", @@ -2575,7 +2578,7 @@ "GoogleTagManager_id": "Google Tag Manager Id", "Got_it": "Got it", "Government": "Government", - "Grandfathered_app": "Grandfathered app - counts towards app limit but limit is not applied to this app", + "Grandfathered_app": "Grandfathered app – this app is exempt from the app limit policy", "Graphql_CORS": "GraphQL CORS", "Graphql_Enabled": "GraphQL Enabled", "Graphql_Subscription_Port": "GraphQL Subscription Port", @@ -4313,6 +4316,8 @@ "private": "private", "Private_channels": "Private channels", "Private_Apps": "Private Apps", + "Private_app_install_modal_title": "Upload disabled private app", + "Private_app_install_modal_content": "Community workspaces cannot enable private apps. You can upload this app but it will be disabled.", "Private_Channel": "Private Channel", "Private_Channels": "Private channels", "Private_Chats": "Private Chats", @@ -6454,7 +6459,8 @@ "cloud.RegisterWorkspace_Setup_Terms_Privacy": "I agree with <1>Terms and Conditions and <3>Privacy Policy", "Larger_amounts_of_active_connections": "For larger amounts of active connections you can consider our <1>multiple instance solutions.", "Uninstall_grandfathered_app": "Uninstall {{appName}}?", - "App_will_lose_grandfathered_status": "**This {{context}} app will lose its grandfathered status.** \n \nWorkspaces on Community can have up to {{limit}} {{context}} apps enabled. Grandfathered apps count towards the limit but the limit is not applied to them.", + "App_will_lose_grandfathered_status": "**This app will lose its grandfathered status.** \n \nWorkspaces on Community can have up to {{limit}} apps enabled. Grandfathered apps count towards the limit but the limit is not applied to them.", + "App_will_lose_grandfathered_status_private": "**This app will lose its grandfathered status.** \n \nBecause Community workspaces cannot enable private apps, this workspace will require a premium plan in order to enable this app again in future.", "All_rooms": "All rooms", "All_visible": "All visible", "all": "all", @@ -6493,6 +6499,7 @@ "ActiveSessions_available": "sessions available", "Monthly_active_contacts": "Monthly active contacts", "Upgrade": "Upgrade", + "Upgrade_subscription_to_enable_private_apps": "Upgrade subscription to enable private apps.", "Seats": "Seats", "Marketplace_apps": "Marketplace apps", "Private_apps": "Private apps", @@ -6528,7 +6535,8 @@ "MAC_InfoText": "(MAC) the number of unique omnichannel contacts engaged with during the billing month.", "CountMAC_InfoText": "(MAC) the number of unique omnichannel contacts engaged with during the calendar month.", "ActiveSessions_InfoText": "Total concurrent connections. A single user can be connected multiple times. User presence service is disabled at 200 or more to prevent performance issues.", - "Apps_InfoText": "Community allows up to 3 private apps and 5 marketplace apps to be enabled", + "Apps_InfoText": "Community allows up to {{privateAppsMaxCount}} private apps and {{marketplaceAppsMaxCount}} marketplace apps to be enabled", + "Apps_InfoText_limited": "Community workspaces can enable up to {{marketplaceAppsMaxCount}} marketplace apps. Private apps can only be enabled in <1>premium plans.", "Remove_RocketChat_Watermark_InfoText": "Watermark is automatically removed when a paid license is active.", "Remove_RocketChat_Watermark": "Remove Rocket.Chat watermark", "High_scalabaility": "High scalabaility", diff --git a/packages/i18n/src/locales/es.i18n.json b/packages/i18n/src/locales/es.i18n.json index a486580cf821e..c66c07dda80c4 100644 --- a/packages/i18n/src/locales/es.i18n.json +++ b/packages/i18n/src/locales/es.i18n.json @@ -5066,7 +5066,7 @@ "MAC_InfoText": "Contactos Activos Mensuales (MAC). El número de contactos únicos de Omnichannel con quienes se interactuó durante un mes de facturación", "CountMAC_InfoText": "Contactos Activos Mensuales (MAC). El número de contactos únicos de Omnichannel con quienes se interactuó durante un mes calendario", "ActiveSessions_InfoText": "Total de conexiones concurrentes. Un usuario puede estar conectado varias veces. El servicio de presencia de usuario se deshabilita cuando el total llega a 200 conexiones para prevenir problemas de rendimiento", - "Apps_InfoText": "Comunidad permite hasta 3 aplicaciones privadas y 5 aplicaciones de la tienda ser habilitadas", + "Apps_InfoText": "Comunidad permite hasta {{privateAppsMaxCount}} aplicaciones privadas y {{marketplaceAppsMaxCount}} aplicaciones de la tienda ser habilitadas", "Remove_RocketChat_Watermark_InfoText": "La marca de agua es removida automticamente cuando una licencia de paga es activada", "Remove_RocketChat_Watermark": "Remover marca de agua de Rocket.Chat", "High_scalabaility": "Alta escalabilidad", @@ -5085,4 +5085,4 @@ "Unlimited_seats": "Puestos ilimitados", "Unlimited_MACs": "Contactos Activos por Mes (MAC) ilimitados", "Unlimited_seats_MACs": "Puestos y Contactos Activos por Mes (MAC) ilimitados" -} \ No newline at end of file +} diff --git a/packages/i18n/src/locales/fi.i18n.json b/packages/i18n/src/locales/fi.i18n.json index ebeafeb0f1d18..40387d02e4b14 100644 --- a/packages/i18n/src/locales/fi.i18n.json +++ b/packages/i18n/src/locales/fi.i18n.json @@ -2319,7 +2319,6 @@ "GoogleTagManager_id": "Google Tag Manager -tunnus", "Got_it": "Selvä", "Government": "Valtionjohto", - "Grandfathered_app": "Aikaisemmin käytössä ollut sovellus - lasketaan mukaan sovellusrajoitukseen, mutta rajoitusta ei sovelleta tähän sovellukseen", "Graphql_CORS": "GraphQL CORS", "Graphql_Enabled": "GraphQL käytössä", "Graphql_Subscription_Port": "GraphQL-tilausportti", diff --git a/packages/i18n/src/locales/hi-IN.i18n.json b/packages/i18n/src/locales/hi-IN.i18n.json index d75e30c2c9229..2f8c20ae63521 100644 --- a/packages/i18n/src/locales/hi-IN.i18n.json +++ b/packages/i18n/src/locales/hi-IN.i18n.json @@ -2418,7 +2418,6 @@ "GoogleTagManager_id": "Google टैग प्रबंधक आईडी", "Got_it": "समझ गया", "Government": "सरकार", - "Grandfathered_app": "दादाजी ऐप - ऐप सीमा में गिना जाता है लेकिन इस ऐप पर सीमा लागू नहीं होती है", "Graphql_CORS": "ग्राफक्यूएल कॉर्स", "Graphql_Enabled": "ग्राफक्यूएल सक्षम", "Graphql_Subscription_Port": "ग्राफक्यूएल सदस्यता पोर्ट", @@ -6111,7 +6110,7 @@ "MAC_InfoText": "(मैक) बिलिंग माह के दौरान जुड़े अद्वितीय सर्वचैनल संपर्कों की संख्या।", "CountMAC_InfoText": "(मैक) कैलेंडर माह के दौरान जुड़े अद्वितीय ओमनीचैनल संपर्कों की संख्या।", "ActiveSessions_InfoText": "कुल समवर्ती कनेक्शन. एक ही यूजर को कई बार कनेक्ट किया जा सकता है। प्रदर्शन समस्याओं को रोकने के लिए उपयोगकर्ता उपस्थिति सेवा 200 या उससे अधिक पर अक्षम है।", - "Apps_InfoText": "समुदाय 3 निजी ऐप्स और 5 मार्केटप्लेस ऐप्स को सक्षम करने की अनुमति देता है", + "Apps_InfoText": "समुदाय {{privateAppsMaxCount}} निजी ऐप्स और {{marketplaceAppsMaxCount}} मार्केटप्लेस ऐप्स को सक्षम करने की अनुमति देता है", "Remove_RocketChat_Watermark_InfoText": "सशुल्क लाइसेंस सक्रिय होने पर वॉटरमार्क स्वचालित रूप से हटा दिया जाता है।", "Remove_RocketChat_Watermark": "रॉकेट.चैट वॉटरमार्क हटाएँ", "High_scalabaility": "उच्च मापनीयता", diff --git a/packages/i18n/src/locales/pl.i18n.json b/packages/i18n/src/locales/pl.i18n.json index bb0feccfd6383..08dfbd471e862 100644 --- a/packages/i18n/src/locales/pl.i18n.json +++ b/packages/i18n/src/locales/pl.i18n.json @@ -5420,10 +5420,10 @@ "UpgradeToGetMore_accessibility-certification_Body": "Zgodność ze standardami WCAG i BITV dzięki programowi dostępności Rocket.Chat.", "UpgradeToGetMore_engagement-dashboard_Title": "Analityka", "UpgradeToGetMore_auditing_Title": "Audyt wiadomości", - "Apps_InfoText": "Wersja Community umożliwia włączenie do 3 aplikacji prywatnych i 5 aplikacji marketplace", + "Apps_InfoText": "Wersja Community umożliwia włączenie do {{privateAppsMaxCount}} aplikacji prywatnych i {{marketplaceAppsMaxCount}} aplikacji marketplace", "Anyone_can_react_to_messages": "Każdy może reagować na wiadomości", "Anyone_can_access": "Każdy może uzyskać dostęp", "Broadcast_hint_enabled": "Tylko właściciele {{roomType}} mogą pisać nowe wiadomości, ale każdy może odpowiadać w wątku", "Anyone_can_send_new_messages": "Każdy może wysyłać nowe wiadomości", "Select_messages_to_hide": "Wybierz wiadomości do ukrycia" -} \ No newline at end of file +} diff --git a/packages/i18n/src/locales/sv.i18n.json b/packages/i18n/src/locales/sv.i18n.json index 42caa2802c108..774dde2b4f17c 100644 --- a/packages/i18n/src/locales/sv.i18n.json +++ b/packages/i18n/src/locales/sv.i18n.json @@ -2323,7 +2323,6 @@ "GoogleTagManager_id": "Google Tag manager id", "Got_it": "Uppfattat", "Government": "Regering", - "Grandfathered_app": "Gamla appar - räknas mot appgränsen men gränsen tillämpas inte på denna app", "Graphql_CORS": "GraphQL CORS", "Graphql_Enabled": "GraphQL aktiverat", "Graphql_Subscription_Port": "GraphQL-prenumerationsport",