Skip to content

Commit

Permalink
feat: New marketplace state for out of support window workspaces (#33238
Browse files Browse the repository at this point in the history
)
  • Loading branch information
MartinSchoeler authored Oct 15, 2024
1 parent 065a742 commit 31eb47f
Show file tree
Hide file tree
Showing 16 changed files with 227 additions and 60 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
---

Adds new empty states for the marketplace view
23 changes: 19 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 isErrorObject = (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,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,
Expand All @@ -75,6 +88,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
70 changes: 44 additions & 26 deletions apps/meteor/client/providers/AppsProvider/AppsProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand All @@ -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();
Expand Down Expand Up @@ -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,
Expand All @@ -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'])]);
},
Expand Down
15 changes: 2 additions & 13 deletions apps/meteor/client/views/marketplace/AppsPage/AppsPage.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
};
Expand Down
23 changes: 18 additions & 5 deletions apps/meteor/client/views/marketplace/AppsPage/AppsPageContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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();
Expand All @@ -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';
Expand Down Expand Up @@ -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;

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

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

if (noAppRequests) {
return <NoAppRequestsEmptyState />;
}
Expand All @@ -213,7 +224,9 @@ const AppsPageContent = (): ReactElement => {
};

return (
<>
<PageContent>
<MarketplaceHeader unsupportedVersion={unsupportedVersion} title={t(`Apps_context_${context}`)} />

<AppsFilters
setText={setText}
freePaidFilterStructure={freePaidFilterStructure}
Expand All @@ -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}
Expand All @@ -243,8 +256,8 @@ const AppsPageContent = (): ReactElement => {
/>
)}
{getEmptyState()}
{appsResult.phase === AsyncStatePhase.REJECTED && <AppsPageConnectionError onButtonClick={reload} />}
</>
{appsResult.phase === AsyncStatePhase.REJECTED && !unsupportedVersion && <AppsPageConnectionError onButtonClick={reload} />}
</PageContent>
);
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>>;
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
});
});
Original file line number Diff line number Diff line change
@@ -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';
Loading

0 comments on commit 31eb47f

Please sign in to comment.