From 3b650018e568fe9e1aa5f5663d612e9c493c0445 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Mon, 29 Apr 2024 10:25:22 -0600 Subject: [PATCH 1/4] fix: Server sending 2 notifications when `@all/@here` were used by a user without permissions (#32289) --- .changeset/flat-starfishes-crash.md | 5 ++ .../app/lib/server/methods/sendMessage.ts | 3 +- .../hooks/BeforeSavePreventMention.ts | 13 +--- .../server/services/messages/service.ts | 2 +- .../meteor/tests/e2e/message-mentions.spec.ts | 62 +++++++++++++++++++ 5 files changed, 72 insertions(+), 13 deletions(-) create mode 100644 .changeset/flat-starfishes-crash.md diff --git a/.changeset/flat-starfishes-crash.md b/.changeset/flat-starfishes-crash.md new file mode 100644 index 000000000000..9c5bb2425f19 --- /dev/null +++ b/.changeset/flat-starfishes-crash.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixed a problem in how server was processing errors that was sending 2 ephemeral error messages when @all or @here were used while they were disabled via permissions diff --git a/apps/meteor/app/lib/server/methods/sendMessage.ts b/apps/meteor/app/lib/server/methods/sendMessage.ts index e12ebc2d47e9..5749daa980f3 100644 --- a/apps/meteor/app/lib/server/methods/sendMessage.ts +++ b/apps/meteor/app/lib/server/methods/sendMessage.ts @@ -87,8 +87,9 @@ export async function executeSendMessage(uid: IUser['_id'], message: AtLeast { await expect(poHomeChannel.content.messagePopupUsers.locator('role=listitem >> text="here"')).toBeVisible(); }); + test.describe('Should not allow to send @all mention if permission to do so is disabled', () => { + let targetChannel2: string; + test.beforeAll(async ({ api }) => { + expect((await api.post('/permissions.update', { permissions: [{ '_id': 'mention-all', 'roles': [] }] })).status()).toBe(200); + }); + + test.afterAll(async ({ api }) => { + expect((await api.post('/permissions.update', { permissions: [{ '_id': 'mention-all', 'roles': ['admin', 'owner', 'moderator', 'user'] }] })).status()).toBe(200); + await deleteChannel(api, targetChannel2); + }); + + test('expect to receive an error as notification when sending @all while permission is disabled', async ({ page }) => { + const adminPage = new HomeChannel(page); + + await test.step('create private room', async () => { + targetChannel2 = faker.string.uuid(); + + await poHomeChannel.sidenav.openNewByLabel('Channel'); + await poHomeChannel.sidenav.inputChannelName.type(targetChannel2); + await poHomeChannel.sidenav.btnCreate.click(); + + await expect(page).toHaveURL(`/group/${targetChannel2}`); + }); + await test.step('receive notify message', async () => { + await adminPage.sidenav.openChat(targetChannel2); + await adminPage.content.dispatchSlashCommand('@all'); + await expect(adminPage.content.lastUserMessage).toContainText('Notify all in this room is not allowed'); + }); + }); + }); + + test.describe('Should not allow to send @here mention if permission to do so is disabled', () => { + let targetChannel2: string; + test.beforeAll(async ({ api }) => { + expect((await api.post('/permissions.update', { permissions: [{ '_id': 'mention-here', 'roles': [] }] })).status()).toBe(200); + }); + + test.afterAll(async ({ api }) => { + expect((await api.post('/permissions.update', { permissions: [{ '_id': 'mention-here', 'roles': ['admin', 'owner', 'moderator', 'user'] }] })).status()).toBe(200); + await deleteChannel(api, targetChannel2); + }); + + test('expect to receive an error as notification when sending here while permission is disabled', async ({ page }) => { + const adminPage = new HomeChannel(page); + + await test.step('create private room', async () => { + targetChannel2 = faker.string.uuid(); + + await poHomeChannel.sidenav.openNewByLabel('Channel'); + await poHomeChannel.sidenav.inputChannelName.type(targetChannel2); + await poHomeChannel.sidenav.btnCreate.click(); + + await expect(page).toHaveURL(`/group/${targetChannel2}`); + }); + await test.step('receive notify message', async () => { + await adminPage.sidenav.openChat(targetChannel2); + await adminPage.content.dispatchSlashCommand('@here'); + await expect(adminPage.content.lastUserMessage).toContainText('Notify all in this room is not allowed'); + }); + }); + }); + test.describe('users not in channel', () => { let targetChannel: string; let targetChannel2: string; From bc50dd54a2b2a4c122cad68b50de249e77a915e6 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Mon, 29 Apr 2024 11:33:09 -0600 Subject: [PATCH 2/4] fix: `UserDataFiles` store uploads not proxied through server because of missing setting (#32182) --- .changeset/lazy-gorilas-shop.md | 6 ++++++ apps/meteor/server/settings/file-upload.ts | 21 +++++++++++++++++++++ packages/i18n/src/locales/en.i18n.json | 6 ++++++ 3 files changed, 33 insertions(+) create mode 100644 .changeset/lazy-gorilas-shop.md diff --git a/.changeset/lazy-gorilas-shop.md b/.changeset/lazy-gorilas-shop.md new file mode 100644 index 000000000000..c71610f703fc --- /dev/null +++ b/.changeset/lazy-gorilas-shop.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/i18n": patch +--- + +Fixed an issue with object storage settings that was not allowing admins to decide if files generated via "Export conversation" feature were being proxied through server or not. diff --git a/apps/meteor/server/settings/file-upload.ts b/apps/meteor/server/settings/file-upload.ts index 4be9dfd117a7..76e788cda0e2 100644 --- a/apps/meteor/server/settings/file-upload.ts +++ b/apps/meteor/server/settings/file-upload.ts @@ -187,6 +187,13 @@ export const createFileUploadSettings = () => value: 'AmazonS3', }, }); + await this.add('FileUpload_S3_Proxy_UserDataFiles', false, { + type: 'boolean', + enableQuery: { + _id: 'FileUpload_Storage_Type', + value: 'AmazonS3', + }, + }); }); await this.section('Google Cloud Storage', async function () { @@ -244,6 +251,13 @@ export const createFileUploadSettings = () => value: 'GoogleCloudStorage', }, }); + await this.add('FileUpload_GoogleStorage_Proxy_UserDataFiles', false, { + type: 'boolean', + enableQuery: { + _id: 'FileUpload_Storage_Type', + value: 'GoogleCloudStorage', + }, + }); }); await this.section('File System', async function () { @@ -302,6 +316,13 @@ export const createFileUploadSettings = () => value: 'Webdav', }, }); + await this.add('FileUpload_Webdav_Proxy_UserDataFiles', false, { + type: 'boolean', + enableQuery: { + _id: 'FileUpload_Storage_Type', + value: 'Webdav', + }, + }); }); await this.add('FileUpload_Enabled_Direct', true, { diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 24fb91023e1a..5ac14f5a3b6b 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -2360,6 +2360,8 @@ "FileUpload_GoogleStorage_Proxy_Uploads": "Proxy Uploads", "FileUpload_GoogleStorage_Proxy_Uploads_Description": "Proxy upload file transmissions through your server instead of direct access to the asset's URL", "FileUpload_GoogleStorage_Secret": "Google Storage Secret", + "FileUpload_GoogleStorage_Proxy_UserDataFiles": "Proxy User Data Files", + "FileUpload_GoogleStorage_Proxy_UserDataFiles_Description": "Proxy user data file transmissions through your server instead of direct access to the asset's URL", "FileUpload_GoogleStorage_Secret_Description": "Please follow [these instructions](https://github.com/CulturalMe/meteor-slingshot#google-cloud) and paste the result here.", "FileUpload_json_web_token_secret_for_files": "File Upload Json Web Token Secret", "FileUpload_json_web_token_secret_for_files_description": "File Upload Json Web Token Secret (Used to be able to access uploaded files without authentication)", @@ -2390,6 +2392,8 @@ "FileUpload_S3_Proxy_Avatars_Description": "Proxy avatar file transmissions through your server instead of direct access to the asset's URL", "FileUpload_S3_Proxy_Uploads": "Proxy Uploads", "FileUpload_S3_Proxy_Uploads_Description": "Proxy upload file transmissions through your server instead of direct access to the asset's URL", + "FileUpload_S3_Proxy_UserDataFiles": "Proxy User Data Files", + "FileUpload_S3_Proxy_UserDataFiles_Description": "Proxy user data file transmissions through your server instead of direct access to the asset's URL", "Hold_Call_EE_only": "Hold Call (Enterprise Edition only)", "FileUpload_S3_Region": "Region", "FileUpload_S3_SignatureVersion": "Signature Version", @@ -2401,6 +2405,8 @@ "FileUpload_Webdav_Proxy_Avatars_Description": "Proxy avatar file transmissions through your server instead of direct access to the asset's URL", "FileUpload_Webdav_Proxy_Uploads": "Proxy Uploads", "FileUpload_Webdav_Proxy_Uploads_Description": "Proxy upload file transmissions through your server instead of direct access to the asset's URL", + "FileUpload_Webdav_Proxy_UserDataFiles": "Proxy User Data Files", + "FileUpload_Webdav_Proxy_UserDataFiles_Description": "Proxy user data file transmissions through your server instead of direct access to the asset's URL", "FileUpload_Webdav_Server_URL": "WebDAV Server Access URL", "FileUpload_Webdav_Upload_Folder_Path": "Upload Folder Path", "FileUpload_Webdav_Upload_Folder_Path_Description": "WebDAV folder path which the files should be uploaded to", From 6205ef14f0bb106044dda57f73bd5b28f89cb660 Mon Sep 17 00:00:00 2001 From: Yash Rajpal <58601732+yash-rajpal@users.noreply.github.com> Date: Mon, 29 Apr 2024 23:45:34 +0530 Subject: [PATCH 3/4] fix: Video Conf call joined translation param (#32327) --- .changeset/shiny-crabs-peel.md | 5 +++++ .../src/blocks/VideoConferenceBlock/VideoConferenceBlock.tsx | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/shiny-crabs-peel.md diff --git a/.changeset/shiny-crabs-peel.md b/.changeset/shiny-crabs-peel.md new file mode 100644 index 000000000000..f4d066827bfc --- /dev/null +++ b/.changeset/shiny-crabs-peel.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/fuselage-ui-kit': patch +--- + +Fix translation param on video conf joined message diff --git a/packages/fuselage-ui-kit/src/blocks/VideoConferenceBlock/VideoConferenceBlock.tsx b/packages/fuselage-ui-kit/src/blocks/VideoConferenceBlock/VideoConferenceBlock.tsx index 58980848856d..81cfbe3f88bf 100644 --- a/packages/fuselage-ui-kit/src/blocks/VideoConferenceBlock/VideoConferenceBlock.tsx +++ b/packages/fuselage-ui-kit/src/blocks/VideoConferenceBlock/VideoConferenceBlock.tsx @@ -180,7 +180,7 @@ const VideoConferenceBlock = ({ {data.users.length > MAX_USERS ? t('__usersCount__member_joined', { - usersCount: data.users.length - MAX_USERS, + count: data.users.length - MAX_USERS, }) : t('joined')} From 972b5b851652a63c2b5a7b048e1af24ef1791d0e Mon Sep 17 00:00:00 2001 From: Tiago Evangelista Pinto Date: Tue, 30 Apr 2024 11:11:18 -0300 Subject: [PATCH 4/4] refactor(client): Rewrite `AppMenu` to TypeScript (#31533) --- .../client/components/WarningModal.spec.tsx | 18 + .../meteor/client/components/WarningModal.tsx | 14 +- apps/meteor/client/contexts/AppsContext.tsx | 24 ++ apps/meteor/client/providers/AppsProvider.tsx | 10 +- apps/meteor/client/sidebar/RoomMenu.tsx | 1 - .../tabs/AppStatus/AppStatus.spec.tsx | 32 ++ .../tabs/AppStatus/AppStatus.tsx | 18 +- .../client/views/marketplace/AppMenu.spec.tsx | 30 ++ .../client/views/marketplace/AppMenu.tsx | 48 +++ .../{IframeModal.js => IframeModal.tsx} | 12 +- .../UninstallGrandfatheredAppModal.tsx | 3 +- .../hooks/useAppInstallationHandler.tsx | 21 +- .../{AppMenu.js => hooks/useAppMenu.tsx} | 345 ++++++++++-------- .../marketplace/hooks/useAppsCountQuery.ts | 2 +- .../marketplace/hooks/useAppsOrchestration.ts | 9 + .../hooks/useMarketplaceActions.ts | 57 +++ .../hooks/useOpenIncompatibleModal.tsx | 16 +- .../Info/hooks/actions/useRoomHide.tsx | 1 - .../Info/hooks/actions/useRoomLeave.tsx | 1 - apps/meteor/ee/client/apps/orchestrator.ts | 3 +- .../meteor/tests/mocks/client/marketplace.tsx | 69 ++++ apps/meteor/tests/mocks/data.ts | 141 ++++++- .../src/MockedAppRootBuilder.tsx | 7 +- packages/rest-typings/src/apps/index.ts | 2 +- 24 files changed, 683 insertions(+), 201 deletions(-) create mode 100644 apps/meteor/client/components/WarningModal.spec.tsx create mode 100644 apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppStatus/AppStatus.spec.tsx create mode 100644 apps/meteor/client/views/marketplace/AppMenu.spec.tsx create mode 100644 apps/meteor/client/views/marketplace/AppMenu.tsx rename apps/meteor/client/views/marketplace/{IframeModal.js => IframeModal.tsx} (72%) rename apps/meteor/client/views/marketplace/{AppMenu.js => hooks/useAppMenu.tsx} (55%) create mode 100644 apps/meteor/client/views/marketplace/hooks/useAppsOrchestration.ts create mode 100644 apps/meteor/client/views/marketplace/hooks/useMarketplaceActions.ts create mode 100644 apps/meteor/tests/mocks/client/marketplace.tsx diff --git a/apps/meteor/client/components/WarningModal.spec.tsx b/apps/meteor/client/components/WarningModal.spec.tsx new file mode 100644 index 000000000000..8a3ec4f47f8b --- /dev/null +++ b/apps/meteor/client/components/WarningModal.spec.tsx @@ -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( 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(); +}); diff --git a/apps/meteor/client/components/WarningModal.tsx b/apps/meteor/client/components/WarningModal.tsx index 1f3d3e2814eb..00ae92e1d3bd 100644 --- a/apps/meteor/client/components/WarningModal.tsx +++ b/apps/meteor/client/components/WarningModal.tsx @@ -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; }; const WarningModal = ({ text, confirmText, close, cancel, cancelText, confirm, ...props }: WarningModalProps): ReactElement => { const t = useTranslation(); return ( - + {t('Are_you_sure')} diff --git a/apps/meteor/client/contexts/AppsContext.tsx b/apps/meteor/client/contexts/AppsContext.tsx index b1732b6444b4..1fbf7399d648 100644 --- a/apps/meteor/client/contexts/AppsContext.tsx +++ b/apps/meteor/client/contexts/AppsContext.tsx @@ -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; + getAppClientManager(): AppClientManager; + handleError(error: unknown): void; + getInstalledApps(): Promise; + getAppsFromMarketplace(isAdminUser?: boolean): Promise; + getAppsOnBundle(bundleId: string): Promise; + getApp(appId: string): Promise; + setAppSettings(appId: string, settings: ISetting[]): Promise; + installApp(appId: string, version: string, permissionsGranted?: IPermission[]): Promise; + updateApp(appId: string, version: string, permissionsGranted?: IPermission[]): Promise; + buildExternalUrl(appId: string, purchaseType?: 'buy' | 'subscription', details?: boolean): Promise; + buildExternalAppRequest(appId: string): Promise<{ url: string }>; + buildIncompatibleExternalUrl(appId: string, appVersion: string, action: string): Promise; + getCategories(): Promise>; +} + export type AppsContextValue = { installedApps: Omit, 'error'>; marketplaceApps: Omit, 'error'>; privateApps: Omit, 'error'>; reload: () => Promise; + orchestrator?: IAppsOrchestrator; }; export const AppsContext = createContext({ @@ -25,4 +48,5 @@ export const AppsContext = createContext({ value: undefined, }, reload: () => Promise.resolve(), + orchestrator: undefined, }); diff --git a/apps/meteor/client/providers/AppsProvider.tsx b/apps/meteor/client/providers/AppsProvider.tsx index 61baaefc4a49..e521a05cafdc 100644 --- a/apps/meteor/client/providers/AppsProvider.tsx +++ b/apps/meteor/client/providers/AppsProvider.tsx @@ -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'; @@ -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(); @@ -160,8 +164,10 @@ const AppsProvider: FC = ({ children }) => { reload: async () => { await Promise.all([queryClient.invalidateQueries(['marketplace'])]); }, + orchestrator: AppClientOrchestratorInstance, }} /> ); }; + export default AppsProvider; diff --git a/apps/meteor/client/sidebar/RoomMenu.tsx b/apps/meteor/client/sidebar/RoomMenu.tsx index a1fd7400e88f..da9908b3ed5f 100644 --- a/apps/meteor/client/sidebar/RoomMenu.tsx +++ b/apps/meteor/client/sidebar/RoomMenu.tsx @@ -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} />, diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppStatus/AppStatus.spec.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppStatus/AppStatus.spec.tsx new file mode 100644 index 000000000000..3d9db6163cf4 --- /dev/null +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppStatus/AppStatus.spec.tsx @@ -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(, { + 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(); +}); diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppStatus/AppStatus.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppStatus/AppStatus.tsx index 6c854119028d..cfd93ef59260 100644 --- a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppStatus/AppStatus.tsx +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppStatus/AppStatus.tsx @@ -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 = { @@ -48,18 +48,22 @@ const AppStatus = ({ app, showStatus = true, isAppDetailsPage, installed, ...pro const action = button?.action; + const marketplaceActions = useMarketplaceActions(); + const confirmAction = useCallback( 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(() => { diff --git a/apps/meteor/client/views/marketplace/AppMenu.spec.tsx b/apps/meteor/client/views/marketplace/AppMenu.spec.tsx new file mode 100644 index 000000000000..8eea5386b3b7 --- /dev/null +++ b/apps/meteor/client/views/marketplace/AppMenu.spec.tsx @@ -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(, { + 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(); + }); +}); diff --git a/apps/meteor/client/views/marketplace/AppMenu.tsx b/apps/meteor/client/views/marketplace/AppMenu.tsx new file mode 100644 index 000000000000..be1159ebd4e2 --- /dev/null +++ b/apps/meteor/client/views/marketplace/AppMenu.tsx @@ -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 ; + } + + if (!isAdminUser && app?.installed && sections.length === 0) { + return null; + } + + return ( + + {sections.map(({ items }, idx) => ( + + {items.map((option) => ( + + {option.content} + + ))} + + ))} + + ); +}; + +export default memo(AppMenu); diff --git a/apps/meteor/client/views/marketplace/IframeModal.js b/apps/meteor/client/views/marketplace/IframeModal.tsx similarity index 72% rename from apps/meteor/client/views/marketplace/IframeModal.js rename to apps/meteor/client/views/marketplace/IframeModal.tsx index f69002e527c3..44616c1f6874 100644 --- a/apps/meteor/client/views/marketplace/IframeModal.js +++ b/apps/meteor/client/views/marketplace/IframeModal.tsx @@ -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) => { let data; try { data = JSON.parse(e.data); @@ -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; + +const IframeModal = ({ url, confirm, cancel, wrapperHeight = 'x360', ...props }: IframeModalProps) => { const t = useTranslation(); useEffect(() => { diff --git a/apps/meteor/client/views/marketplace/components/UninstallGrandfatheredAppModal/UninstallGrandfatheredAppModal.tsx b/apps/meteor/client/views/marketplace/components/UninstallGrandfatheredAppModal/UninstallGrandfatheredAppModal.tsx index 0b38a263528b..ce87f0d4673a 100644 --- a/apps/meteor/client/views/marketplace/components/UninstallGrandfatheredAppModal/UninstallGrandfatheredAppModal.tsx +++ b/apps/meteor/client/views/marketplace/components/UninstallGrandfatheredAppModal/UninstallGrandfatheredAppModal.tsx @@ -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; diff --git a/apps/meteor/client/views/marketplace/hooks/useAppInstallationHandler.tsx b/apps/meteor/client/views/marketplace/hooks/useAppInstallationHandler.tsx index eca06002ec57..3c0c41798172 100644 --- a/apps/meteor/client/views/marketplace/hooks/useAppInstallationHandler.tsx +++ b/apps/meteor/client/views/marketplace/hooks/useAppInstallationHandler.tsx @@ -2,7 +2,6 @@ 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'; @@ -10,15 +9,16 @@ 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) { @@ -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(); } catch (error) { handleAPIError(error); @@ -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) { @@ -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(); } catch (error) { handleAPIError(error); @@ -120,6 +126,7 @@ export function useAppInstallationHandler({ app, action, isAppPurchased, onDismi ); }, [ app, + appsOrchestrator, action, appCountQuery.data, setModal, diff --git a/apps/meteor/client/views/marketplace/AppMenu.js b/apps/meteor/client/views/marketplace/hooks/useAppMenu.tsx similarity index 55% rename from apps/meteor/client/views/marketplace/AppMenu.js rename to apps/meteor/client/views/marketplace/hooks/useAppMenu.tsx index f50651ac4f53..25ae9bc0ead8 100644 --- a/apps/meteor/client/views/marketplace/AppMenu.js +++ b/apps/meteor/client/views/marketplace/hooks/useAppMenu.tsx @@ -1,4 +1,6 @@ -import { Box, Icon, Menu, Skeleton } from '@rocket.chat/fuselage'; +import { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus'; +import type { App } from '@rocket.chat/core-typings'; +import { Box, Icon } from '@rocket.chat/fuselage'; import { useSetModal, useEndpoint, @@ -8,74 +10,101 @@ import { usePermission, useRouter, } from '@rocket.chat/ui-contexts'; +import type { MouseEvent, ReactNode } from 'react'; import React, { useMemo, useCallback, useState } from 'react'; import semver from 'semver'; -import WarningModal from '../../components/WarningModal'; -import { useIsEnterprise } from '../../hooks/useIsEnterprise'; -import IframeModal from './IframeModal'; -import UninstallGrandfatheredAppModal from './components/UninstallGrandfatheredAppModal/UninstallGrandfatheredAppModal'; -import { appEnabledStatuses, appButtonProps } from './helpers'; -import { handleAPIError } from './helpers/handleAPIError'; -import { marketplaceActions } from './helpers/marketplaceActions'; -import { warnEnableDisableApp } from './helpers/warnEnableDisableApp'; -import { useAppInstallationHandler } from './hooks/useAppInstallationHandler'; -import { useAppsCountQuery } from './hooks/useAppsCountQuery'; -import { useOpenAppPermissionsReviewModal } from './hooks/useOpenAppPermissionsReviewModal'; -import { useOpenIncompatibleModal } from './hooks/useOpenIncompatibleModal'; - -function AppMenu({ app, isAppDetailsPage, ...props }) { +import WarningModal from '../../../components/WarningModal'; +import { useIsEnterprise } from '../../../hooks/useIsEnterprise'; +import IframeModal from '../IframeModal'; +import UninstallGrandfatheredAppModal from '../components/UninstallGrandfatheredAppModal/UninstallGrandfatheredAppModal'; +import type { Actions } from '../helpers'; +import { appEnabledStatuses, appButtonProps } from '../helpers'; +import { handleAPIError } from '../helpers/handleAPIError'; +import { warnEnableDisableApp } from '../helpers/warnEnableDisableApp'; +import { useAppInstallationHandler } from './useAppInstallationHandler'; +import type { MarketplaceRouteContext } from './useAppsCountQuery'; +import { useAppsCountQuery } from './useAppsCountQuery'; +import { useMarketplaceActions } from './useMarketplaceActions'; +import { useOpenAppPermissionsReviewModal } from './useOpenAppPermissionsReviewModal'; +import { useOpenIncompatibleModal } from './useOpenIncompatibleModal'; + +export type AppMenuOption = { + id: string; + section: number; + content: ReactNode; + disabled?: boolean; + onClick?: (e?: MouseEvent) => void; +}; + +type AppMenuSections = { + items: AppMenuOption[]; +}[]; + +export const useAppMenu = (app: App, isAppDetailsPage: boolean) => { const t = useTranslation(); - const dispatchToastMessage = useToastMessageDispatch(); - const setModal = useSetModal(); const router = useRouter(); + const setModal = useSetModal(); + const dispatchToastMessage = useToastMessageDispatch(); + const openIncompatibleModal = useOpenIncompatibleModal(); - const context = useRouteParameter('context'); + const context = useRouteParameter('context') as MarketplaceRouteContext; const currentTab = useRouteParameter('tab'); + const appCountQuery = useAppsCountQuery(context); - const setAppStatus = useEndpoint('POST', `/apps/${app.id}/status`); - const buildExternalUrl = useEndpoint('GET', '/apps'); - const syncApp = useEndpoint('POST', `/apps/${app.id}/sync`); - const uninstallApp = useEndpoint('DELETE', `/apps/${app.id}`); + const isAdminUser = usePermission('manage-apps'); const { data } = useIsEnterprise(); - const isEnterpriseLicense = !!data?.isEnterprise; - const [loading, setLoading] = useState(false); + const [isLoading, setLoading] = useState(false); const [requestedEndUser, setRequestedEndUser] = useState(app.requestedEndUser); - - const canAppBeSubscribed = app.purchaseType === 'subscription'; - const isSubscribed = app.subscriptionInfo && ['active', 'trialing'].includes(app.subscriptionInfo.status); - const isAppEnabled = appEnabledStatuses.includes(app.status); const [isAppPurchased, setPurchased] = useState(app?.isPurchased); - const isAdminUser = usePermission('manage-apps'); - const appCountQuery = useAppsCountQuery(context); - const openIncompatibleModal = useOpenIncompatibleModal(); - - const button = appButtonProps({ ...app, isAdminUser }); + const button = appButtonProps({ ...app, isAdminUser, endUserRequested: false }); + const buttonLabel = button?.label.replace(' ', '_') as + | 'Update' + | 'Install' + | 'Subscribe' + | 'See_Pricing' + | 'Try_now' + | 'Buy' + | 'Request' + | 'Requested'; const action = button?.action || ''; + const setAppStatus = useEndpoint<'POST', '/apps/:id/status'>('POST', '/apps/:id/status', { id: app.id }); + const buildExternalUrl = useEndpoint('GET', '/apps'); + const syncApp = useEndpoint<'POST', '/apps/:id/sync'>('POST', '/apps/:id/sync', { id: app.id }); + const uninstallApp = useEndpoint<'DELETE', '/apps/:id'>('DELETE', '/apps/:id', { id: app.id }); + + const canAppBeSubscribed = app.purchaseType === 'subscription'; + const isSubscribed = app.subscriptionInfo && ['active', 'trialing'].includes(app.subscriptionInfo.status); + const isAppEnabled = app.status ? appEnabledStatuses.includes(app.status) : false; + const closeModal = useCallback(() => { setModal(null); setLoading(false); }, [setModal, setLoading]); + const marketplaceActions = useMarketplaceActions(); + const installationSuccess = useCallback( - async (action, permissionsGranted) => { - if (action === 'purchase') { - setPurchased(true); - } + async (action: Actions | '', permissionsGranted) => { + if (action) { + if (action === 'purchase') { + setPurchased(true); + } - if (action === 'request') { - setRequestedEndUser(true); - } else { - await marketplaceActions[action]({ ...app, permissionsGranted }); + if (action === 'request') { + setRequestedEndUser(true); + } else { + await marketplaceActions[action]({ ...app, permissionsGranted }); + } } setLoading(false); }, - [app, setLoading], + [app, marketplaceActions, setLoading], ); const openPermissionModal = useOpenAppPermissionsReviewModal({ @@ -105,12 +134,12 @@ function AppMenu({ app, isAppDetailsPage, ...props }) { let data; try { - data = await buildExternalUrl({ + data = (await buildExternalUrl({ buildExternalUrl: 'true', appId: app.id, purchaseType: app.purchaseType, - details: true, - }); + details: 'true', + })) as { url: string }; } catch (error) { handleAPIError(error); return; @@ -144,7 +173,7 @@ function AppMenu({ app, isAppDetailsPage, ...props }) { const confirm = async () => { closeModal(); try { - const { status } = await setAppStatus({ status: 'manually_disabled' }); + const { status } = await setAppStatus({ status: AppStatus.MANUALLY_DISABLED }); warnEnableDisableApp(app.name, status, 'disable'); } catch (error) { handleAPIError(error); @@ -157,7 +186,7 @@ function AppMenu({ app, isAppDetailsPage, ...props }) { const handleEnable = useCallback(async () => { try { - const { status } = await setAppStatus({ status: 'manually_enabled' }); + const { status } = await setAppStatus({ status: AppStatus.MANUALLY_ENABLED }); warnEnableDisableApp(app.name, status, 'enable'); } catch (error) { handleAPIError(error); @@ -276,121 +305,128 @@ function AppMenu({ app, isAppDetailsPage, ...props }) { const canUpdate = app.installed && app.version && app.marketplaceVersion && semver.lt(app.version, app.marketplaceVersion); - const menuOptions = useMemo(() => { - const bothAppStatusOptions = { - ...(canAppBeSubscribed && + const menuSections = useMemo(() => { + const bothAppStatusOptions = [ + canAppBeSubscribed && isSubscribed && isAdminUser && { - subscribe: { - label: ( - <> - - {t('Subscription')} - - ), - action: handleSubscription, - }, - }), - }; - - const nonInstalledAppOptions = { - ...(!app.installed && - button && { - acquire: { - label: ( - <> - {isAdminUser && } - {t(button.label.replace(' ', '_'))} - - ), - action: handleAcquireApp, - disabled: requestedEndUser, - }, - }), - }; + id: 'subscribe', + section: 0, + content: ( + <> + + {t('Subscription')} + + ), + onClick: handleSubscription, + }, + ]; + + const nonInstalledAppOptions = [ + !app.installed && + !!button && { + id: 'acquire', + section: 0, + disabled: requestedEndUser, + content: ( + <> + {isAdminUser && } + {t(buttonLabel)} + + ), + onClick: handleAcquireApp, + }, + ]; const isEnterpriseOrNot = (app.isEnterpriseOnly && isEnterpriseLicense) || !app.isEnterpriseOnly; const isPossibleToEnableApp = app.installed && isAdminUser && !isAppEnabled && isEnterpriseOrNot; const doesItReachedTheLimit = - !app.migrated && !appCountQuery?.data?.hasUnlimitedApps && appCountQuery?.data?.enabled >= appCountQuery?.data?.limit; + !app.migrated && + !appCountQuery?.data?.hasUnlimitedApps && + !!appCountQuery?.data?.enabled && + appCountQuery?.data?.enabled >= appCountQuery?.data?.limit; - const installedAppOptions = { - ...(context !== 'details' && + const installedAppOptions = [ + context !== 'details' && isAdminUser && app.installed && { - viewLogs: { - label: ( - <> - - {t('View_Logs')} - - ), - action: handleViewLogs, - }, - }), - ...(isAdminUser && - canUpdate && + id: 'viewLogs', + section: 0, + content: ( + <> + + {t('View_Logs')} + + ), + onClick: handleViewLogs, + }, + isAdminUser && + !!canUpdate && !isAppDetailsPage && { - update: { - label: ( - <> - - {t('Update')} - - ), - action: handleUpdate, - }, - }), - ...(app.installed && - isAdminUser && - isAppEnabled && { - disable: { - label: ( - - - {t('Disable')} - - ), - action: handleDisable, - }, - }), - ...(isPossibleToEnableApp && { - enable: { - label: ( + id: 'update', + section: 0, + content: ( <> - - {t('Enable')} + + {t('Update')} ), - disabled: doesItReachedTheLimit, - action: handleEnable, + onClick: handleUpdate, }, - }), - ...(app.installed && - isAdminUser && { - divider: { - type: 'divider', - }, - }), - ...(app.installed && + app.installed && + isAdminUser && + isAppEnabled && { + id: 'disable', + section: 0, + content: ( + + + {t('Disable')} + + ), + onClick: handleDisable, + }, + isPossibleToEnableApp && { + id: 'enable', + section: 0, + disabled: doesItReachedTheLimit, + content: ( + <> + + {t('Enable')} + + ), + onClick: handleEnable, + }, + app.installed && isAdminUser && { - uninstall: { - label: ( - - - {t('Uninstall')} - - ), - action: handleUninstall, - }, - }), - }; + id: 'uninstall', + section: 1, + content: ( + + + {t('Uninstall')} + + ), + onClick: handleUninstall, + }, + ]; - return { - ...bothAppStatusOptions, - ...nonInstalledAppOptions, - ...installedAppOptions, - }; + const filtered = [...bothAppStatusOptions, ...nonInstalledAppOptions, ...installedAppOptions].flatMap((value) => + value && typeof value !== 'boolean' ? value : [], + ); + + const sections: AppMenuSections = []; + + filtered.forEach((option) => { + if (typeof sections[option.section] === 'undefined') { + sections[option.section] = { items: [] }; + } + + sections[option.section].items.push(option); + }); + + return sections; }, [ canAppBeSubscribed, isSubscribed, @@ -400,8 +436,9 @@ function AppMenu({ app, isAppDetailsPage, ...props }) { t, handleSubscription, button, - handleAcquireApp, requestedEndUser, + buttonLabel, + handleAcquireApp, isEnterpriseLicense, isAppEnabled, appCountQuery?.data?.hasUnlimitedApps, @@ -417,15 +454,5 @@ function AppMenu({ app, isAppDetailsPage, ...props }) { handleUninstall, ]); - if (loading) { - return ; - } - - if (!isAdminUser && app?.installed) { - return null; - } - - return ; -} - -export default AppMenu; + return { isLoading, isAdminUser, sections: menuSections }; +}; diff --git a/apps/meteor/client/views/marketplace/hooks/useAppsCountQuery.ts b/apps/meteor/client/views/marketplace/hooks/useAppsCountQuery.ts index a571eac3b71f..b23c19a2df40 100644 --- a/apps/meteor/client/views/marketplace/hooks/useAppsCountQuery.ts +++ b/apps/meteor/client/views/marketplace/hooks/useAppsCountQuery.ts @@ -11,7 +11,7 @@ const getProgressBarValues = (numberOfEnabledApps: number, enabledAppsLimit: num percentage: Math.round((numberOfEnabledApps / enabledAppsLimit) * 100), }); -export type MarketplaceRouteContext = 'private' | 'explore' | 'installed' | 'premium' | 'requested'; +export type MarketplaceRouteContext = 'private' | 'explore' | 'installed' | 'premium' | 'requested' | 'details'; export function isMarketplaceRouteContext(context: string): context is MarketplaceRouteContext { return ['private', 'explore', 'installed', 'premium', 'requested'].includes(context); diff --git a/apps/meteor/client/views/marketplace/hooks/useAppsOrchestration.ts b/apps/meteor/client/views/marketplace/hooks/useAppsOrchestration.ts new file mode 100644 index 000000000000..18cd8d3d070a --- /dev/null +++ b/apps/meteor/client/views/marketplace/hooks/useAppsOrchestration.ts @@ -0,0 +1,9 @@ +import { useContext } from 'react'; + +import { AppsContext } from '../../../contexts/AppsContext'; + +export const useAppsOrchestration = () => { + const { orchestrator } = useContext(AppsContext); + + return orchestrator; +}; diff --git a/apps/meteor/client/views/marketplace/hooks/useMarketplaceActions.ts b/apps/meteor/client/views/marketplace/hooks/useMarketplaceActions.ts new file mode 100644 index 000000000000..84882e35de8b --- /dev/null +++ b/apps/meteor/client/views/marketplace/hooks/useMarketplaceActions.ts @@ -0,0 +1,57 @@ +import type { App, AppPermission } from '@rocket.chat/core-typings'; +import { useMutation } from '@tanstack/react-query'; + +import { handleAPIError } from '../helpers/handleAPIError'; +import { warnAppInstall } from '../helpers/warnAppInstall'; +import { warnStatusChange } from '../helpers/warnStatusChange'; +import { useAppsOrchestration } from './useAppsOrchestration'; + +type InstallAppParams = App & { + permissionsGranted?: AppPermission[]; +}; + +type UpdateAppParams = App & { + permissionsGranted?: AppPermission[]; +}; + +export const useMarketplaceActions = () => { + const appsOrchestrator = useAppsOrchestration(); + + if (!appsOrchestrator) { + throw new Error('Apps orchestrator is not available'); + } + + const installAppMutation = useMutation( + ({ id, marketplaceVersion, permissionsGranted }: InstallAppParams) => + appsOrchestrator.installApp(id, marketplaceVersion, permissionsGranted), + { + onSuccess: ({ status }, { name }) => { + if (!status) return; + warnAppInstall(name, status); + }, + onError: (error) => { + handleAPIError(error); + }, + }, + ); + + const updateAppMutation = useMutation( + ({ id, marketplaceVersion, permissionsGranted }: UpdateAppParams) => + appsOrchestrator.updateApp(id, marketplaceVersion, permissionsGranted), + { + onSuccess: ({ status }, { name }) => { + if (!status) return; + warnStatusChange(name, status); + }, + onError: (error) => { + handleAPIError(error); + }, + }, + ); + + return { + purchase: installAppMutation.mutateAsync, + install: installAppMutation.mutateAsync, + update: updateAppMutation.mutateAsync, + } as const; +}; diff --git a/apps/meteor/client/views/marketplace/hooks/useOpenIncompatibleModal.tsx b/apps/meteor/client/views/marketplace/hooks/useOpenIncompatibleModal.tsx index b80bdb9a61a7..1645f47f4432 100644 --- a/apps/meteor/client/views/marketplace/hooks/useOpenIncompatibleModal.tsx +++ b/apps/meteor/client/views/marketplace/hooks/useOpenIncompatibleModal.tsx @@ -1,13 +1,19 @@ import { useSetModal } from '@rocket.chat/ui-contexts'; import React, { useCallback } from 'react'; -import { AppClientOrchestratorInstance } from '../../../../ee/client/apps/orchestrator'; import IframeModal from '../IframeModal'; import { handleAPIError } from '../helpers/handleAPIError'; +import { useAppsOrchestration } from './useAppsOrchestration'; export const useOpenIncompatibleModal = () => { const setModal = useSetModal(); + const appsOrchestrator = useAppsOrchestration(); + + if (!appsOrchestrator) { + throw new Error('Apps orchestrator is not available'); + } + return useCallback( async (app, actionName, cancelAction) => { const handleCancel = () => { @@ -21,16 +27,12 @@ export const useOpenIncompatibleModal = () => { }; try { - const incompatibleData = await AppClientOrchestratorInstance.buildIncompatibleExternalUrl( - app.id, - app.marketplaceVersion, - actionName, - ); + const incompatibleData = await appsOrchestrator.buildIncompatibleExternalUrl(app.id, app.marketplaceVersion, actionName); setModal(); } catch (e) { handleAPIError(e); } }, - [setModal], + [appsOrchestrator, setModal], ); }; diff --git a/apps/meteor/client/views/room/contextualBar/Info/hooks/actions/useRoomHide.tsx b/apps/meteor/client/views/room/contextualBar/Info/hooks/actions/useRoomHide.tsx index fde85f426396..c381359d52c4 100644 --- a/apps/meteor/client/views/room/contextualBar/Info/hooks/actions/useRoomHide.tsx +++ b/apps/meteor/client/views/room/contextualBar/Info/hooks/actions/useRoomHide.tsx @@ -33,7 +33,6 @@ export const useRoomHide = (room: IRoom) => { text={t(warnText as TranslationKey, room.fname || room.name)} confirmText={t('Yes_hide_it')} close={() => setModal(null)} - cancel={() => setModal(null)} cancelText={t('Cancel')} confirm={hide} />, diff --git a/apps/meteor/client/views/room/contextualBar/Info/hooks/actions/useRoomLeave.tsx b/apps/meteor/client/views/room/contextualBar/Info/hooks/actions/useRoomLeave.tsx index 6b842c1fc173..bc7d670ccc1c 100644 --- a/apps/meteor/client/views/room/contextualBar/Info/hooks/actions/useRoomLeave.tsx +++ b/apps/meteor/client/views/room/contextualBar/Info/hooks/actions/useRoomLeave.tsx @@ -38,7 +38,6 @@ export const useRoomLeave = (room: IRoom, joined = true) => { text={t(warnText as TranslationKey, room.fname || room.name)} confirmText={t('Leave_room')} close={() => setModal(null)} - cancel={() => setModal(null)} cancelText={t('Cancel')} confirm={leaveAction} />, diff --git a/apps/meteor/ee/client/apps/orchestrator.ts b/apps/meteor/ee/client/apps/orchestrator.ts index a921c8cb61be..d16be3d0c8c7 100644 --- a/apps/meteor/ee/client/apps/orchestrator.ts +++ b/apps/meteor/ee/client/apps/orchestrator.ts @@ -1,4 +1,5 @@ import { AppClientManager } from '@rocket.chat/apps-engine/client/AppClientManager'; +import type { AppsEngineUIHost } from '@rocket.chat/apps-engine/client/AppsEngineUIHost'; 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'; @@ -11,7 +12,7 @@ import type { IAppExternalURL, ICategory } from './@types/IOrchestrator'; import { RealAppsEngineUIHost } from './RealAppsEngineUIHost'; class AppClientOrchestrator { - private _appClientUIHost: RealAppsEngineUIHost; + private _appClientUIHost: AppsEngineUIHost; private _manager: AppClientManager; diff --git a/apps/meteor/tests/mocks/client/marketplace.tsx b/apps/meteor/tests/mocks/client/marketplace.tsx new file mode 100644 index 000000000000..f0147509cb12 --- /dev/null +++ b/apps/meteor/tests/mocks/client/marketplace.tsx @@ -0,0 +1,69 @@ +import { faker } from '@faker-js/faker'; +import { AppClientManager } from '@rocket.chat/apps-engine/client/AppClientManager'; +import { AppsEngineUIHost } from '@rocket.chat/apps-engine/client/AppsEngineUIHost'; +import type { IExternalComponentRoomInfo } from '@rocket.chat/apps-engine/client/definition'; +import React from 'react'; +import type { ReactNode } from 'react'; + +import { AppsContext, type IAppsOrchestrator } from '../../../client/contexts/AppsContext'; +import { AsyncStatePhase } from '../../../client/lib/asyncState'; +import { createFakeApp, createFakeExternalComponentRoomInfo, createFakeExternalComponentUserInfo } from '../data'; + +class MockedAppsEngineUIHost extends AppsEngineUIHost { + public async getClientRoomInfo(): Promise { + return createFakeExternalComponentRoomInfo(); + } + + public async getClientUserInfo() { + return createFakeExternalComponentUserInfo(); + } +} + +class MockedAppClientManager extends AppClientManager {} + +export const mockAppsOrchestrator = () => { + const appsEngineUIHost = new MockedAppsEngineUIHost(); + const manager = new MockedAppClientManager(appsEngineUIHost); + + const orchestrator: IAppsOrchestrator = { + load: () => Promise.resolve(), + getAppClientManager: () => manager, + handleError: () => undefined, + getInstalledApps: async () => [], + getAppsFromMarketplace: async () => [], + getAppsOnBundle: async () => [], + getApp: () => Promise.reject(new Error('not implemented')), + setAppSettings: async () => undefined, + installApp: () => Promise.reject(new Error('not implemented')), + updateApp: () => Promise.reject(new Error('not implemented')), + buildExternalUrl: () => Promise.reject(new Error('not implemented')), + buildExternalAppRequest: () => Promise.reject(new Error('not implemented')), + buildIncompatibleExternalUrl: () => Promise.reject(new Error('not implemented')), + getCategories: () => Promise.reject(new Error('not implemented')), + }; + + return orchestrator; +}; + +export const mockedAppsContext = (children: ReactNode) => ( + Promise.resolve(), + orchestrator: mockAppsOrchestrator(), + }} + > + {children} + +); diff --git a/apps/meteor/tests/mocks/data.ts b/apps/meteor/tests/mocks/data.ts index 182c7cbede5d..812de9579b4e 100644 --- a/apps/meteor/tests/mocks/data.ts +++ b/apps/meteor/tests/mocks/data.ts @@ -1,5 +1,7 @@ import { faker } from '@faker-js/faker'; -import type { IMessage, IRoom, ISubscription, IUser } from '@rocket.chat/core-typings'; +import type { IExternalComponentRoomInfo, IExternalComponentUserInfo } from '@rocket.chat/apps-engine/client/definition'; +import type { LicenseInfo } from '@rocket.chat/core-typings'; +import { AppSubscriptionStatus, type App, type IMessage, type IRoom, type ISubscription, type IUser } from '@rocket.chat/core-typings'; import { parse } from '@rocket.chat/message-parser'; import type { MessageWithMdEnforced } from '../../client/lib/parseMessageTextToAstMarkdown'; @@ -89,3 +91,140 @@ export function createFakeMessageWithMd(overrides?: Partial = {}): App { + const appId = faker.database.mongodbObjectId(); + + const app: App = { + id: appId, + iconFileData: faker.image.dataUri(), + name: faker.commerce.productName(), + appRequestStats: { + appId: partialApp.id ?? appId, + totalSeen: faker.number.int({ min: 0, max: 100 }), + totalUnseen: faker.number.int({ min: 0, max: 100 }), + }, + author: { + name: faker.company.name(), + homepage: faker.internet.url(), + support: faker.internet.email(), + }, + description: faker.lorem.paragraph(), + shortDescription: faker.lorem.sentence(), + privacyPolicySummary: faker.lorem.sentence(), + detailedDescription: { + raw: faker.lorem.paragraph(), + rendered: faker.lorem.paragraph(), + }, + detailedChangelog: { + raw: faker.lorem.paragraph(), + rendered: faker.lorem.paragraph(), + }, + categories: [], + version: faker.system.semver(), + versionIncompatible: faker.datatype.boolean(), + price: faker.number.float({ min: 0, max: 1000 }), + purchaseType: faker.helpers.arrayElement(['buy', 'subscription']), + pricingPlans: [], + iconFileContent: faker.image.dataUri(), + isSubscribed: faker.datatype.boolean(), + bundledIn: [], + marketplaceVersion: faker.system.semver(), + get latest() { + return app; + }, + subscriptionInfo: { + typeOf: faker.lorem.word(), + status: faker.helpers.enumValue(AppSubscriptionStatus), + statusFromBilling: faker.datatype.boolean(), + isSeatBased: faker.datatype.boolean(), + seats: faker.number.int({ min: 0, max: 50 }), + maxSeats: faker.number.int({ min: 50, max: 100 }), + license: { + license: faker.lorem.word(), + version: faker.number.int({ min: 0, max: 3 }), + expireDate: faker.date.future().toISOString(), + }, + startDate: faker.date.past().toISOString(), + periodEnd: faker.date.future().toISOString(), + endDate: faker.date.future().toISOString(), + isSubscribedViaBundle: faker.datatype.boolean(), + }, + tosLink: faker.internet.url(), + privacyLink: faker.internet.url(), + modifiedAt: faker.date.recent().toISOString(), + permissions: faker.helpers.multiple(() => ({ + name: faker.hacker.verb(), + required: faker.datatype.boolean(), + })), + languages: faker.helpers.multiple(() => faker.location.countryCode()), + createdDate: faker.date.past().toISOString(), + private: faker.datatype.boolean(), + documentationUrl: faker.internet.url(), + migrated: faker.datatype.boolean(), + ...partialApp, + }; + + return app; +} + +export const createFakeExternalComponentUserInfo = (partial: Partial = {}): IExternalComponentUserInfo => ({ + id: faker.database.mongodbObjectId(), + username: faker.internet.userName(), + avatarUrl: faker.image.avatar(), + ...partial, +}); + +export const createFakeExternalComponentRoomInfo = (partial: Partial = {}): IExternalComponentRoomInfo => ({ + id: faker.database.mongodbObjectId(), + members: faker.helpers.multiple(createFakeExternalComponentUserInfo), + slugifiedName: faker.lorem.slug(), + ...partial, +}); + +export const createFakeLicenseInfo = (partial: Partial> = {}): Omit => ({ + activeModules: faker.helpers.arrayElements([ + 'auditing', + 'canned-responses', + 'ldap-enterprise', + 'livechat-enterprise', + 'voip-enterprise', + 'omnichannel-mobile-enterprise', + 'engagement-dashboard', + 'push-privacy', + 'scalability', + 'teams-mention', + 'saml-enterprise', + 'oauth-enterprise', + 'device-management', + 'federation', + 'videoconference-enterprise', + 'message-read-receipt', + 'outlook-calendar', + 'hide-watermark', + 'custom-roles', + 'accessibility-certification', + ]), + preventedActions: { + activeUsers: faker.datatype.boolean(), + guestUsers: faker.datatype.boolean(), + roomsPerGuest: faker.datatype.boolean(), + privateApps: faker.datatype.boolean(), + marketplaceApps: faker.datatype.boolean(), + monthlyActiveContacts: faker.datatype.boolean(), + }, + limits: { + activeUsers: { value: faker.number.int({ min: 0 }), max: faker.number.int({ min: 0 }) }, + guestUsers: { value: faker.number.int({ min: 0 }), max: faker.number.int({ min: 0 }) }, + roomsPerGuest: { value: faker.number.int({ min: 0 }), max: faker.number.int({ min: 0 }) }, + privateApps: { value: faker.number.int({ min: 0 }), max: faker.number.int({ min: 0 }) }, + marketplaceApps: { value: faker.number.int({ min: 0 }), max: faker.number.int({ min: 0 }) }, + monthlyActiveContacts: { value: faker.number.int({ min: 0 }), max: faker.number.int({ min: 0 }) }, + }, + tags: faker.helpers.multiple(() => ({ + name: faker.commerce.productAdjective(), + color: faker.internet.color(), + })), + trial: faker.datatype.boolean(), + ...partial, +}); diff --git a/packages/mock-providers/src/MockedAppRootBuilder.tsx b/packages/mock-providers/src/MockedAppRootBuilder.tsx index 4ba47345efc9..a3b2ca725f47 100644 --- a/packages/mock-providers/src/MockedAppRootBuilder.tsx +++ b/packages/mock-providers/src/MockedAppRootBuilder.tsx @@ -40,13 +40,16 @@ export class MockedAppRootBuilder { private server: ContextType = { absoluteUrl: (path: string) => `http://localhost:3000/${path}`, - callEndpoint: (_args: { + callEndpoint: ({ + method, + pathPattern, + }: { method: TMethod; pathPattern: TPathPattern; keys: UrlParams; params: OperationParams; }): Promise>> => { - throw new Error('not implemented'); + throw new Error(`not implemented (method: ${method}, pathPattern: ${pathPattern})`); }, getStream: () => () => () => undefined, uploadToEndpoint: () => Promise.reject(new Error('not implemented')), diff --git a/packages/rest-typings/src/apps/index.ts b/packages/rest-typings/src/apps/index.ts index b4362d87ba41..f037f6b59c74 100644 --- a/packages/rest-typings/src/apps/index.ts +++ b/packages/rest-typings/src/apps/index.ts @@ -124,7 +124,7 @@ export type AppsEndpoints = { status: string; }; POST: (params: { status: AppStatus }) => { - status: string; + status: AppStatus; }; };