Skip to content

Commit

Permalink
Merge branch 'unsuported-marketplace' into private-apps-gauge
Browse files Browse the repository at this point in the history
  • Loading branch information
MartinSchoeler authored Sep 18, 2024
2 parents 61a4e98 + 5d644f8 commit 08b93d5
Show file tree
Hide file tree
Showing 20 changed files with 254 additions and 56 deletions.
6 changes: 6 additions & 0 deletions .changeset/two-geckos-train.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@rocket.chat/meteor": major
"@rocket.chat/i18n": major
---

Added new empty states for the marketplace view
20 changes: 16 additions & 4 deletions apps/meteor/client/apps/orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import type { App } from '../views/marketplace/types';
import type { IAppExternalURL, ICategory } from './@types/IOrchestrator';
import { RealAppsEngineUIHost } from './RealAppsEngineUIHost';

const isApiError = (e: unknown): e is { error: string } =>
typeof e === 'object' && e !== null && 'error' in e && typeof e.error === 'string';

class AppClientOrchestrator {
private _appClientUIHost: AppsEngineUIHost;

Expand Down Expand Up @@ -53,15 +56,22 @@ class AppClientOrchestrator {
throw new Error('Invalid response from API');
}

public async getAppsFromMarketplace(isAdminUser?: boolean): Promise<App[]> {
const result = await sdk.rest.get('/apps/marketplace', { isAdminUser: isAdminUser ? isAdminUser.toString() : 'false' });
public async getAppsFromMarketplace(isAdminUser?: boolean): Promise<{ apps: App[]; error?: unknown }> {
let result: App[] = [];
try {
result = await sdk.rest.get('/apps/marketplace', { isAdminUser: isAdminUser ? isAdminUser.toString() : 'false' });
} catch (e) {
if (isApiError(e)) {
return { apps: [], error: e.error };
}
}

if (!Array.isArray(result)) {
// TODO: chapter day: multiple results are returned, but we only need one
throw new Error('Invalid response from API');
return { apps: [], error: 'Invalid response from API' };
}

return (result as App[]).map((app: App) => {
const apps = (result as App[]).map((app: App) => {
const { latest, appRequestStats, price, pricingPlans, purchaseType, isEnterpriseOnly, modifiedAt, bundledIn, requestedEndUser } = app;
return {
...latest,
Expand All @@ -75,6 +85,8 @@ class AppClientOrchestrator {
requestedEndUser,
};
});

return { apps, error: undefined };
}

public async getAppsOnBundle(bundleId: string): Promise<App[]> {
Expand Down
11 changes: 7 additions & 4 deletions apps/meteor/client/contexts/AppsContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export interface IAppsOrchestrator {
getAppClientManager(): AppClientManager;
handleError(error: unknown): void;
getInstalledApps(): Promise<App[]>;
getAppsFromMarketplace(isAdminUser?: boolean): Promise<App[]>;
getAppsFromMarketplace(isAdminUser?: boolean): Promise<{ apps: App[]; error?: unknown }>;
getAppsOnBundle(bundleId: string): Promise<App[]>;
getApp(appId: string): Promise<App>;
setAppSettings(appId: string, settings: ISetting[]): Promise<void>;
Expand All @@ -27,9 +27,9 @@ export interface IAppsOrchestrator {
}

export type AppsContextValue = {
installedApps: Omit<AsyncState<{ apps: App[] }>, 'error'>;
marketplaceApps: Omit<AsyncState<{ apps: App[] }>, 'error'>;
privateApps: Omit<AsyncState<{ apps: App[] }>, 'error'>;
installedApps: AsyncState<{ apps: App[] }>;
marketplaceApps: AsyncState<{ apps: App[] }>;
privateApps: AsyncState<{ apps: App[] }>;
reload: () => Promise<void>;
orchestrator?: IAppsOrchestrator;
};
Expand All @@ -38,14 +38,17 @@ export const AppsContext = createContext<AppsContextValue>({
installedApps: {
phase: AsyncStatePhase.LOADING,
value: undefined,
error: undefined,
},
marketplaceApps: {
phase: AsyncStatePhase.LOADING,
value: undefined,
error: undefined,
},
privateApps: {
phase: AsyncStatePhase.LOADING,
value: undefined,
error: undefined,
},
reload: () => Promise.resolve(),
orchestrator: undefined,
Expand Down
43 changes: 29 additions & 14 deletions apps/meteor/client/providers/AppsProvider/AppsProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useDebouncedCallback } from '@rocket.chat/fuselage-hooks';
import { usePermission, useStream } from '@rocket.chat/ui-contexts';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import React, { useEffect } from 'react';
import React, { useEffect, useState } from 'react';

import { AppClientOrchestratorInstance } from '../../apps/orchestrator';
import { AppsContext } from '../../contexts/AppsContext';
Expand All @@ -17,15 +17,24 @@ import { storeQueryFunction } from './storeQueryFunction';
const getAppState = (
loading: boolean,
apps: App[] | undefined,
): Omit<
AsyncState<{
apps: App[];
}>,
'error'
> => ({
phase: loading ? AsyncStatePhase.LOADING : AsyncStatePhase.RESOLVED,
value: { apps: apps || [] },
});
error?: Error,
): AsyncState<{
apps: App[];
}> => {
if (error) {
return {
phase: AsyncStatePhase.REJECTED,
value: undefined,
error,
};
}

return {
phase: loading ? AsyncStatePhase.LOADING : AsyncStatePhase.RESOLVED,
value: { apps: apps || [] },
error,
};
};

type AppsProviderProps = {
children: ReactNode;
Expand All @@ -39,6 +48,8 @@ const AppsProvider = ({ children }: AppsProviderProps) => {
const { data } = useIsEnterprise();
const isEnterprise = !!data?.isEnterprise;

const [marketplaceError, setMarketplaceError] = useState<Error>();

const invalidateAppsCountQuery = useInvalidateAppsCountQueryCallback();
const invalidateLicenseQuery = useInvalidateLicense();

Expand Down Expand Up @@ -66,10 +77,14 @@ const AppsProvider = ({ children }: AppsProviderProps) => {

const marketplace = useQuery(
['marketplace', 'apps-marketplace', isAdminUser],
() => {
const result = AppClientOrchestratorInstance.getAppsFromMarketplace(isAdminUser);
async () => {
const result = await AppClientOrchestratorInstance.getAppsFromMarketplace(isAdminUser);
queryClient.invalidateQueries(['marketplace', 'apps-stored']);
return result;
if (result.error && typeof result.error === 'string') {
setMarketplaceError(new Error(result.error));
return [];
}
return result.apps;
},
{
staleTime: Infinity,
Expand Down Expand Up @@ -108,7 +123,7 @@ const AppsProvider = ({ children }: AppsProviderProps) => {
children={children}
value={{
installedApps: getAppState(isLoading, installedAppsData),
marketplaceApps: getAppState(isLoading, marketplaceAppsData),
marketplaceApps: getAppState(isLoading, marketplaceAppsData, marketplaceError),
privateApps: getAppState(isLoading, privateAppsData),
reload: async () => {
await Promise.all([queryClient.invalidateQueries(['marketplace'])]);
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?: string | 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: string | ReactNode;
};

const InfoTextIconModal = ({ title, infoText }: InfoTextIconModalProps): ReactElement => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Box, ProgressBar, Skeleton } from '@rocket.chat/fuselage';
import type { ReactElement } from 'react';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Trans, useTranslation } from 'react-i18next';

import type { CardProps } from '../FeatureUsageCard';
import FeatureUsageCard from '../FeatureUsageCard';
Expand All @@ -25,7 +25,15 @@ const AppsUsageCard = ({ privateAppsLimit, marketplaceAppsLimit }: AppsUsageCard

const card: CardProps = {
title: t('Apps'),
infoText: t('Apps_InfoText'),
infoText: (
<Trans i18nKey='Apps_InfoText'>
Community workspaces can enable up to 5 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>
),
...((marketplaceAppsPercentage || 0) >= 80 && {
upgradeButton: (
<UpgradeButton target='app-usage-card' action='upgrade' small>
Expand Down
8 changes: 5 additions & 3 deletions apps/meteor/client/views/marketplace/AppsPage/AppsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useTranslation, useRouteParameter } from '@rocket.chat/ui-contexts';
import type { ReactElement } from 'react';
import React from 'react';
import React, { useRef } from 'react';

import { Page, PageContent } from '../../../components/Page';
import MarketplaceHeader from '../components/MarketplaceHeader';
Expand All @@ -13,11 +13,13 @@ const AppsPage = (): ReactElement => {

const context = useRouteParameter('context') as AppsContext;

const unsupportedVersion = useRef<boolean>(false);

return (
<Page background='tint'>
<MarketplaceHeader title={t(`Apps_context_${context}`)} />
<MarketplaceHeader unsupportedVersion={unsupportedVersion} title={t(`Apps_context_${context}`)} />
<PageContent paddingInline='0'>
<AppsPageContent />
<AppsPageContent unsupportedVersion={unsupportedVersion} />
</PageContent>
</Page>
);
Expand Down
19 changes: 15 additions & 4 deletions apps/meteor/client/views/marketplace/AppsPage/AppsPageContent.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useDebouncedState } from '@rocket.chat/fuselage-hooks';
import { useRouteParameter, useRouter, useTranslation } from '@rocket.chat/ui-contexts';
import type { ReactElement } from 'react';
import type { MutableRefObject, ReactElement } from 'react';
import React, { useEffect, useMemo, useState, useCallback } from 'react';

import { usePagination } from '../../../components/GenericTable/hooks/usePagination';
Expand All @@ -20,8 +20,13 @@ import NoInstalledAppMatchesEmptyState from './NoInstalledAppMatchesEmptyState';
import NoInstalledAppsEmptyState from './NoInstalledAppsEmptyState';
import NoMarketplaceOrInstalledAppMatchesEmptyState from './NoMarketplaceOrInstalledAppMatchesEmptyState';
import PrivateEmptyState from './PrivateEmptyState';
import UnsupportedEmptyState from './UnsupportedEmptyState';

const AppsPageContent = (): ReactElement => {
type AppsPageContentProps = {
unsupportedVersion: MutableRefObject<boolean>;
};

const AppsPageContent = ({ unsupportedVersion }: AppsPageContentProps): ReactElement => {
const t = useTranslation();
const { marketplaceApps, installedApps, privateApps, reload } = useAppsResult();
const [text, setText] = useDebouncedState('', 500);
Expand Down Expand Up @@ -134,6 +139,8 @@ const AppsPageContent = (): ReactElement => {

const noInstalledApps = appsResult.phase === AsyncStatePhase.RESOLVED && !isMarketplace && appsResult.value?.totalAppsLength === 0;

unsupportedVersion.current = appsResult.phase === AsyncStatePhase.REJECTED && appsResult.error.message === 'unsupported version';

const noMarketplaceOrInstalledAppMatches =
appsResult.phase === AsyncStatePhase.RESOLVED && (isMarketplace || isPremium) && appsResult.value?.count === 0;

Expand Down Expand Up @@ -189,6 +196,10 @@ const AppsPageContent = (): ReactElement => {
}, [isMarketplace, isRequested, sortFilterOnSelected, t, toggleInitialSortOption]);

const getEmptyState = () => {
if (unsupportedVersion.current) {
return <UnsupportedEmptyState />;
}

if (noAppRequests) {
return <NoAppRequestsEmptyState />;
}
Expand Down Expand Up @@ -229,7 +240,7 @@ const AppsPageContent = (): ReactElement => {
context={context || 'explore'}
/>
{appsResult.phase === AsyncStatePhase.LOADING && <AppsPageContentSkeleton />}
{appsResult.phase === AsyncStatePhase.RESOLVED && noErrorsOcurred && (
{appsResult.phase === AsyncStatePhase.RESOLVED && noErrorsOcurred && !unsupportedVersion.current && (
<AppsPageContentBody
isMarketplace={isMarketplace}
isFiltered={isFiltered}
Expand All @@ -243,7 +254,7 @@ const AppsPageContent = (): ReactElement => {
/>
)}
{getEmptyState()}
{appsResult.phase === AsyncStatePhase.REJECTED && <AppsPageConnectionError onButtonClick={reload} />}
{appsResult.phase === AsyncStatePhase.REJECTED && !unsupportedVersion.current && <AppsPageConnectionError onButtonClick={reload} />}
</>
);
};
Expand Down
24 changes: 12 additions & 12 deletions apps/meteor/client/views/marketplace/AppsPage/PrivateEmptyState.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import { States, StatesIcon, StatesTitle, StatesSubtitle, Box } from '@rocket.chat/fuselage';
import { Box, Skeleton } from '@rocket.chat/fuselage';
import React from 'react';
import { useTranslation } from 'react-i18next';

import { useLicense } from '../../../hooks/useLicense';
import PrivateEmptyStateDefault from './PrivateEmptyStateDefault';
import PrivateEmptyStateUpgrade from './PrivateEmptyStateUpgrade';

const PrivateEmptyState = () => {
const { t } = useTranslation();
const { data, isLoading } = useLicense({ loadValues: true });
const { limits } = data || {};

if (isLoading) {
return <Skeleton />;
}

return (
<Box mbs='24px'>
<States>
<StatesIcon name='lock' />
<StatesTitle>{t('No_private_apps_installed')}</StatesTitle>
<StatesSubtitle>{t('Private_apps_are_side-loaded')}</StatesSubtitle>
</States>
</Box>
);
return <Box mbs='24px'>{limits?.privateApps?.max === 0 ? <PrivateEmptyStateUpgrade /> : <PrivateEmptyStateDefault />}</Box>;
};

export default PrivateEmptyState;
Original file line number Diff line number Diff line change
@@ -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 (
<States>
<StatesIcon name='lock' />
<StatesTitle>{t('No_private_apps_installed')}</StatesTitle>
<StatesSubtitle>{t('Private_apps_are_side-loaded')}</StatesSubtitle>
</States>
);
};

export default PrivateEmptyStateDefault;
Original file line number Diff line number Diff line change
@@ -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 (
<States>
<StatesIcon name='lightning' />
<StatesTitle>{t('Private_apps_upgrade_empty_state_title')}</StatesTitle>
<StatesSubtitle>{t('Private_apps_upgrade_empty_state_description')}</StatesSubtitle>
{isAdmin && (
<StatesActions>
<UpgradeButton primary icon={undefined} target='private-apps-header' action='upgrade'>
{t('Upgrade')}
</UpgradeButton>
</StatesActions>
)}
</States>
);
};

export default PrivateEmptyStateUpgrade;
Loading

0 comments on commit 08b93d5

Please sign in to comment.