feat: New marketplace state for out of support window workspaces (#33238)

pull/33518/head^2
Martin Schoeler 1 year ago committed by GitHub
parent 065a74258a
commit 31eb47f573
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 6
      .changeset/two-geckos-train.md
  2. 23
      apps/meteor/client/apps/orchestrator.ts
  3. 11
      apps/meteor/client/contexts/AppsContext.tsx
  4. 70
      apps/meteor/client/providers/AppsProvider/AppsProvider.tsx
  5. 15
      apps/meteor/client/views/marketplace/AppsPage/AppsPage.tsx
  6. 23
      apps/meteor/client/views/marketplace/AppsPage/AppsPageContent.tsx
  7. 7
      apps/meteor/client/views/marketplace/AppsPage/AppsPageContentBody.tsx
  8. 33
      apps/meteor/client/views/marketplace/AppsPage/UnsupportedEmptyState.spec.tsx
  9. 16
      apps/meteor/client/views/marketplace/AppsPage/UnsupportedEmptyState.stories.tsx
  10. 33
      apps/meteor/client/views/marketplace/AppsPage/UnsupportedEmptyState.tsx
  11. 13
      apps/meteor/client/views/marketplace/components/MarketplaceHeader.tsx
  12. 15
      apps/meteor/client/views/marketplace/components/UpdateRocketChatButton.tsx
  13. 10
      apps/meteor/client/views/marketplace/hooks/useFilteredApps.ts
  14. 6
      apps/meteor/ee/server/apps/communication/rest.ts
  15. 2
      apps/meteor/tests/mocks/client/marketplace.tsx
  16. 4
      packages/i18n/src/locales/en.i18n.json

@ -0,0 +1,6 @@
---
"@rocket.chat/meteor": major
"@rocket.chat/i18n": major
---
Adds new empty states for the marketplace view

@ -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<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 (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<App[]> {

@ -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>;
@ -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;
};
@ -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,

@ -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<Error>();
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 (
<AppsContext.Provider
children={children}
value={{
installedApps: getAppState(isLoading, installedAppsData),
marketplaceApps: getAppState(isLoading, marketplaceAppsData),
privateApps: getAppState(isLoading, privateAppsData),
installedApps: getAppState(isMarketplaceDataLoading, installedAppsData),
marketplaceApps: getAppState(isMarketplaceDataLoading, marketplaceAppsData, marketplaceError),
privateApps: getAppState(isMarketplaceDataLoading, privateAppsData),
reload: async () => {
await Promise.all([queryClient.invalidateQueries(['marketplace'])]);
},

@ -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 (
<Page background='tint'>
<MarketplaceHeader title={t(`Apps_context_${context}`)} />
<PageContent paddingInline='0'>
<AppsPageContent />
</PageContent>
<AppsPageContent />
</Page>
);
};

@ -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 <UnsupportedEmptyState />;
}
if (noAppRequests) {
return <NoAppRequestsEmptyState />;
}
@ -213,7 +224,9 @@ const AppsPageContent = (): ReactElement => {
};
return (
<>
<PageContent>
<MarketplaceHeader unsupportedVersion={unsupportedVersion} title={t(`Apps_context_${context}`)} />
<AppsFilters
setText={setText}
freePaidFilterStructure={freePaidFilterStructure}
@ -229,7 +242,7 @@ const AppsPageContent = (): ReactElement => {
context={context || 'explore'}
/>
{appsResult.phase === AsyncStatePhase.LOADING && <AppsPageContentSkeleton />}
{appsResult.phase === AsyncStatePhase.RESOLVED && noErrorsOcurred && (
{appsResult.phase === AsyncStatePhase.RESOLVED && noErrorsOcurred && !unsupportedVersion && (
<AppsPageContentBody
isMarketplace={isMarketplace}
isFiltered={isFiltered}
@ -243,8 +256,8 @@ const AppsPageContent = (): ReactElement => {
/>
)}
{getEmptyState()}
{appsResult.phase === AsyncStatePhase.REJECTED && <AppsPageConnectionError onButtonClick={reload} />}
</>
{appsResult.phase === AsyncStatePhase.REJECTED && !unsupportedVersion && <AppsPageConnectionError onButtonClick={reload} />}
</PageContent>
);
};

@ -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<React.SetStateAction<25 | 50 | 100>>;

@ -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) => (
<AppsContext.Provider
value={{
installedApps: asyncState.resolved({ apps: [] }),
marketplaceApps: asyncState.rejected(new Error('unsupported version')),
privateApps: asyncState.resolved({ apps: [] }),
reload: () => Promise.resolve(),
orchestrator: undefined,
}}
>
{children}
</AppsContext.Provider>
));
it('should inform that the marketplace is unavailable due unsupported version', () => {
render(<UnsupportedEmptyState />, { wrapper: appRoot.build(), legacyRoot: true });
expect(screen.getByRole('heading', { name: 'Marketplace unavailable' })).toBeInTheDocument();
});
});

@ -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<typeof UnsupportedEmptyState>;
export const Default: ComponentStory<typeof UnsupportedEmptyState> = () => <UnsupportedEmptyState />;
Default.storyName = 'UnsupportedEmptyState';

@ -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 (
<Box mbs={64}>
<States>
<StatesIcon name='warning' />
<StatesTitle>{title}</StatesTitle>
<StatesSubtitle>{description}</StatesSubtitle>
<StatesActions>
<Button secondary is='a' href='https://go.rocket.chat/i/support-prerequisites ' external>
{t('Learn_more')}
</Button>
{isAdmin && <UpdateRocketChatButton />}
</StatesActions>
</States>
</Box>
);
};
export default UnsupportedEmptyState;

@ -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 =>
<PageHeader title={title}>
<ButtonGroup wrap align='end'>
{result.isLoading && <GenericResourceUsageSkeleton />}
{result.isSuccess && !result.data.hasUnlimitedApps && <EnabledAppsCount {...result.data} context={context} />}
{isAdmin && result.isSuccess && !result.data.hasUnlimitedApps && (
{!unsupportedVersion && result.isSuccess && !result.data.hasUnlimitedApps && (
<EnabledAppsCount {...result.data} context={context} />
)}
{!unsupportedVersion && isAdmin && result.isSuccess && !result.data.hasUnlimitedApps && (
<Button
onClick={() => {
setModal(<UnlimitedAppsUpsellModal onClose={() => setModal(null)} />);
@ -39,7 +43,10 @@ const MarketplaceHeader = ({ title }: { title: string }): ReactElement | null =>
{t('Enable_unlimited_apps')}
</Button>
)}
{isAdmin && context === 'private' && <Button onClick={handleUploadButtonClick}>{t('Upload_private_app')}</Button>}
{unsupportedVersion && context !== 'private' && <UpdateRocketChatButton />}
</ButtonGroup>
</PageHeader>
);

@ -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 (
<Button primary is='a' href='https://docs.rocket.chat/v1/docs/en/updating-rocketchat' external>
{t('Update')}
</Button>
);
};
export default UpdateRocketChatButton;

@ -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) {

@ -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();

@ -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,

@ -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",

Loading…
Cancel
Save