diff --git a/apps/meteor/app/apps/client/orchestrator.ts b/apps/meteor/app/apps/client/orchestrator.ts index 3bc2cf8bc02..6e3591de0be 100644 --- a/apps/meteor/app/apps/client/orchestrator.ts +++ b/apps/meteor/app/apps/client/orchestrator.ts @@ -216,6 +216,21 @@ class AppClientOrchestrator { if ('url' in result) { return result; } + + throw new Error('Failed to build external url'); + } + + public async buildIncompatibleExternalUrl(appId: string, appVersion: string, action: string): Promise { + const result = await APIClient.get('/apps/incompatibleModal', { + appId, + appVersion, + action, + }); + + if ('url' in result) { + return result; + } + throw new Error('Failed to build external url'); } diff --git a/apps/meteor/app/apps/server/communication/rest.js b/apps/meteor/app/apps/server/communication/rest.js index b623952f779..1dd088b5cd4 100644 --- a/apps/meteor/app/apps/server/communication/rest.js +++ b/apps/meteor/app/apps/server/communication/rest.js @@ -13,6 +13,7 @@ import { formatAppInstanceForRest } from '../../lib/misc/formatAppInstanceForRes import { actionButtonsHandler } from './endpoints/actionButtonsHandler'; import { fetch } from '../../../../server/lib/http/fetch'; +const rocketChatVersion = Info.version; const appsEngineVersionForMarketplace = Info.marketplaceApiVersion.replace(/-.*/g, ''); const getDefaultHeaders = () => ({ 'X-Apps-Engine-Version': appsEngineVersionForMarketplace, @@ -65,6 +66,22 @@ export class AppsRestApi { this.api.addRoute('actionButtons', ...actionButtonsHandler(this)); + this.api.addRoute( + 'incompatibleModal', + { authRequired: true }, + { + async get() { + const baseUrl = orchestrator.getMarketplaceUrl(); + const workspaceId = settings.get('Cloud_Workspace_Id'); + const { action, appId, appVersion } = this.queryParams; + + return API.v1.success({ + url: `${baseUrl}/apps/${appId}/incompatible/${appVersion}/${action}?workspaceId=${workspaceId}&rocketChatVersion=${rocketChatVersion}`, + }); + }, + }, + ); + // WE NEED TO MOVE EACH ENDPOINT HANDLER TO IT'S OWN FILE this.api.addRoute( '', diff --git a/apps/meteor/client/views/admin/apps/AppDetailsPage/AppDetailsPageHeader.tsx b/apps/meteor/client/views/admin/apps/AppDetailsPage/AppDetailsPageHeader.tsx index 67417d167fb..14825e5bcbb 100644 --- a/apps/meteor/client/views/admin/apps/AppDetailsPage/AppDetailsPageHeader.tsx +++ b/apps/meteor/client/views/admin/apps/AppDetailsPage/AppDetailsPageHeader.tsx @@ -1,5 +1,5 @@ import type { App } from '@rocket.chat/core-typings'; -import { Box } from '@rocket.chat/fuselage'; +import { Box, Tag } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; import moment from 'moment'; import React, { ReactElement } from 'react'; @@ -7,12 +7,23 @@ import React, { ReactElement } from 'react'; import AppAvatar from '../../../../components/avatar/AppAvatar'; import AppMenu from '../AppMenu'; import BundleChips from '../BundleChips'; +import { appIncompatibleStatusProps } from '../helpers'; import AppStatus from './tabs/AppStatus'; +const versioni18nKey = (app: App): string => { + const { version, marketplaceVersion, marketplace } = app; + if (typeof marketplace === 'boolean') { + return marketplaceVersion; + } + + return version; +}; + const AppDetailsPageHeader = ({ app }: { app: App }): ReactElement => { const t = useTranslation(); - const { iconFileData, name, author, version, iconFileContent, installed, isSubscribed, modifiedAt, bundledIn } = app; + const { iconFileData, name, author, iconFileContent, installed, modifiedAt, bundledIn, versionIncompatible, isSubscribed } = app; const lastUpdated = modifiedAt && moment(modifiedAt).fromNow(); + const incompatibleStatus = versionIncompatible ? appIncompatibleStatusProps() : undefined; return ( @@ -25,6 +36,7 @@ const AppDetailsPageHeader = ({ app }: { app: App }): ReactElement => { {bundledIn && Boolean(bundledIn.length) && } {app?.shortDescription && {app.shortDescription}} + {(installed || isSubscribed) && } @@ -34,11 +46,26 @@ const AppDetailsPageHeader = ({ app }: { app: App }): ReactElement => { {t('By_author', { author: author?.name })} | - {t('Version_version', { version })} + + + {t('Version_version', { version: versioni18nKey(app) })} + + + {versionIncompatible && ( + + + {incompatibleStatus?.label} + + + )} + {lastUpdated && ( <> | - + {t('Marketplace_app_last_updated', { lastUpdated, })} diff --git a/apps/meteor/client/views/admin/apps/AppDetailsPage/tabs/AppStatus/AppStatus.js b/apps/meteor/client/views/admin/apps/AppDetailsPage/tabs/AppStatus/AppStatus.js deleted file mode 100644 index 7f806669b44..00000000000 --- a/apps/meteor/client/views/admin/apps/AppDetailsPage/tabs/AppStatus/AppStatus.js +++ /dev/null @@ -1,136 +0,0 @@ -import { Box, Button, Icon, Throbber, Tag } from '@rocket.chat/fuselage'; -import { useSafely } from '@rocket.chat/fuselage-hooks'; -import { useSetModal, useMethod, useTranslation } from '@rocket.chat/ui-contexts'; -import React, { useCallback, useState, memo } from 'react'; - -import { Apps } from '../../../../../../../app/apps/client/orchestrator'; -import AppPermissionsReviewModal from '../../../AppPermissionsReviewModal'; -import CloudLoginModal from '../../../CloudLoginModal'; -import IframeModal from '../../../IframeModal'; -import { appButtonProps, appStatusSpanProps, handleAPIError, handleInstallError } from '../../../helpers'; -import { marketplaceActions } from '../../../helpers/marketplaceActions'; -import AppStatusPriceDisplay from './AppStatusPriceDisplay'; - -const AppStatus = ({ app, showStatus = true, isAppDetailsPage, installed, ...props }) => { - const t = useTranslation(); - const [loading, setLoading] = useSafely(useState()); - const [isAppPurchased, setPurchased] = useSafely(useState(app?.isPurchased)); - const setModal = useSetModal(); - - const { price, purchaseType, pricingPlans } = app; - - const button = appButtonProps(app || {}); - const status = !button && appStatusSpanProps(app); - - const action = button?.action || ''; - const confirmAction = useCallback( - (permissionsGranted) => { - setModal(null); - - marketplaceActions[action]({ ...app, permissionsGranted }).then(() => { - setLoading(false); - }); - }, - [setModal, action, app, setLoading], - ); - - const cancelAction = useCallback(() => { - setLoading(false); - setModal(null); - }, [setLoading, setModal]); - - const showAppPermissionsReviewModal = () => { - if (!isAppPurchased) { - setPurchased(true); - } - - if (!app.permissions || app.permissions.length === 0) { - return confirmAction(app.permissions); - } - - if (!Array.isArray(app.permissions)) { - handleInstallError(new Error('The "permissions" property from the app manifest is invalid')); - } - - return setModal(); - }; - - const checkUserLoggedIn = useMethod('cloud:checkUserLoggedIn'); - - const handleClick = async (e) => { - e.preventDefault(); - e.stopPropagation(); - - setLoading(true); - - const isLoggedIn = await checkUserLoggedIn(); - - if (!isLoggedIn) { - setLoading(false); - setModal(); - return; - } - - if (action === 'purchase' && !isAppPurchased) { - try { - const data = await Apps.buildExternalUrl(app.id, app.purchaseType, false); - setModal(); - } catch (error) { - handleAPIError(error); - } - return; - } - - showAppPermissionsReviewModal(); - }; - - const shouldShowPriceDisplay = isAppDetailsPage && button && button.action !== 'update'; - - return ( - - {button && isAppDetailsPage && ( - - - {shouldShowPriceDisplay && !installed && ( - - - - )} - - )} - {status && ( - <> - {status.label} - - )} - - ); -}; - -export default memo(AppStatus); diff --git a/apps/meteor/client/views/admin/apps/AppDetailsPage/tabs/AppStatus/AppStatus.tsx b/apps/meteor/client/views/admin/apps/AppDetailsPage/tabs/AppStatus/AppStatus.tsx new file mode 100644 index 00000000000..e478aa4a227 --- /dev/null +++ b/apps/meteor/client/views/admin/apps/AppDetailsPage/tabs/AppStatus/AppStatus.tsx @@ -0,0 +1,171 @@ +import type { App } from '@rocket.chat/core-typings'; +import { Box, Button, Icon, Throbber, Tag, Margins } from '@rocket.chat/fuselage'; +import { useSafely } from '@rocket.chat/fuselage-hooks'; +import { useSetModal, useMethod, useTranslation, TranslationKey } from '@rocket.chat/ui-contexts'; +import React, { useCallback, useState, memo, ReactElement, Fragment } from 'react'; + +import { Apps } from '../../../../../../../app/apps/client/orchestrator'; +import AppPermissionsReviewModal from '../../../AppPermissionsReviewModal'; +import CloudLoginModal from '../../../CloudLoginModal'; +import IframeModal from '../../../IframeModal'; +import { appButtonProps, appMultiStatusProps, handleAPIError, handleInstallError } from '../../../helpers'; +import { marketplaceActions } from '../../../helpers/marketplaceActions'; +import AppStatusPriceDisplay from './AppStatusPriceDisplay'; + +type AppStatusProps = { + app: App; + showStatus?: boolean; + isAppDetailsPage: boolean; + installed?: boolean; +}; + +const AppStatus = ({ app, showStatus = true, isAppDetailsPage, installed, ...props }: AppStatusProps): ReactElement => { + const t = useTranslation(); + const [loading, setLoading] = useSafely(useState(false)); + const [isAppPurchased, setPurchased] = useSafely(useState(app?.isPurchased)); + const setModal = useSetModal(); + const { price, purchaseType, pricingPlans } = app; + const button = appButtonProps(app || {}); + const statuses = appMultiStatusProps(app, isAppDetailsPage); + + if (button?.action === undefined && button?.action) { + throw new Error('action must not be null'); + } + + const action = button?.action; + const confirmAction = useCallback( + (permissionsGranted) => { + setModal(null); + + if (action === undefined) { + setLoading(false); + return; + } + + marketplaceActions[action]({ ...app, permissionsGranted }).then(() => { + setLoading(false); + }); + }, + [setModal, action, app, setLoading], + ); + + const cancelAction = useCallback(() => { + setLoading(false); + setModal(null); + }, [setLoading, setModal]); + + const showAppPermissionsReviewModal = (): void => { + if (!isAppPurchased) { + setPurchased(true); + } + + if (!app.permissions || app.permissions.length === 0) { + return confirmAction(app.permissions); + } + + if (!Array.isArray(app.permissions)) { + handleInstallError(new Error('The "permissions" property from the app manifest is invalid')); + } + + return setModal(); + }; + + const openIncompatibleModal = async (app: App, action: string, cancel: () => void): Promise => { + try { + const incompatibleData = await Apps.buildIncompatibleExternalUrl(app.id, app.marketplaceVersion, action); + setModal(); + } catch (e: any) { + handleAPIError(e); + } + }; + + const openPurchaseModal = async (app: App): Promise => { + try { + const data = await Apps.buildExternalUrl(app.id, app.purchaseType, false); + setModal(); + } catch (error) { + handleAPIError(error); + } + }; + + const checkUserLoggedIn = useMethod('cloud:checkUserLoggedIn'); + + const handleClick = async (e: React.MouseEvent): Promise => { + e.preventDefault(); + e.stopPropagation(); + + setLoading(true); + + const isLoggedIn = await checkUserLoggedIn(); + + if (!isLoggedIn) { + setLoading(false); + setModal(); + return; + } + + if (app.versionIncompatible && action !== undefined) { + openIncompatibleModal(app, action, cancelAction); + return; + } + + if (action !== undefined && action === 'purchase' && !isAppPurchased) { + openPurchaseModal(app); + return; + } + + showAppPermissionsReviewModal(); + }; + + const shouldShowPriceDisplay = isAppDetailsPage && button; + + return ( + + {button && isAppDetailsPage && ( + + + + {shouldShowPriceDisplay && !installed && ( + + + + )} + + )} + + {statuses?.map((status, index) => ( + + + {status.tooltipText ? ( + + {status.label} + + ) : ( + + {status.label} + + )} + + + ))} + + ); +}; + +export default memo(AppStatus); diff --git a/apps/meteor/client/views/admin/apps/AppDetailsPage/tabs/AppStatus/AppStatusPriceDisplay.tsx b/apps/meteor/client/views/admin/apps/AppDetailsPage/tabs/AppStatus/AppStatusPriceDisplay.tsx index fae7f7a31ee..5645cf4856f 100644 --- a/apps/meteor/client/views/admin/apps/AppDetailsPage/tabs/AppStatus/AppStatusPriceDisplay.tsx +++ b/apps/meteor/client/views/admin/apps/AppDetailsPage/tabs/AppStatus/AppStatusPriceDisplay.tsx @@ -1,4 +1,4 @@ -import type { AppPricingPlan } from '@rocket.chat/core-typings'; +import type { AppPricingPlan, PurchaseType } from '@rocket.chat/core-typings'; import { Box, Tag } from '@rocket.chat/fuselage'; import { TranslationKey, useTranslation } from '@rocket.chat/ui-contexts'; import React, { FC, useMemo } from 'react'; @@ -6,7 +6,7 @@ import React, { FC, useMemo } from 'react'; import { formatPriceAndPurchaseType } from '../../../helpers'; type AppStatusPriceDisplayProps = { - purchaseType: string; + purchaseType: PurchaseType; pricingPlans: AppPricingPlan[]; price: number; showType?: boolean; diff --git a/apps/meteor/client/views/admin/apps/AppMenu.js b/apps/meteor/client/views/admin/apps/AppMenu.js index 5d61b575288..b2119307b40 100644 --- a/apps/meteor/client/views/admin/apps/AppMenu.js +++ b/apps/meteor/client/views/admin/apps/AppMenu.js @@ -20,6 +20,15 @@ import IframeModal from './IframeModal'; import { appEnabledStatuses, handleAPIError, appButtonProps, handleInstallError, warnEnableDisableApp } from './helpers'; import { marketplaceActions } from './helpers/marketplaceActions'; +const openIncompatibleModal = async (app, action, cancel, setModal) => { + try { + const incompatibleData = await Apps.buildIncompatibleExternalUrl(app.id, app.marketplaceVersion, action); + setModal(); + } catch (e) { + handleAPIError(e); + } +}; + function AppMenu({ app, isAppDetailsPage, ...props }) { const t = useTranslation(); const dispatchToastMessage = useToastMessageDispatch(); @@ -80,6 +89,7 @@ function AppMenu({ app, isAppDetailsPage, ...props }) { const closeModal = useCallback(() => { setModal(null); + setLoading(false); }, [setModal]); const handleSubscription = useCallback(async () => { @@ -88,6 +98,11 @@ function AppMenu({ app, isAppDetailsPage, ...props }) { return; } + if (app?.versionIncompatible && !isSubscribed) { + openIncompatibleModal(app, 'subscribe', closeModal, setModal); + return; + } + let data; try { data = await buildExternalUrl({ @@ -110,7 +125,7 @@ function AppMenu({ app, isAppDetailsPage, ...props }) { }; setModal(); - }, [checkUserLoggedIn, setModal, closeModal, buildExternalUrl, app.id, app.purchaseType, syncApp]); + }, [checkUserLoggedIn, app, setModal, closeModal, isSubscribed, buildExternalUrl, syncApp]); const handleAcquireApp = useCallback(async () => { setLoading(true); @@ -123,6 +138,11 @@ function AppMenu({ app, isAppDetailsPage, ...props }) { return; } + if (app?.versionIncompatible) { + openIncompatibleModal(app, 'subscribe', closeModal, setModal); + return; + } + if (action === 'purchase' && !isAppPurchased) { try { const data = await Apps.buildExternalUrl(app.id, app.purchaseType, false); @@ -134,7 +154,7 @@ function AppMenu({ app, isAppDetailsPage, ...props }) { } showAppPermissionsReviewModal(); - }, [action, app.id, app.purchaseType, cancelAction, checkUserLoggedIn, isAppPurchased, setModal, showAppPermissionsReviewModal]); + }, [action, app, closeModal, cancelAction, checkUserLoggedIn, isAppPurchased, setModal, showAppPermissionsReviewModal]); const handleViewLogs = useCallback(() => { router.push({ context, page: 'info', id: app.id, version: app.version, tab: 'logs' }); @@ -215,9 +235,38 @@ function AppMenu({ app, isAppDetailsPage, ...props }) { uninstallApp, ]); + const incompatibleIconName = useCallback( + (app, action) => { + if (!app.versionIncompatible) { + if (action === 'update') { + return 'refresh'; + } + + return 'card'; + } + + // Now we are handling an incompatible app + if (action === 'subscribe' && !isSubscribed) { + return 'warning'; + } + + if (action === 'install' || action === 'update') { + return 'warning'; + } + + return 'card'; + }, + [isSubscribed], + ); + const handleUpdate = useCallback(async () => { setLoading(true); + if (app?.versionIncompatible) { + openIncompatibleModal(app, 'update', closeModal, setModal); + return; + } + const isLoggedIn = await checkUserLoggedIn(); if (!isLoggedIn) { @@ -227,7 +276,7 @@ function AppMenu({ app, isAppDetailsPage, ...props }) { } showAppPermissionsReviewModal(); - }, [checkUserLoggedIn, setModal, showAppPermissionsReviewModal]); + }, [checkUserLoggedIn, app, closeModal, setModal, showAppPermissionsReviewModal]); const canUpdate = app.installed && app.version && app.marketplaceVersion && semver.lt(app.version, app.marketplaceVersion); @@ -238,7 +287,7 @@ function AppMenu({ app, isAppDetailsPage, ...props }) { subscribe: { label: ( - + {t('Subscription')} ), @@ -250,7 +299,12 @@ function AppMenu({ app, isAppDetailsPage, ...props }) { const nonInstalledAppOptions = { ...(!app.installed && { acquire: { - label: {t(button.label.replace(' ', '_'))}, + label: ( + + + {t(button.label.replace(' ', '_'))} + + ), action: handleAcquireApp, }, }), @@ -274,7 +328,7 @@ function AppMenu({ app, isAppDetailsPage, ...props }) { update: { label: ( - + {t('Update')} ), @@ -331,9 +385,9 @@ function AppMenu({ app, isAppDetailsPage, ...props }) { }, [ canAppBeSubscribed, isSubscribed, + app, t, handleSubscription, - app?.installed, button?.label, handleAcquireApp, context, @@ -345,9 +399,10 @@ function AppMenu({ app, isAppDetailsPage, ...props }) { handleDisable, handleEnable, handleUninstall, + incompatibleIconName, ]); - return loading ? : ; + return loading ? : ; } export default AppMenu; diff --git a/apps/meteor/client/views/admin/apps/AppsList/AppRow.tsx b/apps/meteor/client/views/admin/apps/AppsList/AppRow.tsx index a599d4955ef..f667d3c9f70 100644 --- a/apps/meteor/client/views/admin/apps/AppsList/AppRow.tsx +++ b/apps/meteor/client/views/admin/apps/AppsList/AppRow.tsx @@ -90,9 +90,10 @@ const AppRow = (props: AppRowProps): ReactElement => { {shortDescription && {shortDescription}} + {canUpdate && } - + diff --git a/apps/meteor/client/views/admin/apps/BundleChips.tsx b/apps/meteor/client/views/admin/apps/BundleChips.tsx index 7150893f802..cf231e16f9e 100644 --- a/apps/meteor/client/views/admin/apps/BundleChips.tsx +++ b/apps/meteor/client/views/admin/apps/BundleChips.tsx @@ -1,6 +1,6 @@ -import { Box, PositionAnimated, AnimatedVisibility, Tooltip, Tag } from '@rocket.chat/fuselage'; +import { Tag } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; -import React, { RefObject, useRef, useState, ReactElement, Fragment } from 'react'; +import React, { ReactElement } from 'react'; import { App } from './types'; @@ -15,29 +15,18 @@ type BundleChipsProps = { const BundleChips = ({ bundledIn }: BundleChipsProps): ReactElement => { const t = useTranslation(); - const bundleRef = useRef(); - const [isHovered, setIsHovered] = useState(false); - return ( <> - {bundledIn.map((bundle) => ( - - setIsHovered(true)} onMouseLeave={(): void => setIsHovered(false)}> - {bundle.bundleName} - - } - placement='top-middle' - margin={8} - visible={isHovered ? AnimatedVisibility.VISIBLE : AnimatedVisibility.HIDDEN} - > - - {t('this_app_is_included_with_subscription', { - bundleName: bundle.bundleName, - })} - - - + {bundledIn.map((bundle, index) => ( + + {bundle.bundleName} + ))} ); diff --git a/apps/meteor/client/views/admin/apps/helpers.ts b/apps/meteor/client/views/admin/apps/helpers.ts index db3a53a1397..6e2e5e97030 100644 --- a/apps/meteor/client/views/admin/apps/helpers.ts +++ b/apps/meteor/client/views/admin/apps/helpers.ts @@ -1,6 +1,6 @@ import { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus'; import { IApiEndpointMetadata } from '@rocket.chat/apps-engine/definition/api'; -import { App, AppPricingPlan } from '@rocket.chat/core-typings'; +import { App, AppPricingPlan, PurchaseType } from '@rocket.chat/core-typings'; import semver from 'semver'; import { Utilities } from '../../../../app/apps/lib/misc/Utilities'; @@ -9,6 +9,17 @@ import { dispatchToastMessage } from '../../../lib/toast'; export const appEnabledStatuses = [AppStatus.AUTO_ENABLED, AppStatus.MANUALLY_ENABLED]; +interface ApiError { + xhr: { + responseJSON: { + error: string; + status: string; + messages: string[]; + payload?: any; + }; + }; +} + const appErroredStatuses = [ AppStatus.COMPILER_ERROR_DISABLED, AppStatus.ERROR_DISABLED, @@ -16,16 +27,19 @@ const appErroredStatuses = [ AppStatus.INVALID_LICENSE_DISABLED, ]; +type Actions = 'update' | 'install' | 'purchase'; + type appButtonResponseProps = { - action: 'update' | 'install' | 'purchase'; - icon?: 'reload'; + action: Actions; + icon?: 'reload' | 'warning'; label: 'Update' | 'Install' | 'Subscribe' | 'See Pricing' | 'Try now' | 'Buy'; }; type appStatusSpanResponseProps = { type?: 'failed' | 'warning'; icon: 'warning' | 'ban' | 'checkmark-circled' | 'check'; - label: 'Config Needed' | 'Failed' | 'Disabled' | 'Trial period' | 'Installed'; + label: 'Config Needed' | 'Failed' | 'Disabled' | 'Trial period' | 'Installed' | 'Incompatible'; + tooltipText?: string; }; type PlanType = 'Subscription' | 'Paid' | 'Free'; @@ -50,7 +64,12 @@ export const apiCurlGetter = }).split('\n'); }; -export function handleInstallError(apiError: { xhr: { responseJSON: { status: any; messages: any; error: any; payload?: any } } }): void { +export function handleInstallError(apiError: ApiError | Error): void { + if (apiError instanceof Error) { + dispatchToastMessage({ type: 'error', message: apiError.message }); + return; + } + if (!apiError.xhr || !apiError.xhr.responseJSON) { return; } @@ -141,9 +160,18 @@ export const appButtonProps = ({ subscriptionInfo, pricingPlans, isEnterpriseOnly, + versionIncompatible, }: App): appButtonResponseProps | undefined => { const canUpdate = installed && version && marketplaceVersion && semver.lt(version, marketplaceVersion); if (canUpdate) { + if (versionIncompatible) { + return { + action: 'update', + icon: 'warning', + label: 'Update', + }; + } + return { action: 'update', icon: 'reload', @@ -157,6 +185,14 @@ export const appButtonProps = ({ const canDownload = isPurchased; if (canDownload) { + if (versionIncompatible) { + return { + action: 'install', + icon: 'warning', + label: 'Install', + }; + } + return { action: 'install', label: 'Install', @@ -168,6 +204,14 @@ export const appButtonProps = ({ const cannotTry = pricingPlans.every((currentPricingPlan) => currentPricingPlan.trialDays === 0); const isTierBased = pricingPlans.every((currentPricingPlan) => currentPricingPlan.tiers && currentPricingPlan.tiers.length > 0); + if (versionIncompatible) { + return { + action: 'purchase', + label: 'Subscribe', + icon: 'warning', + }; + } + if (cannotTry || isEnterpriseOnly) { return { action: 'purchase', @@ -190,23 +234,44 @@ export const appButtonProps = ({ const canBuy = price > 0; if (canBuy) { + if (versionIncompatible) { + return { + action: 'purchase', + label: 'Buy', + icon: 'warning', + }; + } + return { action: 'purchase', label: 'Buy', }; } + if (versionIncompatible) { + return { + action: 'purchase', + label: 'Install', + icon: 'warning', + }; + } + return { action: 'purchase', label: 'Install', }; }; +export const appIncompatibleStatusProps = (): appStatusSpanResponseProps => ({ + icon: 'check', + label: 'Incompatible', + tooltipText: t('App_version_incompatible_tooltip'), +}); + export const appStatusSpanProps = ({ installed, status, subscriptionInfo }: App): appStatusSpanResponseProps | undefined => { if (!installed) { return; } - const isFailed = status && appErroredStatuses.includes(status); if (isFailed) { return { @@ -239,6 +304,21 @@ export const appStatusSpanProps = ({ installed, status, subscriptionInfo }: App) }; }; +export const appMultiStatusProps = (app: App, isAppDetailsPage: boolean): appStatusSpanResponseProps[] => { + const status = appStatusSpanProps(app); + const statuses = []; + + if (app?.versionIncompatible !== undefined && !isAppDetailsPage) { + statuses.push(appIncompatibleStatusProps()); + } + + if (status) { + statuses.push(status); + } + + return statuses; +}; + export const formatPrice = (price: number): string => `\$${price.toFixed(2)}`; export const formatPricingPlan = ({ strategy, price, tiers = [], trialDays }: AppPricingPlan): string => { @@ -260,7 +340,11 @@ export const formatPricingPlan = ({ strategy, price, tiers = [], trialDays }: Ap }); }; -export const formatPriceAndPurchaseType = (purchaseType: string, pricingPlans: AppPricingPlan[], price: number): FormattedPriceAndPlan => { +export const formatPriceAndPurchaseType = ( + purchaseType: PurchaseType, + pricingPlans: AppPricingPlan[], + price: number, +): FormattedPriceAndPlan => { if (purchaseType === 'subscription') { const type = 'Subscription'; if (!pricingPlans || !Array.isArray(pricingPlans) || pricingPlans.length === 0) { diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json index 1b1d4fe0fb0..25c6255f1f8 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json @@ -5098,6 +5098,7 @@ "Verify_your_email_for_the_code_we_sent": "Verify your email for the code we sent", "Version": "Version", "Version_version": "Version __version__", + "App_version_incompatible_tooltip": "App incompatible with Rocket.Chat version", "Video_Conference_Description": "Configure conferencing calls for your workspace.", "Video_Chat_Window": "Video Chat", "Video_Conference": "Conference Call", diff --git a/apps/meteor/tests/unit/client/views/admin/apps/helpers/filterAppsByFree.test.ts b/apps/meteor/tests/unit/client/views/admin/apps/helpers/filterAppsByFree.test.ts index 4f1453d93fc..eed2b57523a 100644 --- a/apps/meteor/tests/unit/client/views/admin/apps/helpers/filterAppsByFree.test.ts +++ b/apps/meteor/tests/unit/client/views/admin/apps/helpers/filterAppsByFree.test.ts @@ -1,36 +1,41 @@ /* eslint-env mocha */ +import type { PurchaseType } from '@rocket.chat/core-typings'; import { expect } from 'chai'; import { filterAppsByFree } from '../../../../../../../client/views/admin/apps/helpers/filterAppsByFree'; describe('filterAppsByFree', () => { it('should return true if app purchase type is buy and price does not exist or is 0', () => { + const purchaseType: PurchaseType = 'buy'; const app = { - purchaseType: 'buy', + purchaseType, price: 0, }; const result = filterAppsByFree(app); expect(result).to.be.true; }); it('should return false if app purchase type is not buy', () => { + const purchaseType: PurchaseType = 'subscription'; const app = { - purchaseType: 'subscription', + purchaseType, price: 0, }; const result = filterAppsByFree(app); expect(result).to.be.false; }); it('should return false if app price exists and is different than 0', () => { + const purchaseType: PurchaseType = 'buy'; const app = { - purchaseType: 'buy', + purchaseType, price: 5, }; const result = filterAppsByFree(app); expect(result).to.be.false; }); it('should return false if both app purchase type is different than buy and price exists and is different than 0', () => { + const purchaseType: PurchaseType = 'subscription'; const app = { - purchaseType: 'subscription', + purchaseType, price: 5, }; const result = filterAppsByFree(app); diff --git a/apps/meteor/tests/unit/client/views/admin/apps/helpers/filterAppsByPaid.test.ts b/apps/meteor/tests/unit/client/views/admin/apps/helpers/filterAppsByPaid.test.ts index 0e577592545..55de2b293f0 100644 --- a/apps/meteor/tests/unit/client/views/admin/apps/helpers/filterAppsByPaid.test.ts +++ b/apps/meteor/tests/unit/client/views/admin/apps/helpers/filterAppsByPaid.test.ts @@ -1,36 +1,41 @@ /* eslint-env mocha */ +import type { PurchaseType } from '@rocket.chat/core-typings'; import { expect } from 'chai'; import { filterAppsByPaid } from '../../../../../../../client/views/admin/apps/helpers/filterAppsByPaid'; describe('filterAppsByPaid', () => { it('should return true if both app purchase type is subscription and app price exists and is not 0', () => { + const purchaseType: PurchaseType = 'subscription'; const app = { - purchaseType: 'subscription', + purchaseType, price: 5, }; const result = filterAppsByPaid(app); expect(result).to.be.true; }); it('should return true if app purchase type is subscription', () => { + const purchaseType: PurchaseType = 'subscription'; const app = { - purchaseType: 'subscription', + purchaseType, price: 0, }; const result = filterAppsByPaid(app); expect(result).to.be.true; }); it('should return true if app price exists and is not 0', () => { + const purchaseType: PurchaseType = 'buy'; const app = { - purchaseType: 'buy', + purchaseType, price: 5, }; const result = filterAppsByPaid(app); expect(result).to.be.true; }); it('should return false if both app price does not exist or is 0 and app purchase type is not subscription', () => { + const purchaseType: PurchaseType = 'buy'; const app = { - purchaseType: 'buy', + purchaseType, price: 0, }; const result = filterAppsByPaid(app); diff --git a/packages/core-typings/src/Apps.ts b/packages/core-typings/src/Apps.ts index 49fe81c9631..4ee1756eb76 100644 --- a/packages/core-typings/src/Apps.ts +++ b/packages/core-typings/src/Apps.ts @@ -60,6 +60,8 @@ export type AppPermission = { required?: boolean; }; +export type PurchaseType = 'buy' | 'subscription'; + export type App = { id: string; iconFileData: string; @@ -82,8 +84,9 @@ export type App = { }; categories: string[]; version: string; + versionIncompatible?: boolean; price: number; - purchaseType: string; + purchaseType: PurchaseType; pricingPlans: AppPricingPlan[]; iconFileContent: string; installed?: boolean; diff --git a/packages/rest-typings/src/apps/index.ts b/packages/rest-typings/src/apps/index.ts index 3e28fcdb210..adc5ee57ed2 100644 --- a/packages/rest-typings/src/apps/index.ts +++ b/packages/rest-typings/src/apps/index.ts @@ -11,6 +11,10 @@ export type AppsEndpoints = { GET: () => { externalComponents: IExternalComponent[] }; }; + '/apps/incompatibleModal': { + GET: (params: { appId: string; appVersion: string; action: string }) => { url: string }; + }; + '/apps/:id': { GET: | ((params: { marketplace?: 'true' | 'false'; version?: string; appVersion?: string; update?: 'true' | 'false' }) => {