Skip to content

Commit

Permalink
refactor(client): Rewrite AppMenu to TypeScript (#31533)
Browse files Browse the repository at this point in the history
  • Loading branch information
tiagoevanp authored Apr 30, 2024
1 parent 6205ef1 commit 972b5b8
Show file tree
Hide file tree
Showing 24 changed files with 683 additions and 201 deletions.
18 changes: 18 additions & 0 deletions apps/meteor/client/components/WarningModal.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen } from '@testing-library/react';
import React from 'react';

import WarningModal from './WarningModal';

import '@testing-library/jest-dom';

it('should look good', async () => {
render(<WarningModal text='text' confirmText='confirm' cancelText='cancel' confirm={() => undefined} close={() => undefined} />, {
wrapper: mockAppRoot().build(),
});

expect(screen.getByRole('heading')).toHaveTextContent('Are_you_sure');
expect(screen.getByRole('button', { name: 'cancel' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'confirm' })).toBeInTheDocument();
expect(screen.getByText('text')).toBeInTheDocument();
});
14 changes: 7 additions & 7 deletions apps/meteor/client/components/WarningModal.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
import { Button, Modal } from '@rocket.chat/fuselage';
import { useTranslation } from '@rocket.chat/ui-contexts';
import type { ReactElement } from 'react';
import type { ReactElement, ReactNode } from 'react';
import React from 'react';

type WarningModalProps = {
text: string;
confirmText: string;
text: ReactNode;
confirmText: ReactNode;
cancelText?: ReactNode;
confirm: () => void;
cancel?: () => void;
close: () => void;
cancel: () => void;
cancelText: string;
confirm: () => Promise<void>;
};

const WarningModal = ({ text, confirmText, close, cancel, cancelText, confirm, ...props }: WarningModalProps): ReactElement => {
const t = useTranslation();
return (
<Modal {...props}>
<Modal open {...props}>
<Modal.Header>
<Modal.Icon color='danger' name='modal-warning' />
<Modal.Title>{t('Are_you_sure')}</Modal.Title>
Expand Down
24 changes: 24 additions & 0 deletions apps/meteor/client/contexts/AppsContext.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,37 @@
import type { AppClientManager } from '@rocket.chat/apps-engine/client/AppClientManager';
import type { IPermission } from '@rocket.chat/apps-engine/definition/permissions/IPermission';
import type { ISetting } from '@rocket.chat/apps-engine/definition/settings';
import type { Serialized } from '@rocket.chat/core-typings';
import { createContext } from 'react';

import type { IAppExternalURL, ICategory } from '../../ee/client/apps/@types/IOrchestrator';
import type { AsyncState } from '../lib/asyncState';
import { AsyncStatePhase } from '../lib/asyncState';
import type { App } from '../views/marketplace/types';

export interface IAppsOrchestrator {
load(): Promise<void>;
getAppClientManager(): AppClientManager;
handleError(error: unknown): void;
getInstalledApps(): Promise<App[]>;
getAppsFromMarketplace(isAdminUser?: boolean): Promise<App[]>;
getAppsOnBundle(bundleId: string): Promise<App[]>;
getApp(appId: string): Promise<App>;
setAppSettings(appId: string, settings: ISetting[]): Promise<void>;
installApp(appId: string, version: string, permissionsGranted?: IPermission[]): Promise<App>;
updateApp(appId: string, version: string, permissionsGranted?: IPermission[]): Promise<App>;
buildExternalUrl(appId: string, purchaseType?: 'buy' | 'subscription', details?: boolean): Promise<IAppExternalURL>;
buildExternalAppRequest(appId: string): Promise<{ url: string }>;
buildIncompatibleExternalUrl(appId: string, appVersion: string, action: string): Promise<IAppExternalURL>;
getCategories(): Promise<Serialized<ICategory[]>>;
}

export type AppsContextValue = {
installedApps: Omit<AsyncState<{ apps: App[] }>, 'error'>;
marketplaceApps: Omit<AsyncState<{ apps: App[] }>, 'error'>;
privateApps: Omit<AsyncState<{ apps: App[] }>, 'error'>;
reload: () => Promise<void>;
orchestrator?: IAppsOrchestrator;
};

export const AppsContext = createContext<AppsContextValue>({
Expand All @@ -25,4 +48,5 @@ export const AppsContext = createContext<AppsContextValue>({
value: undefined,
},
reload: () => Promise.resolve(),
orchestrator: undefined,
});
10 changes: 8 additions & 2 deletions apps/meteor/client/providers/AppsProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,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 { FC } from 'react';
import type { ReactNode } from 'react';
import React, { useEffect } from 'react';

import { AppClientOrchestratorInstance } from '../../ee/client/apps/orchestrator';
Expand All @@ -28,7 +28,11 @@ const getAppState = (
value: { apps: apps || [] },
});

const AppsProvider: FC = ({ children }) => {
type AppsProviderProps = {
children: ReactNode;
};

const AppsProvider = ({ children }: AppsProviderProps) => {
const isAdminUser = usePermission('manage-apps');

const queryClient = useQueryClient();
Expand Down Expand Up @@ -160,8 +164,10 @@ const AppsProvider: FC = ({ children }) => {
reload: async () => {
await Promise.all([queryClient.invalidateQueries(['marketplace'])]);
},
orchestrator: AppClientOrchestratorInstance,
}}
/>
);
};

export default AppsProvider;
1 change: 0 additions & 1 deletion apps/meteor/client/sidebar/RoomMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,6 @@ const RoomMenu = ({
text={t(warnText as TranslationKey, name)}
confirmText={t('Leave_room')}
close={closeModal}
cancel={closeModal}
cancelText={t('Cancel')}
confirm={leave}
/>,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { faker } from '@faker-js/faker';
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen } from '@testing-library/react';
import React from 'react';
import '@testing-library/jest-dom';

import { mockedAppsContext } from '../../../../../../tests/mocks/client/marketplace';
import { createFakeApp, createFakeLicenseInfo } from '../../../../../../tests/mocks/data';
import AppStatus from './AppStatus';

it('should look good', async () => {
const app = createFakeApp();

render(<AppStatus app={app} showStatus isAppDetailsPage />, {
wrapper: mockAppRoot()
.withJohnDoe()
.withEndpoint('GET', '/apps/count', async () => ({
maxMarketplaceApps: faker.number.int({ min: 0 }),
installedApps: faker.number.int({ min: 0 }),
maxPrivateApps: faker.number.int({ min: 0 }),
totalMarketplaceEnabled: faker.number.int({ min: 0 }),
totalPrivateEnabled: faker.number.int({ min: 0 }),
}))
.withEndpoint('GET', '/v1/licenses.info', async () => ({
license: createFakeLicenseInfo(),
}))
.wrap(mockedAppsContext)
.build(),
});

screen.getByRole('button', { name: 'Request' }).click();
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ import semver from 'semver';
import { useIsEnterprise } from '../../../../../hooks/useIsEnterprise';
import type { appStatusSpanResponseProps } from '../../../helpers';
import { appButtonProps, appMultiStatusProps } from '../../../helpers';
import { marketplaceActions } from '../../../helpers/marketplaceActions';
import type { AppInstallationHandlerParams } from '../../../hooks/useAppInstallationHandler';
import { useAppInstallationHandler } from '../../../hooks/useAppInstallationHandler';
import { useMarketplaceActions } from '../../../hooks/useMarketplaceActions';
import AppStatusPriceDisplay from './AppStatusPriceDisplay';

type AppStatusProps = {
Expand Down Expand Up @@ -48,18 +48,22 @@ const AppStatus = ({ app, showStatus = true, isAppDetailsPage, installed, ...pro

const action = button?.action;

const marketplaceActions = useMarketplaceActions();

const confirmAction = useCallback<AppInstallationHandlerParams['onSuccess']>(
async (action, permissionsGranted) => {
if (action !== 'request') {
setPurchased(true);
await marketplaceActions[action]({ ...app, permissionsGranted });
} else {
setEndUserRequested(true);
if (action) {
if (action !== 'request') {
setPurchased(true);
await marketplaceActions[action]({ ...app, permissionsGranted });
} else {
setEndUserRequested(true);
}
}

setLoading(false);
},
[app, setLoading, setPurchased],
[app, marketplaceActions, setLoading, setPurchased],
);

const cancelAction = useCallback(() => {
Expand Down
30 changes: 30 additions & 0 deletions apps/meteor/client/views/marketplace/AppMenu.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { faker } from '@faker-js/faker';
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen } from '@testing-library/react';
import React from 'react';
import '@testing-library/jest-dom';

import { mockedAppsContext } from '../../../tests/mocks/client/marketplace';
import { createFakeApp } from '../../../tests/mocks/data';
import AppMenu from './AppMenu';

describe('without app details', () => {
it('should look good', async () => {
const app = createFakeApp();

render(<AppMenu app={app} isAppDetailsPage={false} />, {
wrapper: mockAppRoot()
.withEndpoint('GET', '/apps/count', async () => ({
maxMarketplaceApps: faker.number.int({ min: 0 }),
installedApps: faker.number.int({ min: 0 }),
maxPrivateApps: faker.number.int({ min: 0 }),
totalMarketplaceEnabled: faker.number.int({ min: 0 }),
totalPrivateEnabled: faker.number.int({ min: 0 }),
}))
.wrap(mockedAppsContext)
.build(),
});

expect(screen.getByRole('button', { name: 'More_options' })).toBeInTheDocument();
});
});
48 changes: 48 additions & 0 deletions apps/meteor/client/views/marketplace/AppMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type { App } from '@rocket.chat/core-typings';
import { MenuItem, MenuItemContent, MenuSection, MenuV2, Skeleton } from '@rocket.chat/fuselage';
import { useTranslation } from '@rocket.chat/ui-contexts';
import React, { memo } from 'react';

import { useHandleMenuAction } from '../../components/GenericMenu/hooks/useHandleMenuAction';
import type { AppMenuOption } from './hooks/useAppMenu';
import { useAppMenu } from './hooks/useAppMenu';

type AppMenuProps = {
app: App;
isAppDetailsPage: boolean;
};

const AppMenu = ({ app, isAppDetailsPage }: AppMenuProps) => {
const t = useTranslation();

const { isLoading, isAdminUser, sections } = useAppMenu(app, isAppDetailsPage);

const itemsList = sections.reduce((acc, { items }) => [...acc, ...items], [] as AppMenuOption[]);

const onAction = useHandleMenuAction(itemsList);
const disabledKeys = itemsList.filter((item) => item.disabled).map((item) => item.id);

if (isLoading) {
return <Skeleton variant='rect' height='x28' width='x28' />;
}

if (!isAdminUser && app?.installed && sections.length === 0) {
return null;
}

return (
<MenuV2 title={t('More_options')} onAction={onAction} disabledKeys={disabledKeys} detached>
{sections.map(({ items }, idx) => (
<MenuSection key={idx} items={items}>
{items.map((option) => (
<MenuItem key={option.id}>
<MenuItemContent>{option.content}</MenuItemContent>
</MenuItem>
))}
</MenuSection>
))}
</MenuV2>
);
};

export default memo(AppMenu);
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { Box, Modal } from '@rocket.chat/fuselage';
import { useTranslation } from '@rocket.chat/ui-contexts';
import type { ComponentProps } from 'react';
import React, { useEffect } from 'react';

const iframeMsgListener = (confirm, cancel) => (e) => {
const iframeMsgListener = (confirm: (data: any) => void, cancel: () => void) => (e: MessageEvent<any>) => {
let data;
try {
data = JSON.parse(e.data);
Expand All @@ -13,7 +14,14 @@ const iframeMsgListener = (confirm, cancel) => (e) => {
data.result ? confirm(data) : cancel();
};

const IframeModal = ({ url, confirm, cancel, wrapperHeight = 'x360', ...props }) => {
type IframeModalProps = {
url: string;
confirm: (data: any) => void;
cancel: () => void;
wrapperHeight?: string;
} & ComponentProps<typeof Modal>;

const IframeModal = ({ url, confirm, cancel, wrapperHeight = 'x360', ...props }: IframeModalProps) => {
const t = useTranslation();

useEffect(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import { useTranslation } from '@rocket.chat/ui-contexts';
import React from 'react';

import MarkdownText from '../../../../components/MarkdownText';
import type { MarketplaceRouteContext } from '../../hooks/useAppsCountQuery';

type UninstallGrandfatheredAppModalProps = {
context: 'explore' | 'marketplace' | 'private';
context: MarketplaceRouteContext;
limit: number;
appName: string;
handleUninstall: () => void;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,23 @@ import type { App } from '@rocket.chat/core-typings';
import { useEndpoint, useRouteParameter, useSetModal, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import React, { useCallback } from 'react';

import { AppClientOrchestratorInstance } from '../../../../ee/client/apps/orchestrator';
import { useExternalLink } from '../../../hooks/useExternalLink';
import { useCheckoutUrl } from '../../admin/subscription/hooks/useCheckoutUrl';
import IframeModal from '../IframeModal';
import AppInstallModal from '../components/AppInstallModal/AppInstallModal';
import type { Actions } from '../helpers';
import { handleAPIError } from '../helpers/handleAPIError';
import { isMarketplaceRouteContext, useAppsCountQuery } from './useAppsCountQuery';
import { useAppsOrchestration } from './useAppsOrchestration';
import { useOpenAppPermissionsReviewModal } from './useOpenAppPermissionsReviewModal';
import { useOpenIncompatibleModal } from './useOpenIncompatibleModal';

export type AppInstallationHandlerParams = {
app: App;
action: Actions;
isAppPurchased: boolean;
action: Actions | '';
isAppPurchased?: boolean;
onDismiss: () => void;
onSuccess: (action: Actions, appPermissions?: App['permissions']) => void;
onSuccess: (action: Actions | '', appPermissions?: App['permissions']) => void;
};

export function useAppInstallationHandler({ app, action, isAppPurchased, onDismiss, onSuccess }: AppInstallationHandlerParams) {
Expand Down Expand Up @@ -52,10 +52,16 @@ export function useAppInstallationHandler({ app, action, isAppPurchased, onDismi

const openPermissionModal = useOpenAppPermissionsReviewModal({ app, onCancel: closeModal, onConfirm: success });

const appsOrchestrator = useAppsOrchestration();

if (!appsOrchestrator) {
throw new Error('Apps orchestrator is not available');
}

const acquireApp = useCallback(async () => {
if (action === 'purchase' && !isAppPurchased) {
try {
const data = await AppClientOrchestratorInstance.buildExternalUrl(app.id, app.purchaseType, false);
const data = await appsOrchestrator.buildExternalUrl(app.id, app.purchaseType, false);
setModal(<IframeModal url={data.url} cancel={onDismiss} confirm={openPermissionModal} />);
} catch (error) {
handleAPIError(error);
Expand All @@ -64,7 +70,7 @@ export function useAppInstallationHandler({ app, action, isAppPurchased, onDismi
}

openPermissionModal();
}, [action, isAppPurchased, openPermissionModal, app.id, app.purchaseType, setModal, onDismiss]);
}, [action, isAppPurchased, openPermissionModal, appsOrchestrator, app.id, app.purchaseType, setModal, onDismiss]);

return useCallback(async () => {
if (app?.versionIncompatible) {
Expand All @@ -88,7 +94,7 @@ export function useAppInstallationHandler({ app, action, isAppPurchased, onDismi
};

try {
const data = await AppClientOrchestratorInstance.buildExternalAppRequest(app.id);
const data = await appsOrchestrator.buildExternalAppRequest(app.id);
setModal(<IframeModal url={data.url} wrapperHeight='x460' cancel={onDismiss} confirm={requestConfirmAction} />);
} catch (error) {
handleAPIError(error);
Expand Down Expand Up @@ -120,6 +126,7 @@ export function useAppInstallationHandler({ app, action, isAppPurchased, onDismi
);
}, [
app,
appsOrchestrator,
action,
appCountQuery.data,
setModal,
Expand Down
Loading

0 comments on commit 972b5b8

Please sign in to comment.