[NEW] Incompatible Apps (#27280)

Co-authored-by: rique223 <henrique.jobs1@gmail.com>
Co-authored-by: dougfabris <devfabris@gmail.com>
Co-authored-by: juliajforesti <juliajforesti@gmail.com>
pull/27312/head^2
Matheus Lucca do Carmo 3 years ago committed by GitHub
parent f97c6fb98b
commit 1ad8097680
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 15
      apps/meteor/app/apps/client/orchestrator.ts
  2. 17
      apps/meteor/app/apps/server/communication/rest.js
  3. 35
      apps/meteor/client/views/admin/apps/AppDetailsPage/AppDetailsPageHeader.tsx
  4. 136
      apps/meteor/client/views/admin/apps/AppDetailsPage/tabs/AppStatus/AppStatus.js
  5. 171
      apps/meteor/client/views/admin/apps/AppDetailsPage/tabs/AppStatus/AppStatus.tsx
  6. 4
      apps/meteor/client/views/admin/apps/AppDetailsPage/tabs/AppStatus/AppStatusPriceDisplay.tsx
  7. 71
      apps/meteor/client/views/admin/apps/AppMenu.js
  8. 3
      apps/meteor/client/views/admin/apps/AppsList/AppRow.tsx
  9. 35
      apps/meteor/client/views/admin/apps/BundleChips.tsx
  10. 98
      apps/meteor/client/views/admin/apps/helpers.ts
  11. 1
      apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json
  12. 13
      apps/meteor/tests/unit/client/views/admin/apps/helpers/filterAppsByFree.test.ts
  13. 13
      apps/meteor/tests/unit/client/views/admin/apps/helpers/filterAppsByPaid.test.ts
  14. 5
      packages/core-typings/src/Apps.ts
  15. 4
      packages/rest-typings/src/apps/index.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<IAppExternalURL> {
const result = await APIClient.get('/apps/incompatibleModal', {
appId,
appVersion,
action,
});
if ('url' in result) {
return result;
}
throw new Error('Failed to build external url');
}

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

@ -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 (
<Box display='flex' flexDirection='row' mbe='x20' w='full'>
@ -25,6 +36,7 @@ const AppDetailsPageHeader = ({ app }: { app: App }): ReactElement => {
{bundledIn && Boolean(bundledIn.length) && <BundleChips bundledIn={bundledIn} />}
</Box>
{app?.shortDescription && <Box mbe='x16'>{app.shortDescription}</Box>}
<Box display='flex' flexDirection='row' alignItems='center' mbe='x16'>
<AppStatus app={app} installed={installed} isAppDetailsPage />
{(installed || isSubscribed) && <AppMenu app={app} isAppDetailsPage mis='x8' />}
@ -34,11 +46,26 @@ const AppDetailsPageHeader = ({ app }: { app: App }): ReactElement => {
{t('By_author', { author: author?.name })}
</Box>
<Box is='span'> | </Box>
<Box mi='x16'>{t('Version_version', { version })}</Box>
<Box mi='x16' marginInlineEnd='x4'>
{t('Version_version', { version: versioni18nKey(app) })}
</Box>
{versionIncompatible && (
<Box is='span' marginInlineEnd='x16' marginBlockStart='x4'>
<Tag
title={incompatibleStatus?.tooltipText}
variant={incompatibleStatus?.label === 'Disabled' ? 'secondary-danger' : 'secondary'}
>
{incompatibleStatus?.label}
</Tag>
</Box>
)}
{lastUpdated && (
<>
<Box is='span'> | </Box>
<Box mis='x16'>
<Box mi='x16'>
{t('Marketplace_app_last_updated', {
lastUpdated,
})}

@ -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(<AppPermissionsReviewModal appPermissions={app.permissions} onCancel={cancelAction} onConfirm={confirmAction} />);
};
const checkUserLoggedIn = useMethod('cloud:checkUserLoggedIn');
const handleClick = async (e) => {
e.preventDefault();
e.stopPropagation();
setLoading(true);
const isLoggedIn = await checkUserLoggedIn();
if (!isLoggedIn) {
setLoading(false);
setModal(<CloudLoginModal />);
return;
}
if (action === 'purchase' && !isAppPurchased) {
try {
const data = await Apps.buildExternalUrl(app.id, app.purchaseType, false);
setModal(<IframeModal url={data.url} cancel={cancelAction} confirm={showAppPermissionsReviewModal} />);
} catch (error) {
handleAPIError(error);
}
return;
}
showAppPermissionsReviewModal();
};
const shouldShowPriceDisplay = isAppDetailsPage && button && button.action !== 'update';
return (
<Box {...props}>
{button && isAppDetailsPage && (
<Box
display='flex'
flexDirection='row'
alignItems='center'
justifyContent='center'
borderRadius='x4'
invisible={!showStatus && !loading}
>
<Button
secondary={button.label !== 'Update'}
primary={button.label === 'Update'}
fontSize='x12'
fontWeight={700}
disabled={loading}
onClick={handleClick}
pi='x8'
pb='x6'
lineHeight='x12'
>
{loading ? (
<Throbber inheritColor />
) : (
<>
{button.icon && <Icon name={button.icon} mie='x8' />}
{t(button.label.replace(' ', '_'))}
</>
)}
</Button>
{shouldShowPriceDisplay && !installed && (
<Box mis='x8'>
<AppStatusPriceDisplay purchaseType={purchaseType} pricingPlans={pricingPlans} price={price} showType={false} />
</Box>
)}
</Box>
)}
{status && (
<>
<Tag variant={status.label === 'Disabled' ? 'secondary-danger' : ''}>{status.label}</Tag>
</>
)}
</Box>
);
};
export default memo(AppStatus);

@ -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(<AppPermissionsReviewModal appPermissions={app.permissions} onCancel={cancelAction} onConfirm={confirmAction} />);
};
const openIncompatibleModal = async (app: App, action: string, cancel: () => void): Promise<void> => {
try {
const incompatibleData = await Apps.buildIncompatibleExternalUrl(app.id, app.marketplaceVersion, action);
setModal(<IframeModal url={incompatibleData.url} cancel={cancel} confirm={cancel} />);
} catch (e: any) {
handleAPIError(e);
}
};
const openPurchaseModal = async (app: App): Promise<void> => {
try {
const data = await Apps.buildExternalUrl(app.id, app.purchaseType, false);
setModal(<IframeModal url={data.url} cancel={cancelAction} confirm={showAppPermissionsReviewModal} />);
} catch (error) {
handleAPIError(error);
}
};
const checkUserLoggedIn = useMethod('cloud:checkUserLoggedIn');
const handleClick = async (e: React.MouseEvent<HTMLElement>): Promise<void> => {
e.preventDefault();
e.stopPropagation();
setLoading(true);
const isLoggedIn = await checkUserLoggedIn();
if (!isLoggedIn) {
setLoading(false);
setModal(<CloudLoginModal />);
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 (
<Box {...props} display='flex' mis='x4'>
{button && isAppDetailsPage && (
<Box
display='flex'
flexDirection='row'
alignItems='center'
justifyContent='center'
borderRadius='x4'
invisible={!showStatus && !loading}
>
<Button primary fontSize='x12' fontWeight={700} disabled={loading} onClick={handleClick} pi='x8' pb='x6' lineHeight='x14'>
{loading ? (
<Throbber inheritColor />
) : (
<>
{button.icon && <Icon name={button.icon} size='x16' mie='x4' />}
{t(button.label.replace(' ', '_') as TranslationKey)}
</>
)}
</Button>
{shouldShowPriceDisplay && !installed && (
<Box mis='x8'>
<AppStatusPriceDisplay purchaseType={purchaseType} pricingPlans={pricingPlans} price={price} showType={false} />
</Box>
)}
</Box>
)}
{statuses?.map((status, index) => (
<Fragment key={index}>
<Margins all='x8'>
{status.tooltipText ? (
<Tag title={status.tooltipText} variant={status.label === 'Disabled' ? 'secondary-danger' : undefined}>
{status.label}
</Tag>
) : (
<Box is='span'>
<Tag variant={status.label === 'Disabled' ? 'secondary-danger' : undefined}>{status.label}</Tag>
</Box>
)}
</Margins>
</Fragment>
))}
</Box>
);
};
export default memo(AppStatus);

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

@ -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(<IframeModal url={incompatibleData.url} cancel={cancel} />);
} 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(<IframeModal url={data.url} confirm={confirm} cancel={closeModal} />);
}, [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: (
<Box>
<Icon name='card' size='x16' marginInlineEnd='x4' />
<Icon name={incompatibleIconName(app, 'subscribe')} size='x16' marginInlineEnd='x4' />
{t('Subscription')}
</Box>
),
@ -250,7 +299,12 @@ function AppMenu({ app, isAppDetailsPage, ...props }) {
const nonInstalledAppOptions = {
...(!app.installed && {
acquire: {
label: <Box>{t(button.label.replace(' ', '_'))}</Box>,
label: (
<Box>
<Icon name={incompatibleIconName(app, 'install')} size='x16' marginInlineEnd='x4' />
{t(button.label.replace(' ', '_'))}
</Box>
),
action: handleAcquireApp,
},
}),
@ -274,7 +328,7 @@ function AppMenu({ app, isAppDetailsPage, ...props }) {
update: {
label: (
<Box>
<Icon name='refresh' size='x16' marginInlineEnd='x4' />
<Icon name={incompatibleIconName(app, 'update')} size='x16' marginInlineEnd='x4' />
{t('Update')}
</Box>
),
@ -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 ? <Throbber disabled /> : <Menu options={menuOptions} placement='bottom-start' {...props} />;
return loading ? <Throbber disabled /> : <Menu options={menuOptions} placement='bottom-start' maxHeight='initial' {...props} />;
}
export default AppMenu;

@ -90,9 +90,10 @@ const AppRow = (props: AppRowProps): ReactElement => {
{shortDescription && <Box is='span'>{shortDescription}</Box>}
</Box>
</Box>
<Box display='flex' flexDirection='row' alignItems='center' justifyContent='flex-end' onClick={preventClickPropagation} width='20%'>
{canUpdate && <Badge small variant='primary' />}
<AppStatus app={props} isAppDetailsPage={false} installed={installed} mis='x4' />
<AppStatus app={props} isAppDetailsPage={false} installed={installed} />
<Box minWidth='x32'>
<AppMenu app={props} isAppDetailsPage={false} mis='x4' />
</Box>

@ -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<Element>();
const [isHovered, setIsHovered] = useState(false);
return (
<>
{bundledIn.map((bundle) => (
<Fragment key={bundle.bundleId}>
<Box ref={bundleRef} onMouseEnter={(): void => setIsHovered(true)} onMouseLeave={(): void => setIsHovered(false)}>
<Tag variant='primary'>{bundle.bundleName}</Tag>
</Box>
<PositionAnimated
anchor={bundleRef as RefObject<Element>}
placement='top-middle'
margin={8}
visible={isHovered ? AnimatedVisibility.VISIBLE : AnimatedVisibility.HIDDEN}
>
<Tooltip>
{t('this_app_is_included_with_subscription', {
bundleName: bundle.bundleName,
})}
</Tooltip>
</PositionAnimated>
</Fragment>
{bundledIn.map((bundle, index) => (
<Tag
key={index}
title={t('this_app_is_included_with_subscription', {
bundleName: bundle.bundleName,
})}
variant='primary'
>
{bundle.bundleName}
</Tag>
))}
</>
);

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

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

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

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

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

@ -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' }) => {

Loading…
Cancel
Save