diff --git a/.changeset/two-geckos-train.md b/.changeset/two-geckos-train.md new file mode 100644 index 000000000000..16c2d5d6fd9e --- /dev/null +++ b/.changeset/two-geckos-train.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": major +"@rocket.chat/i18n": major +--- + +Adds new empty states for the marketplace view diff --git a/apps/meteor/client/apps/orchestrator.ts b/apps/meteor/client/apps/orchestrator.ts index f33807d25be4..86d4df829aa9 100644 --- a/apps/meteor/client/apps/orchestrator.ts +++ b/apps/meteor/client/apps/orchestrator.ts @@ -11,6 +11,9 @@ import type { App } from '../views/marketplace/types'; import type { IAppExternalURL, ICategory } from './@types/IOrchestrator'; import { RealAppsEngineUIHost } from './RealAppsEngineUIHost'; +const isErrorObject = (e: unknown): e is { error: string } => + typeof e === 'object' && e !== null && 'error' in e && typeof e.error === 'string'; + class AppClientOrchestrator { private _appClientUIHost: AppsEngineUIHost; @@ -53,15 +56,25 @@ class AppClientOrchestrator { throw new Error('Invalid response from API'); } - public async getAppsFromMarketplace(isAdminUser?: boolean): Promise { - 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 (isErrorObject(e)) { + return { apps: [], error: e.error }; + } + if (typeof e === 'string') { + return { apps: [], error: e }; + } + } 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, @@ -75,6 +88,8 @@ class AppClientOrchestrator { requestedEndUser, }; }); + + return { apps, error: undefined }; } public async getAppsOnBundle(bundleId: string): Promise { diff --git a/apps/meteor/client/contexts/AppsContext.tsx b/apps/meteor/client/contexts/AppsContext.tsx index 2be8e74c2d67..9421715eccbf 100644 --- a/apps/meteor/client/contexts/AppsContext.tsx +++ b/apps/meteor/client/contexts/AppsContext.tsx @@ -14,7 +14,7 @@ export interface IAppsOrchestrator { getAppClientManager(): AppClientManager; handleError(error: unknown): void; getInstalledApps(): Promise; - getAppsFromMarketplace(isAdminUser?: boolean): Promise; + getAppsFromMarketplace(isAdminUser?: boolean): Promise<{ apps: App[]; error?: unknown }>; getAppsOnBundle(bundleId: string): Promise; getApp(appId: string): Promise; setAppSettings(appId: string, settings: ISetting[]): Promise; @@ -27,9 +27,9 @@ export interface IAppsOrchestrator { } export type AppsContextValue = { - installedApps: Omit, 'error'>; - marketplaceApps: Omit, 'error'>; - privateApps: Omit, 'error'>; + installedApps: AsyncState<{ apps: App[] }>; + marketplaceApps: AsyncState<{ apps: App[] }>; + privateApps: AsyncState<{ apps: App[] }>; reload: () => Promise; orchestrator?: IAppsOrchestrator; }; @@ -38,14 +38,17 @@ export const AppsContext = createContext({ 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, diff --git a/apps/meteor/client/providers/AppsProvider/AppsProvider.tsx b/apps/meteor/client/providers/AppsProvider/AppsProvider.tsx index cf1d4d671d94..f67df644523f 100644 --- a/apps/meteor/client/providers/AppsProvider/AppsProvider.tsx +++ b/apps/meteor/client/providers/AppsProvider/AppsProvider.tsx @@ -2,12 +2,11 @@ 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'; -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'; @@ -17,15 +16,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; @@ -36,8 +44,10 @@ const AppsProvider = ({ children }: AppsProviderProps) => { const queryClient = useQueryClient(); - const { data } = useIsEnterprise(); - const isEnterprise = !!data?.isEnterprise; + const { isLoading: isLicenseInformationLoading, data: { license } = {} } = useLicense({ loadValues: true }); + const isEnterprise = isLicenseInformationLoading ? undefined : !!license; + + const [marketplaceError, setMarketplaceError] = useState(); const invalidateAppsCountQuery = useInvalidateAppsCountQueryCallback(); const invalidateLicenseQuery = useInvalidateLicense(); @@ -66,10 +76,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, @@ -95,21 +109,25 @@ 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'])]); }, diff --git a/apps/meteor/client/views/marketplace/AppsPage/AppsPage.tsx b/apps/meteor/client/views/marketplace/AppsPage/AppsPage.tsx index 0c90ac238d24..af0b8f384c8a 100644 --- a/apps/meteor/client/views/marketplace/AppsPage/AppsPage.tsx +++ b/apps/meteor/client/views/marketplace/AppsPage/AppsPage.tsx @@ -1,24 +1,13 @@ -import { useTranslation, useRouteParameter } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React from 'react'; -import { Page, PageContent } from '../../../components/Page'; -import MarketplaceHeader from '../components/MarketplaceHeader'; +import { Page } from '../../../components/Page'; import AppsPageContent from './AppsPageContent'; -type AppsContext = 'explore' | 'installed' | 'premium' | 'private'; - const AppsPage = (): ReactElement => { - const t = useTranslation(); - - const context = useRouteParameter('context') as AppsContext; - return ( - - - - + ); }; diff --git a/apps/meteor/client/views/marketplace/AppsPage/AppsPageContent.tsx b/apps/meteor/client/views/marketplace/AppsPage/AppsPageContent.tsx index e72a30e6a5c9..23446b0da208 100644 --- a/apps/meteor/client/views/marketplace/AppsPage/AppsPageContent.tsx +++ b/apps/meteor/client/views/marketplace/AppsPage/AppsPageContent.tsx @@ -4,8 +4,10 @@ import type { ReactElement } from 'react'; import React, { useEffect, useMemo, useState, useCallback } from 'react'; import { usePagination } from '../../../components/GenericTable/hooks/usePagination'; +import { PageContent } from '../../../components/Page'; import { useAppsResult } from '../../../contexts/hooks/useAppsResult'; import { AsyncStatePhase } from '../../../lib/asyncState'; +import MarketplaceHeader from '../components/MarketplaceHeader'; import type { RadioDropDownGroup } from '../definitions/RadioDropDownDefinitions'; import { useCategories } from '../hooks/useCategories'; import type { appsDataType } from '../hooks/useFilteredApps'; @@ -20,6 +22,9 @@ import NoInstalledAppMatchesEmptyState from './NoInstalledAppMatchesEmptyState'; import NoInstalledAppsEmptyState from './NoInstalledAppsEmptyState'; import NoMarketplaceOrInstalledAppMatchesEmptyState from './NoMarketplaceOrInstalledAppMatchesEmptyState'; import PrivateEmptyState from './PrivateEmptyState'; +import UnsupportedEmptyState from './UnsupportedEmptyState'; + +type AppsContext = 'explore' | 'installed' | 'premium' | 'private' | 'requested'; const AppsPageContent = (): ReactElement => { const t = useTranslation(); @@ -29,7 +34,7 @@ const AppsPageContent = (): ReactElement => { const router = useRouter(); - const context = useRouteParameter('context'); + const context = useRouteParameter('context') as AppsContext; const isMarketplace = context === 'explore'; const isPremium = context === 'premium'; @@ -134,6 +139,8 @@ const AppsPageContent = (): ReactElement => { const noInstalledApps = appsResult.phase === AsyncStatePhase.RESOLVED && !isMarketplace && appsResult.value?.totalAppsLength === 0; + const unsupportedVersion = appsResult.phase === AsyncStatePhase.REJECTED && appsResult.error.message === 'unsupported version'; + const noMarketplaceOrInstalledAppMatches = appsResult.phase === AsyncStatePhase.RESOLVED && (isMarketplace || isPremium) && appsResult.value?.count === 0; @@ -189,6 +196,10 @@ const AppsPageContent = (): ReactElement => { }, [isMarketplace, isRequested, sortFilterOnSelected, t, toggleInitialSortOption]); const getEmptyState = () => { + if (unsupportedVersion) { + return ; + } + if (noAppRequests) { return ; } @@ -213,7 +224,9 @@ const AppsPageContent = (): ReactElement => { }; return ( - <> + + + { context={context || 'explore'} /> {appsResult.phase === AsyncStatePhase.LOADING && } - {appsResult.phase === AsyncStatePhase.RESOLVED && noErrorsOcurred && ( + {appsResult.phase === AsyncStatePhase.RESOLVED && noErrorsOcurred && !unsupportedVersion && ( { /> )} {getEmptyState()} - {appsResult.phase === AsyncStatePhase.REJECTED && } - + {appsResult.phase === AsyncStatePhase.REJECTED && !unsupportedVersion && } + ); }; diff --git a/apps/meteor/client/views/marketplace/AppsPage/AppsPageContentBody.tsx b/apps/meteor/client/views/marketplace/AppsPage/AppsPageContentBody.tsx index 56c3ca26f9a7..bd41d63245f6 100644 --- a/apps/meteor/client/views/marketplace/AppsPage/AppsPageContentBody.tsx +++ b/apps/meteor/client/views/marketplace/AppsPage/AppsPageContentBody.tsx @@ -11,7 +11,12 @@ import FeaturedAppsSections from './FeaturedAppsSections'; type AppsPageContentBodyProps = { isMarketplace: boolean; isFiltered: boolean; - appsResult?: { items: App[] } & { shouldShowSearchText: boolean } & PaginatedResult & { allApps: App[] } & { totalAppsLength: number }; + appsResult?: PaginatedResult<{ + items: App[]; + shouldShowSearchText: boolean; + allApps: App[]; + totalAppsLength: number; + }>; itemsPerPage: 25 | 50 | 100; current: number; onSetItemsPerPage: React.Dispatch>; diff --git a/apps/meteor/client/views/marketplace/AppsPage/UnsupportedEmptyState.spec.tsx b/apps/meteor/client/views/marketplace/AppsPage/UnsupportedEmptyState.spec.tsx new file mode 100644 index 000000000000..1e205c602752 --- /dev/null +++ b/apps/meteor/client/views/marketplace/AppsPage/UnsupportedEmptyState.spec.tsx @@ -0,0 +1,33 @@ +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 UnsupportedEmptyState from './UnsupportedEmptyState'; + +describe('with private apps enabled', () => { + const appRoot = mockAppRoot() + .withTranslations('en', 'core', { + Marketplace_unavailable: 'Marketplace unavailable', + }) + .wrap((children) => ( + Promise.resolve(), + orchestrator: undefined, + }} + > + {children} + + )); + + it('should inform that the marketplace is unavailable due unsupported version', () => { + render(, { wrapper: appRoot.build(), legacyRoot: true }); + + expect(screen.getByRole('heading', { name: 'Marketplace unavailable' })).toBeInTheDocument(); + }); +}); diff --git a/apps/meteor/client/views/marketplace/AppsPage/UnsupportedEmptyState.stories.tsx b/apps/meteor/client/views/marketplace/AppsPage/UnsupportedEmptyState.stories.tsx new file mode 100644 index 000000000000..8f7ed193c77d --- /dev/null +++ b/apps/meteor/client/views/marketplace/AppsPage/UnsupportedEmptyState.stories.tsx @@ -0,0 +1,16 @@ +import type { ComponentMeta, ComponentStory } from '@storybook/react'; +import React from 'react'; + +import UnsupportedEmptyState from './UnsupportedEmptyState'; + +export default { + title: 'Marketplace/Components/UnsupportedEmptyState', + component: UnsupportedEmptyState, + parameters: { + layout: 'fullscreen', + controls: { hideNoControlsWarning: true }, + }, +} as ComponentMeta; + +export const Default: ComponentStory = () => ; +Default.storyName = 'UnsupportedEmptyState'; diff --git a/apps/meteor/client/views/marketplace/AppsPage/UnsupportedEmptyState.tsx b/apps/meteor/client/views/marketplace/AppsPage/UnsupportedEmptyState.tsx new file mode 100644 index 000000000000..d7999cfbad01 --- /dev/null +++ b/apps/meteor/client/views/marketplace/AppsPage/UnsupportedEmptyState.tsx @@ -0,0 +1,33 @@ +import { Box, States, StatesIcon, StatesTitle, StatesSubtitle, StatesActions, Button } from '@rocket.chat/fuselage'; +import { usePermission } from '@rocket.chat/ui-contexts'; +import type { ReactElement } from 'react'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import UpdateRocketChatButton from '../components/UpdateRocketChatButton'; + +const UnsupportedEmptyState = (): ReactElement => { + const isAdmin = usePermission('manage-apps'); + const { t } = useTranslation(); + + const title = isAdmin ? t('Update_to_access_marketplace') : t('Marketplace_unavailable'); + const description = isAdmin ? t('Update_to_access_marketplace_description') : t('Marketplace_unavailable_description'); + + return ( + + + + {title} + {description} + + + {isAdmin && } + + + + ); +}; + +export default UnsupportedEmptyState; diff --git a/apps/meteor/client/views/marketplace/components/MarketplaceHeader.tsx b/apps/meteor/client/views/marketplace/components/MarketplaceHeader.tsx index dfc3033e9812..6cb734056229 100644 --- a/apps/meteor/client/views/marketplace/components/MarketplaceHeader.tsx +++ b/apps/meteor/client/views/marketplace/components/MarketplaceHeader.tsx @@ -8,8 +8,9 @@ import { PageHeader } from '../../../components/Page'; import UnlimitedAppsUpsellModal from '../UnlimitedAppsUpsellModal'; import { useAppsCountQuery } from '../hooks/useAppsCountQuery'; import EnabledAppsCount from './EnabledAppsCount'; +import UpdateRocketChatButton from './UpdateRocketChatButton'; -const MarketplaceHeader = ({ title }: { title: string }): ReactElement | null => { +const MarketplaceHeader = ({ title, unsupportedVersion }: { title: string; unsupportedVersion: boolean }): ReactElement | null => { const t = useTranslation(); const isAdmin = usePermission('manage-apps'); const context = (useRouteParameter('context') || 'explore') as 'private' | 'explore' | 'installed' | 'premium' | 'requested'; @@ -29,8 +30,11 @@ const MarketplaceHeader = ({ title }: { title: string }): ReactElement | null => {result.isLoading && } - {result.isSuccess && !result.data.hasUnlimitedApps && } - {isAdmin && result.isSuccess && !result.data.hasUnlimitedApps && ( + {!unsupportedVersion && result.isSuccess && !result.data.hasUnlimitedApps && ( + + )} + + {!unsupportedVersion && isAdmin && result.isSuccess && !result.data.hasUnlimitedApps && ( )} + {isAdmin && context === 'private' && } + + {unsupportedVersion && context !== 'private' && } ); diff --git a/apps/meteor/client/views/marketplace/components/UpdateRocketChatButton.tsx b/apps/meteor/client/views/marketplace/components/UpdateRocketChatButton.tsx new file mode 100644 index 000000000000..9d6be4cc3cc8 --- /dev/null +++ b/apps/meteor/client/views/marketplace/components/UpdateRocketChatButton.tsx @@ -0,0 +1,15 @@ +import { Button } from '@rocket.chat/fuselage'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +const UpdateRocketChatButton = () => { + const { t } = useTranslation(); + + return ( + + ); +}; + +export default UpdateRocketChatButton; diff --git a/apps/meteor/client/views/marketplace/hooks/useFilteredApps.ts b/apps/meteor/client/views/marketplace/hooks/useFilteredApps.ts index c90052fd78e3..1a669b868080 100644 --- a/apps/meteor/client/views/marketplace/hooks/useFilteredApps.ts +++ b/apps/meteor/client/views/marketplace/hooks/useFilteredApps.ts @@ -39,9 +39,13 @@ export const useFilteredApps = ({ sortingMethod: string; status: string; context?: string; -}): Omit< - AsyncState<{ items: App[] } & { shouldShowSearchText: boolean } & PaginatedResult & { allApps: App[] } & { totalAppsLength: number }>, - 'error' +}): AsyncState< + PaginatedResult<{ + items: App[]; + shouldShowSearchText: boolean; + allApps: App[]; + totalAppsLength: number; + }> > => { const value = useMemo(() => { if (appsData.value === undefined) { diff --git a/apps/meteor/ee/server/apps/communication/rest.ts b/apps/meteor/ee/server/apps/communication/rest.ts index 3eed45f4ddad..9baee8196e20 100644 --- a/apps/meteor/ee/server/apps/communication/rest.ts +++ b/apps/meteor/ee/server/apps/communication/rest.ts @@ -125,6 +125,12 @@ export class AppsRestApi { ...(this.queryParams.isAdminUser === 'false' && { endUserID: this.user._id }), }, }); + + if (request.status === 426) { + orchestrator.getRocketChatLogger().error('Workspace out of support window:', await request.json()); + return API.v1.failure({ error: 'unsupported version' }); + } + if (request.status !== 200) { orchestrator.getRocketChatLogger().error('Error getting the Apps:', await request.json()); return API.v1.failure(); diff --git a/apps/meteor/tests/mocks/client/marketplace.tsx b/apps/meteor/tests/mocks/client/marketplace.tsx index f0147509cb12..1e87c26d4f72 100644 --- a/apps/meteor/tests/mocks/client/marketplace.tsx +++ b/apps/meteor/tests/mocks/client/marketplace.tsx @@ -30,7 +30,7 @@ export const mockAppsOrchestrator = () => { getAppClientManager: () => manager, handleError: () => undefined, getInstalledApps: async () => [], - getAppsFromMarketplace: async () => [], + getAppsFromMarketplace: async () => ({ apps: [] }), getAppsOnBundle: async () => [], getApp: () => Promise.reject(new Error('not implemented')), setAppSettings: async () => undefined, diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 391336999af8..4ea13ecaee16 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -3531,6 +3531,8 @@ "Marketplace_app_last_updated": "Last updated {{lastUpdated}}", "Marketplace_view_marketplace": "View Marketplace", "Marketplace_error": "Cannot connect to internet or your workspace may be an offline install.", + "Marketplace_unavailable": "Marketplace unavailable", + "Marketplace_unavailable_description": "This workspace cannot access the marketplace because it’s running an unsupported version of Rocket.Chat. Ask your workspace admin to update and regain access.", "MAU_value": "MAU {{value}}", "Max_length_is": "Max length is %s", "Max_number_incoming_livechats_displayed": "Max number of items displayed in the queue", @@ -5625,6 +5627,8 @@ "Update_LatestAvailableVersion": "Update Latest Available Version", "Update_to_version": "Update to {{version}}", "Update_your_RocketChat": "Update your Rocket.Chat", + "Update_to_access_marketplace": "Update to access marketplace", + "Update_to_access_marketplace_description": "This workspace cannot access the marketplace because it's running an unsupported version of Rocket.Chat.", "Updated_at": "Updated at", "Upgrade_tab_upgrade_your_plan": "Upgrade your plan", "Upload": "Upload",