feat: marketplace add-on components (#33483)

Co-authored-by: Douglas Gubert <1810309+d-gubert@users.noreply.github.com>
Co-authored-by: Tasso Evangelista <2263066+tassoevan@users.noreply.github.com>
pull/33673/head
Tiago Evangelista Pinto 1 year ago committed by GitHub
parent 42143e27db
commit e3dac4aab6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 6
      .changeset/tidy-suns-move.md
  2. 4
      apps/meteor/client/hooks/useHasLicenseModule.ts
  3. 1
      apps/meteor/client/views/admin/subscription/utils/links.ts
  4. 35
      apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppDetails/AppDetails.tsx
  5. 12
      apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppStatus/AppStatus.tsx
  6. 24
      apps/meteor/client/views/marketplace/AppsList/AddonChip.tsx
  7. 42
      apps/meteor/client/views/marketplace/AppsList/AddonRequiredModal.tsx
  8. 2
      apps/meteor/client/views/marketplace/AppsList/AppRow.tsx
  9. 48
      apps/meteor/client/views/marketplace/hooks/useAppMenu.tsx
  10. 6
      packages/i18n/src/locales/en.i18n.json

@ -0,0 +1,6 @@
---
"@rocket.chat/meteor": feat
"@rocket.chat/i18n": feat
---
Introduces new visual components into marketplace pages to inform an add-on necessity into the workspace.

@ -2,10 +2,10 @@ import type { LicenseModule } from '@rocket.chat/core-typings';
import { useLicenseBase } from './useLicense';
export const useHasLicenseModule = (licenseName: LicenseModule): 'loading' | boolean => {
export const useHasLicenseModule = (licenseName: LicenseModule | undefined): 'loading' | boolean => {
return (
useLicenseBase({
select: (data) => data.license.activeModules.includes(licenseName),
select: (data) => !!licenseName && data.license.activeModules.includes(licenseName),
}).data ?? 'loading'
);
};

@ -2,3 +2,4 @@ export const CONTACT_SALES_LINK = 'https://go.rocket.chat/i/contact-sales-produc
export const PRICING_LINK = 'https://go.rocket.chat/i/pricing-product';
export const DOWNGRADE_LINK = 'https://go.rocket.chat/i/docs-downgrade';
export const TRIAL_LINK = 'https://go.rocket.chat/i/docs-trial';
export const GET_ADDONS_LINK = 'https://go.rocket.chat/i/get-addons';

@ -1,10 +1,13 @@
import { Box, Callout, Chip, Margins } from '@rocket.chat/fuselage';
import { Box, Button, Callout, Chip, Margins } from '@rocket.chat/fuselage';
import { ExternalLink } from '@rocket.chat/ui-client';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import DOMPurify from 'dompurify';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useExternalLink } from '../../../../../hooks/useExternalLink';
import { useHasLicenseModule } from '../../../../../hooks/useHasLicenseModule';
import { GET_ADDONS_LINK } from '../../../../admin/subscription/utils/links';
import ScreenshotCarouselAnchor from '../../../components/ScreenshotCarouselAnchor';
import type { AppInfo } from '../../../definitions/AppInfo';
import { purifyOptions } from '../../../lib/purifyOptions';
@ -12,10 +15,7 @@ import AppDetailsAPIs from './AppDetailsAPIs';
import { normalizeUrl } from './normalizeUrl';
type AppDetailsProps = {
app: Omit<AppInfo, 'author' | 'documentationUrl'> & {
author?: Partial<AppInfo['author']>;
documentationUrl?: AppInfo['documentationUrl'];
};
app: AppInfo;
};
const AppDetails = ({ app }: AppDetailsProps) => {
@ -28,6 +28,7 @@ const AppDetails = ({ app }: AppDetailsProps) => {
screenshots,
apis,
documentationUrl: documentation,
addon: appAddon,
} = app;
const isMarkdown = detailedDescription && Object.keys(detailedDescription).length !== 0 && detailedDescription.rendered;
@ -37,18 +38,36 @@ const AppDetails = ({ app }: AppDetailsProps) => {
const normalizedSupportUrl = support ? normalizeUrl(support) : undefined;
const normalizedDocumentationUrl = documentation ? normalizeUrl(documentation) : undefined;
const workspaceHasAddon = useHasLicenseModule(appAddon);
const openExternalLink = useExternalLink();
return (
<Box maxWidth='x640' w='full' marginInline='auto' color='default'>
<Box mbs='36px' maxWidth='x640' w='full' marginInline='auto' color='default'>
{appAddon && !workspaceHasAddon && (
<Callout
mb={16}
title={t('Subscription_add-on_required')}
type='info'
actions={
<Button small onClick={() => openExternalLink(GET_ADDONS_LINK)}>
{t('Contact_sales')}
</Button>
}
>
{t('App_cannot_be_enabled_without_add-on')}
</Callout>
)}
{app.licenseValidation && (
<>
{Object.entries(app.licenseValidation.warnings).map(([key]) => (
<Callout key={key} type='warning'>
<Callout key={key} type='warning' mb={16}>
{t(`Apps_License_Message_${key}` as TranslationKey)}
</Callout>
))}
{Object.entries(app.licenseValidation.errors).map(([key]) => (
<Callout key={key} type='danger'>
<Callout key={key} type='danger' mb={16}>
{t(`Apps_License_Message_${key}` as TranslationKey)}
</Callout>
))}

@ -7,7 +7,9 @@ import type { ReactElement } from 'react';
import React, { useCallback, useState, memo } from 'react';
import semver from 'semver';
import { useHasLicenseModule } from '../../../../../hooks/useHasLicenseModule';
import { useIsEnterprise } from '../../../../../hooks/useIsEnterprise';
import AddonRequiredModal from '../../../AppsList/AddonRequiredModal';
import type { appStatusSpanResponseProps } from '../../../helpers';
import { appButtonProps, appMultiStatusProps } from '../../../helpers';
import type { AppInstallationHandlerParams } from '../../../hooks/useAppInstallationHandler';
@ -41,6 +43,9 @@ const AppStatus = ({ app, showStatus = true, isAppDetailsPage, installed, ...pro
const { data } = useIsEnterprise();
const isEnterprise = data?.isEnterprise ?? false;
const appAddon = app.addon;
const workspaceHasAddon = useHasLicenseModule(appAddon);
const statuses = appMultiStatusProps(app, isAppDetailsPage, context || '', isEnterprise);
const totalSeenRequests = app?.appRequestStats?.totalSeen;
@ -81,8 +86,13 @@ const AppStatus = ({ app, showStatus = true, isAppDetailsPage, installed, ...pro
const handleAcquireApp = useCallback(() => {
setLoading(true);
if (isAdminUser && appAddon && !workspaceHasAddon) {
return setModal(<AddonRequiredModal actionType='install' onDismiss={cancelAction} onInstallAnyway={appInstallationHandler} />);
}
appInstallationHandler();
}, [appInstallationHandler, setLoading]);
}, [appAddon, appInstallationHandler, cancelAction, isAdminUser, setLoading, setModal, workspaceHasAddon]);
// @TODO we should refactor this to not use the label to determine the variant
const getStatusVariant = (status: appStatusSpanResponseProps) => {

@ -0,0 +1,24 @@
import type { App } from '@rocket.chat/core-typings';
import { Tag } from '@rocket.chat/fuselage';
import React from 'react';
import { useTranslation } from 'react-i18next';
type AddonChipProps = {
app: App;
};
const AddonChip = ({ app }: AddonChipProps) => {
const { t } = useTranslation();
if (!app.addon) {
return null;
}
return (
<Tag variant='secondary' title={t('Requires_subscription_add-on')}>
{t('Add-on')}
</Tag>
);
};
export default AddonChip;

@ -0,0 +1,42 @@
import { Button, Modal } from '@rocket.chat/fuselage';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useExternalLink } from '../../../hooks/useExternalLink';
import { GET_ADDONS_LINK } from '../../admin/subscription/utils/links';
export type AddonActionType = 'install' | 'enable';
type AddonRequiredModalProps = {
actionType: AddonActionType;
onDismiss: () => void;
onInstallAnyway: () => void;
};
const AddonRequiredModal = ({ actionType, onDismiss, onInstallAnyway }: AddonRequiredModalProps) => {
const { t } = useTranslation();
const handleOpenLink = useExternalLink();
return (
<Modal>
<Modal.Header>
<Modal.HeaderText>
<Modal.Title>{t('Add-on_required')}</Modal.Title>
</Modal.HeaderText>
<Modal.Close onClick={onDismiss} />
</Modal.Header>
<Modal.Content>{t('Add-on_required_modal_enable_content')}</Modal.Content>
<Modal.Footer>
<Modal.FooterControllers>
{actionType === 'install' && <Button onClick={onInstallAnyway}>{t('Install_anyway')}</Button>}
<Button primary onClick={() => handleOpenLink(GET_ADDONS_LINK)}>
{t('Contact_sales')}
</Button>
</Modal.FooterControllers>
</Modal.Footer>
</Modal>
);
};
export default AddonRequiredModal;

@ -9,6 +9,7 @@ import semver from 'semver';
import AppStatus from '../AppDetailsPage/tabs/AppStatus/AppStatus';
import AppMenu from '../AppMenu';
import BundleChips from '../BundleChips';
import AddonChip from './AddonChip';
// TODO: org props
const AppRow = ({ className, ...props }: App & { className?: string }): ReactElement => {
@ -68,6 +69,7 @@ const AppRow = ({ className, ...props }: App & { className?: string }): ReactEle
{name}
</CardTitle>
{Boolean(bundledIn?.length) && <BundleChips bundledIn={bundledIn} />}
<AddonChip app={props} />
</CardHeader>
{shortDescription && <CardBody id={`${id}-description`}>{shortDescription}</CardBody>}
</CardCol>

@ -15,7 +15,10 @@ import React, { useMemo, useCallback, useState } from 'react';
import semver from 'semver';
import WarningModal from '../../../components/WarningModal';
import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule';
import { useIsEnterprise } from '../../../hooks/useIsEnterprise';
import type { AddonActionType } from '../AppsList/AddonRequiredModal';
import AddonRequiredModal from '../AppsList/AddonRequiredModal';
import IframeModal from '../IframeModal';
import UninstallGrandfatheredAppModal from '../components/UninstallGrandfatheredAppModal/UninstallGrandfatheredAppModal';
import type { Actions } from '../helpers';
@ -56,6 +59,9 @@ export const useAppMenu = (app: App, isAppDetailsPage: boolean) => {
const { data } = useIsEnterprise();
const isEnterpriseLicense = !!data?.isEnterprise;
const appAddon = app.addon;
const workspaceHasAddon = useHasLicenseModule(appAddon);
const [isLoading, setLoading] = useState(false);
const [requestedEndUser, setRequestedEndUser] = useState(app.requestedEndUser);
const [isAppPurchased, setPurchased] = useState(app?.isPurchased);
@ -118,10 +124,30 @@ export const useAppMenu = (app: App, isAppDetailsPage: boolean) => {
setIsPurchased: setPurchased,
});
// TODO: There is no necessity of all these callbacks being out of the above useMemo.
// My propose here is to refactor the hook to make it clearer and with less unnecessary caching.
const missingAddonHandler = useCallback(
(actionType: AddonActionType) => {
setModal(<AddonRequiredModal actionType={actionType} onDismiss={closeModal} onInstallAnyway={appInstallationHandler} />);
},
[appInstallationHandler, closeModal, setModal],
);
const handleAddon = useCallback(
(actionType: AddonActionType, callback: () => void) => {
if (isAdminUser && appAddon && !workspaceHasAddon) {
return missingAddonHandler(actionType);
}
callback();
},
[appAddon, isAdminUser, missingAddonHandler, workspaceHasAddon],
);
const handleAcquireApp = useCallback(() => {
setLoading(true);
appInstallationHandler();
}, [appInstallationHandler, setLoading]);
handleAddon('install', appInstallationHandler);
}, [appInstallationHandler, handleAddon]);
const handleSubscription = useCallback(async () => {
if (app?.versionIncompatible && !isSubscribed) {
@ -181,14 +207,16 @@ export const useAppMenu = (app: App, isAppDetailsPage: boolean) => {
);
}, [app.name, closeModal, setAppStatus, setModal, t]);
const handleEnable = useCallback(async () => {
try {
const { status } = await setAppStatus({ status: AppStatus.MANUALLY_ENABLED });
warnEnableDisableApp(app.name, status, 'enable');
} catch (error) {
handleAPIError(error);
}
}, [app.name, setAppStatus]);
const handleEnable = useCallback(() => {
handleAddon('enable', async () => {
try {
const { status } = await setAppStatus({ status: AppStatus.MANUALLY_ENABLED });
warnEnableDisableApp(app.name, status, 'enable');
} catch (error) {
handleAPIError(error);
}
});
}, [app.name, handleAddon, setAppStatus]);
const handleUninstall = useCallback(() => {
const uninstall = async () => {

@ -380,6 +380,9 @@
"admin-video-conf-provider-not-configured": "**Conference call not enabled**: Configure conference calls in order to make it available on this workspace.",
"admin-no-videoconf-provider-app": "**Conference call not enabled**: Conference call apps are available in the Rocket.Chat marketplace.",
"Administration": "Administration",
"Add-on": "Add-on",
"Add-on_required": "Add-on required",
"Add-on_required_modal_enable_content": "App cannot be enabled without the required subscription add-on. Contact sales to get the add-on for this app.",
"Address": "Address",
"Adjustable_font_size": "Adjustable font size",
"Adjustable_font_size_description": "Designed for those who prefer larger or smaller text for improved readability. This flexibility promotes inclusivity by empowering users to tailor the software interface to their specific needs.",
@ -4580,6 +4583,7 @@
"Require_any_token": "Require any token",
"Require_password_change": "Require password change",
"Require_Two_Factor_Authentication": "Require Two Factor Authentication",
"Requires_subscription_add-on": "Requires subscription add-on",
"Resend_verification_email": "Resend verification email",
"Resend_welcome_email": "Resend welcome email",
"Reset": "Reset",
@ -6353,6 +6357,8 @@
"onboarding.form.standaloneServerForm.servicesUnavailable": "Some of the services will be unavailable or will require manual setup",
"onboarding.form.standaloneServerForm.publishOwnApp": "In order to send push notitications you need to compile and publish your own app to Google Play and App Store",
"onboarding.form.standaloneServerForm.manuallyIntegrate": "Need to manually integrate with external services",
"Subscription_add-on_required": "Subscription add-on required",
"App_cannot_be_enabled_without_add-on": "App cannot be enabled without add-on.",
"subscription.callout.servicesDisruptionsMayOccur": "Services disruptions may occur",
"subscription.callout.servicesDisruptionsOccurring": "Services disruptions occurring",
"subscription.callout.capabilitiesDisabled": "Capabilities disabled",

Loading…
Cancel
Save