snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
This commit is contained in:
89
apps/meteor/client/views/marketplace/hooks/useAppInfo.ts
Normal file
89
apps/meteor/client/views/marketplace/hooks/useAppInfo.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { App } from '@rocket.chat/core-typings';
|
||||
import { useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
import { useApps } from './useApps';
|
||||
import { AppClientOrchestratorInstance } from '../../../apps/orchestrator';
|
||||
import type { AppInfo } from '../definitions/AppInfo';
|
||||
import type { ISettings } from '../definitions/ISettings';
|
||||
|
||||
const getBundledInApp = async (app: App): Promise<App['bundledIn']> => {
|
||||
const { bundledIn = [] } = app;
|
||||
|
||||
return Promise.all(
|
||||
bundledIn.map(async (bundle) => {
|
||||
const apps = await AppClientOrchestratorInstance.getAppsOnBundle(bundle.bundleId);
|
||||
bundle.apps = apps.slice(0, 4);
|
||||
return bundle;
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
export const useAppInfo = (appId: string, context: string): AppInfo | undefined => {
|
||||
const { data: { installedApps, marketplaceApps, privateApps } = {} } = useApps();
|
||||
|
||||
const [appData, setAppData] = useState<AppInfo>();
|
||||
|
||||
const getSettings = useEndpoint('GET', '/apps/:id/settings', { id: appId });
|
||||
const getScreenshots = useEndpoint('GET', '/apps/:id/screenshots', { id: appId });
|
||||
const getApis = useEndpoint('GET', '/apps/:id/apis', { id: appId });
|
||||
const getBundledIn = useEndpoint('GET', '/apps/:id', { id: appId });
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAppInfo = async (): Promise<void> => {
|
||||
if ((!marketplaceApps?.length && !installedApps?.length && !privateApps?.length) || !appId) {
|
||||
return;
|
||||
}
|
||||
|
||||
let appResult: App | undefined;
|
||||
const marketplaceAppsContexts = ['explore', 'premium', 'requested'];
|
||||
|
||||
if (marketplaceAppsContexts.includes(context)) appResult = marketplaceApps?.find((app) => app.id === appId);
|
||||
|
||||
if (context === 'private') appResult = privateApps?.find((app) => app.id === appId);
|
||||
|
||||
if (context === 'installed') appResult = installedApps?.find((app) => app.id === appId);
|
||||
|
||||
if (!appResult) return;
|
||||
|
||||
const [settings, apis, screenshots, bundledIn] = await Promise.all([
|
||||
getSettings().catch(() => ({
|
||||
settings: {},
|
||||
})),
|
||||
getApis().catch(() => ({
|
||||
apis: [],
|
||||
})),
|
||||
getScreenshots().catch(() => ({
|
||||
screenshots: [],
|
||||
})),
|
||||
appResult.marketplace === false
|
||||
? []
|
||||
: getBundledIn({
|
||||
marketplace: 'true',
|
||||
update: 'true',
|
||||
appVersion: appId,
|
||||
})
|
||||
.then(({ app }: any) => {
|
||||
appResult.tosLink = app.tosLink;
|
||||
appResult.privacyLink = app.privacyLink;
|
||||
return getBundledInApp(app);
|
||||
})
|
||||
.catch(() => ({
|
||||
settings: {},
|
||||
})),
|
||||
]);
|
||||
|
||||
setAppData({
|
||||
...appResult,
|
||||
bundledIn: bundledIn as App['bundledIn'],
|
||||
settings: settings.settings as ISettings,
|
||||
apis: apis ? apis.apis : [],
|
||||
screenshots: screenshots ? screenshots.screenshots : [],
|
||||
});
|
||||
};
|
||||
|
||||
fetchAppInfo();
|
||||
}, [appId, context, getApis, getBundledIn, getScreenshots, getSettings, installedApps, marketplaceApps, privateApps]);
|
||||
|
||||
return appData;
|
||||
};
|
||||
@@ -0,0 +1,159 @@
|
||||
import type { App } from '@rocket.chat/core-typings';
|
||||
import { useEndpoint, useRouteParameter, useSetModal, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { isMarketplaceRouteContext, useAppsCountQuery } from './useAppsCountQuery';
|
||||
import { useOpenAppPermissionsReviewModal } from './useOpenAppPermissionsReviewModal';
|
||||
import { useExternalLink } from '../../../hooks/useExternalLink';
|
||||
import { useCheckoutUrl } from '../../admin/subscription/hooks/useCheckoutUrl';
|
||||
import IframeModal from '../IframeModal';
|
||||
import AppInstallModal from '../components/AppInstallModal/AppInstallModal';
|
||||
import type { Actions } from '../helpers';
|
||||
import { useAppsOrchestration } from './useAppsOrchestration';
|
||||
import { useOpenIncompatibleModal } from './useOpenIncompatibleModal';
|
||||
import { handleAPIError } from '../helpers/handleAPIError';
|
||||
|
||||
export type AppInstallationHandlerParams = {
|
||||
app: App;
|
||||
action: Actions | '';
|
||||
isAppPurchased?: boolean;
|
||||
onDismiss: () => void;
|
||||
onSuccess: (action: Actions | '', appPermissions?: App['permissions']) => void;
|
||||
setIsPurchased: (purchased: boolean) => void;
|
||||
};
|
||||
|
||||
export function useAppInstallationHandler({
|
||||
app,
|
||||
action,
|
||||
isAppPurchased,
|
||||
onDismiss,
|
||||
onSuccess,
|
||||
setIsPurchased,
|
||||
}: AppInstallationHandlerParams) {
|
||||
const dispatchToastMessage = useToastMessageDispatch();
|
||||
const setModal = useSetModal();
|
||||
|
||||
const routeContext = String(useRouteParameter('context'));
|
||||
const context = isMarketplaceRouteContext(routeContext) ? routeContext : 'explore';
|
||||
|
||||
const appCountQuery = useAppsCountQuery(context);
|
||||
|
||||
const notifyAdmins = useEndpoint('POST', `/apps/notify-admins`);
|
||||
|
||||
const openIncompatibleModal = useOpenIncompatibleModal();
|
||||
|
||||
const openExternalLink = useExternalLink();
|
||||
const manageSubscriptionUrl = useCheckoutUrl()({ target: 'user-page', action: 'buy_more' });
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
setModal(null);
|
||||
onDismiss();
|
||||
}, [onDismiss, setModal]);
|
||||
|
||||
const success = useCallback(
|
||||
(appPermissions?: App['permissions']) => {
|
||||
setModal(null);
|
||||
onSuccess(action, appPermissions);
|
||||
},
|
||||
[action, onSuccess, setModal],
|
||||
);
|
||||
|
||||
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 appsOrchestrator.buildExternalUrl(app.id, app.purchaseType, false);
|
||||
setModal(
|
||||
<IframeModal
|
||||
url={data.url}
|
||||
cancel={onDismiss}
|
||||
confirm={() => {
|
||||
setIsPurchased(true);
|
||||
openPermissionModal();
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
} catch (error) {
|
||||
handleAPIError(error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
openPermissionModal();
|
||||
}, [action, isAppPurchased, openPermissionModal, appsOrchestrator, app.id, app.purchaseType, setModal, onDismiss, setIsPurchased]);
|
||||
|
||||
return useCallback(async () => {
|
||||
if (app?.versionIncompatible) {
|
||||
openIncompatibleModal(app, action, closeModal);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'request') {
|
||||
const requestConfirmAction = (postMessage: Record<string, unknown>) => {
|
||||
setModal(null);
|
||||
dispatchToastMessage({ type: 'success', message: 'App request submitted' });
|
||||
|
||||
notifyAdmins({
|
||||
appId: app.id,
|
||||
appName: app.name,
|
||||
appVersion: app.marketplaceVersion,
|
||||
message: typeof postMessage.message === 'string' ? postMessage.message : '',
|
||||
});
|
||||
|
||||
success();
|
||||
};
|
||||
|
||||
try {
|
||||
const data = await appsOrchestrator.buildExternalAppRequest(app.id);
|
||||
setModal(<IframeModal url={data.url} wrapperHeight='x460' cancel={onDismiss} confirm={requestConfirmAction} />);
|
||||
} catch (error) {
|
||||
handleAPIError(error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!appCountQuery.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (appCountQuery.data.hasUnlimitedApps) {
|
||||
return acquireApp();
|
||||
}
|
||||
|
||||
setModal(
|
||||
<AppInstallModal
|
||||
enabled={appCountQuery.data.enabled}
|
||||
limit={appCountQuery.data.limit}
|
||||
appName={app.name}
|
||||
handleClose={closeModal}
|
||||
handleConfirm={acquireApp}
|
||||
handleEnableUnlimitedApps={() => {
|
||||
openExternalLink(manageSubscriptionUrl);
|
||||
setModal(null);
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
}, [
|
||||
app,
|
||||
action,
|
||||
appCountQuery.data,
|
||||
setModal,
|
||||
closeModal,
|
||||
acquireApp,
|
||||
openIncompatibleModal,
|
||||
dispatchToastMessage,
|
||||
notifyAdmins,
|
||||
success,
|
||||
appsOrchestrator,
|
||||
onDismiss,
|
||||
openExternalLink,
|
||||
manageSubscriptionUrl,
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { OperationResult } from '@rocket.chat/rest-typings';
|
||||
import { useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import type { UseQueryResult } from '@tanstack/react-query';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { marketplaceQueryKeys } from '../../../lib/queryKeys';
|
||||
|
||||
export const useAppInstances = ({ appId }: { appId: string }): UseQueryResult<OperationResult<'GET', '/apps/:id/status'>> => {
|
||||
const status = useEndpoint('GET', '/apps/:id/status', { id: appId });
|
||||
|
||||
return useQuery({
|
||||
queryKey: marketplaceQueryKeys.appStatus(appId),
|
||||
queryFn: () => status(),
|
||||
});
|
||||
};
|
||||
485
apps/meteor/client/views/marketplace/hooks/useAppMenu.tsx
Normal file
485
apps/meteor/client/views/marketplace/hooks/useAppMenu.tsx
Normal file
@@ -0,0 +1,485 @@
|
||||
import { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus';
|
||||
import type { App, AppPermission } from '@rocket.chat/core-typings';
|
||||
import { Box, Icon } from '@rocket.chat/fuselage';
|
||||
import {
|
||||
useSetModal,
|
||||
useEndpoint,
|
||||
useTranslation,
|
||||
useRouteParameter,
|
||||
useToastMessageDispatch,
|
||||
usePermission,
|
||||
useRouter,
|
||||
} from '@rocket.chat/ui-contexts';
|
||||
import type { MouseEvent, ReactNode } from 'react';
|
||||
import { useMemo, useCallback, useState } from 'react';
|
||||
import semver from 'semver';
|
||||
|
||||
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';
|
||||
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';
|
||||
import { appEnabledStatuses, appButtonProps } from '../helpers';
|
||||
import { handleAPIError } from '../helpers/handleAPIError';
|
||||
import { warnEnableDisableApp } from '../helpers/warnEnableDisableApp';
|
||||
|
||||
export type AppMenuOption = {
|
||||
id: string;
|
||||
section: number;
|
||||
content: ReactNode;
|
||||
disabled?: boolean;
|
||||
onClick?: (e?: MouseEvent<HTMLElement>) => void;
|
||||
};
|
||||
|
||||
type AppMenuSections = {
|
||||
items: AppMenuOption[];
|
||||
}[];
|
||||
|
||||
export const useAppMenu = (app: App, isAppDetailsPage: boolean) => {
|
||||
const t = useTranslation();
|
||||
const router = useRouter();
|
||||
const setModal = useSetModal();
|
||||
const dispatchToastMessage = useToastMessageDispatch();
|
||||
const openIncompatibleModal = useOpenIncompatibleModal();
|
||||
|
||||
const context = useRouteParameter('context') as MarketplaceRouteContext;
|
||||
const currentTab = useRouteParameter('tab');
|
||||
const appCountQuery = useAppsCountQuery(context);
|
||||
|
||||
const isAdminUser = usePermission('manage-apps');
|
||||
const { data } = useIsEnterprise();
|
||||
const isEnterpriseLicense = !!data?.isEnterprise;
|
||||
|
||||
const { data: workspaceHasMarketplaceAddon = false } = useHasLicenseModule(app.addon);
|
||||
const { data: workspaceHasInstalledAddon = false } = useHasLicenseModule(app.installedAddon);
|
||||
|
||||
const [isLoading, setLoading] = useState(false);
|
||||
const [requestedEndUser, setRequestedEndUser] = useState(app.requestedEndUser);
|
||||
const [isAppPurchased, setPurchased] = useState(app?.isPurchased);
|
||||
|
||||
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/buildExternalUrl');
|
||||
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: Actions | '', permissionsGranted?: AppPermission[]) => {
|
||||
if (action) {
|
||||
if (action === 'request') {
|
||||
setRequestedEndUser(true);
|
||||
} else {
|
||||
await marketplaceActions[action]({ ...app, permissionsGranted });
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
},
|
||||
[app, marketplaceActions, setLoading],
|
||||
);
|
||||
|
||||
const openPermissionModal = useOpenAppPermissionsReviewModal({
|
||||
app,
|
||||
onCancel: closeModal,
|
||||
onConfirm: (permissionsGranted) => installationSuccess(action, permissionsGranted),
|
||||
});
|
||||
|
||||
const appInstallationHandler = useAppInstallationHandler({
|
||||
app,
|
||||
isAppPurchased,
|
||||
action,
|
||||
onDismiss: closeModal,
|
||||
onSuccess: installationSuccess,
|
||||
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 (actionType === 'enable' && isAdminUser && app.installedAddon && !workspaceHasInstalledAddon) {
|
||||
return missingAddonHandler(actionType);
|
||||
}
|
||||
|
||||
if (actionType !== 'enable' && isAdminUser && app.addon && !workspaceHasMarketplaceAddon) {
|
||||
return missingAddonHandler(actionType);
|
||||
}
|
||||
|
||||
callback();
|
||||
},
|
||||
[app.addon, app.installedAddon, isAdminUser, missingAddonHandler, workspaceHasInstalledAddon, workspaceHasMarketplaceAddon],
|
||||
);
|
||||
|
||||
const handleAcquireApp = useCallback(() => {
|
||||
setLoading(true);
|
||||
handleAddon('install', appInstallationHandler);
|
||||
}, [appInstallationHandler, handleAddon]);
|
||||
|
||||
const handleSubscription = useCallback(async () => {
|
||||
if (app?.versionIncompatible && !isSubscribed) {
|
||||
openIncompatibleModal(app, 'subscribe', closeModal);
|
||||
return;
|
||||
}
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = await buildExternalUrl({
|
||||
appId: app.id,
|
||||
purchaseType: app.purchaseType,
|
||||
details: 'true',
|
||||
});
|
||||
} catch (error) {
|
||||
handleAPIError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
const confirm = async () => {
|
||||
try {
|
||||
await syncApp();
|
||||
} catch (error) {
|
||||
handleAPIError(error);
|
||||
}
|
||||
};
|
||||
|
||||
setModal(<IframeModal url={data.url} confirm={confirm} cancel={closeModal} />);
|
||||
}, [app, isSubscribed, setModal, closeModal, openIncompatibleModal, buildExternalUrl, syncApp]);
|
||||
|
||||
const handleViewLogs = useCallback(() => {
|
||||
router.navigate({
|
||||
name: 'marketplace',
|
||||
params: {
|
||||
context,
|
||||
page: 'info',
|
||||
id: app.id,
|
||||
version: app.version,
|
||||
tab: 'logs',
|
||||
},
|
||||
});
|
||||
}, [app.id, app.version, context, router]);
|
||||
|
||||
const handleDisable = useCallback(() => {
|
||||
const confirm = async () => {
|
||||
closeModal();
|
||||
try {
|
||||
const { status } = await setAppStatus({ status: AppStatus.MANUALLY_DISABLED });
|
||||
warnEnableDisableApp(app.name, status, 'disable');
|
||||
} catch (error) {
|
||||
handleAPIError(error);
|
||||
}
|
||||
};
|
||||
setModal(
|
||||
<WarningModal close={closeModal} confirm={confirm} text={t('Apps_Marketplace_Deactivate_App_Prompt')} confirmText={t('Yes')} />,
|
||||
);
|
||||
}, [app.name, closeModal, setAppStatus, setModal, t]);
|
||||
|
||||
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 () => {
|
||||
closeModal();
|
||||
try {
|
||||
const { success } = await uninstallApp();
|
||||
if (success) {
|
||||
dispatchToastMessage({ type: 'success', message: `${app.name} uninstalled` });
|
||||
if (context === 'details' && currentTab !== 'details') {
|
||||
router.navigate(
|
||||
{
|
||||
name: 'marketplace',
|
||||
params: { ...router.getRouteParameters(), tab: 'details' },
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
handleAPIError(error);
|
||||
}
|
||||
};
|
||||
|
||||
if (isSubscribed) {
|
||||
const confirm = async () => {
|
||||
await handleSubscription();
|
||||
};
|
||||
|
||||
setModal(
|
||||
<WarningModal
|
||||
close={closeModal}
|
||||
cancel={uninstall}
|
||||
confirm={confirm}
|
||||
text={t('Apps_Marketplace_Uninstall_Subscribed_App_Prompt')}
|
||||
confirmText={t('Apps_Marketplace_Modify_App_Subscription')}
|
||||
cancelText={t('Apps_Marketplace_Uninstall_Subscribed_App_Anyway')}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
if (!appCountQuery.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (app.migrated) {
|
||||
setModal(
|
||||
<UninstallGrandfatheredAppModal
|
||||
context={context}
|
||||
appName={app.name}
|
||||
limit={appCountQuery.data.limit}
|
||||
handleUninstall={uninstall}
|
||||
handleClose={closeModal}
|
||||
/>,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setModal(
|
||||
<WarningModal close={closeModal} confirm={uninstall} text={t('Apps_Marketplace_Uninstall_App_Prompt')} confirmText={t('Yes')} />,
|
||||
);
|
||||
}, [
|
||||
isSubscribed,
|
||||
appCountQuery.data,
|
||||
app.migrated,
|
||||
app.name,
|
||||
setModal,
|
||||
closeModal,
|
||||
t,
|
||||
uninstallApp,
|
||||
dispatchToastMessage,
|
||||
context,
|
||||
currentTab,
|
||||
router,
|
||||
handleSubscription,
|
||||
]);
|
||||
|
||||
const incompatibleIconName = useCallback(
|
||||
(app: App, action: 'subscribe' | 'install' | 'update') => {
|
||||
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);
|
||||
return;
|
||||
}
|
||||
|
||||
handleAddon('update', openPermissionModal);
|
||||
}, [app, handleAddon, openPermissionModal, openIncompatibleModal, closeModal]);
|
||||
|
||||
const canUpdate = app.installed && app.version && app.marketplaceVersion && semver.lt(app.version, app.marketplaceVersion);
|
||||
|
||||
const menuSections = useMemo(() => {
|
||||
const bothAppStatusOptions = [
|
||||
canAppBeSubscribed &&
|
||||
isSubscribed &&
|
||||
isAdminUser && {
|
||||
id: 'subscribe',
|
||||
section: 0,
|
||||
content: (
|
||||
<>
|
||||
<Icon name={incompatibleIconName(app, 'subscribe')} size='x16' marginInlineEnd={4} />
|
||||
{t('Subscription')}
|
||||
</>
|
||||
),
|
||||
onClick: handleSubscription,
|
||||
},
|
||||
];
|
||||
|
||||
const nonInstalledAppOptions = [
|
||||
!app.installed &&
|
||||
!!button && {
|
||||
id: 'acquire',
|
||||
section: 0,
|
||||
disabled: requestedEndUser,
|
||||
content: (
|
||||
<>
|
||||
{isAdminUser && <Icon name={incompatibleIconName(app, 'install')} size='x16' marginInlineEnd={4} />}
|
||||
{t(buttonLabel)}
|
||||
</>
|
||||
),
|
||||
onClick: handleAcquireApp,
|
||||
},
|
||||
];
|
||||
|
||||
const isPossibleToEnableApp =
|
||||
app.installed &&
|
||||
isAdminUser &&
|
||||
!isAppEnabled &&
|
||||
// If the app is migrated, it can be enabled regardless of other validations
|
||||
// If not, and the app isEnterpriseOnly, we need to check the workspace's license
|
||||
(app.migrated || !app.isEnterpriseOnly || isEnterpriseLicense);
|
||||
|
||||
const doesItReachedTheLimit =
|
||||
!app.migrated &&
|
||||
!appCountQuery?.data?.hasUnlimitedApps &&
|
||||
appCountQuery?.data?.enabled !== undefined &&
|
||||
appCountQuery?.data?.enabled >= appCountQuery?.data?.limit;
|
||||
|
||||
const installedAppOptions = [
|
||||
context !== 'details' &&
|
||||
isAdminUser &&
|
||||
app.installed && {
|
||||
id: 'viewLogs',
|
||||
section: 0,
|
||||
content: (
|
||||
<>
|
||||
<Icon name='desktop-text' size='x16' marginInlineEnd={4} />
|
||||
{t('View_Logs')}
|
||||
</>
|
||||
),
|
||||
onClick: handleViewLogs,
|
||||
},
|
||||
isAdminUser &&
|
||||
!!canUpdate &&
|
||||
!isAppDetailsPage && {
|
||||
id: 'update',
|
||||
section: 0,
|
||||
content: (
|
||||
<>
|
||||
<Icon name={incompatibleIconName(app, 'update')} size='x16' marginInlineEnd={4} />
|
||||
{t('Update')}
|
||||
</>
|
||||
),
|
||||
onClick: handleUpdate,
|
||||
},
|
||||
app.installed &&
|
||||
isAdminUser &&
|
||||
isAppEnabled && {
|
||||
id: 'disable',
|
||||
section: 0,
|
||||
content: (
|
||||
<Box color='status-font-on-warning'>
|
||||
<Icon name='ban' size='x16' marginInlineEnd={4} />
|
||||
{t('Disable')}
|
||||
</Box>
|
||||
),
|
||||
onClick: handleDisable,
|
||||
},
|
||||
isPossibleToEnableApp && {
|
||||
id: 'enable',
|
||||
section: 0,
|
||||
disabled: doesItReachedTheLimit,
|
||||
content: (
|
||||
<>
|
||||
<Icon name='check' size='x16' marginInlineEnd='x4' />
|
||||
{t('Enable')}
|
||||
</>
|
||||
),
|
||||
onClick: handleEnable,
|
||||
},
|
||||
app.installed &&
|
||||
isAdminUser && {
|
||||
id: 'uninstall',
|
||||
section: 1,
|
||||
content: (
|
||||
<Box color='status-font-on-danger'>
|
||||
<Icon name='trash' size='x16' marginInlineEnd={4} />
|
||||
{t('Uninstall')}
|
||||
</Box>
|
||||
),
|
||||
onClick: handleUninstall,
|
||||
},
|
||||
];
|
||||
|
||||
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,
|
||||
isAdminUser,
|
||||
incompatibleIconName,
|
||||
app,
|
||||
t,
|
||||
handleSubscription,
|
||||
button,
|
||||
requestedEndUser,
|
||||
buttonLabel,
|
||||
handleAcquireApp,
|
||||
isEnterpriseLicense,
|
||||
isAppEnabled,
|
||||
appCountQuery?.data?.hasUnlimitedApps,
|
||||
appCountQuery?.data?.enabled,
|
||||
appCountQuery?.data?.limit,
|
||||
context,
|
||||
handleViewLogs,
|
||||
canUpdate,
|
||||
isAppDetailsPage,
|
||||
handleUpdate,
|
||||
handleDisable,
|
||||
handleEnable,
|
||||
handleUninstall,
|
||||
]);
|
||||
|
||||
return { isLoading, isAdminUser, sections: menuSections };
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useEndpoint, usePermission } from '@rocket.chat/ui-contexts';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
export const useAppRequestStats = () => {
|
||||
const canManageApp = usePermission('manage-apps');
|
||||
|
||||
const fetchRequestStats = useEndpoint('GET', '/apps/app-request/stats');
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['app-requests-stats'],
|
||||
queryFn: () => fetchRequestStats(),
|
||||
select: ({ data }) => data,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
enabled: canManageApp,
|
||||
});
|
||||
};
|
||||
13
apps/meteor/client/views/marketplace/hooks/useAppRequests.ts
Normal file
13
apps/meteor/client/views/marketplace/hooks/useAppRequests.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { AppRequestFilter } from '@rocket.chat/core-typings';
|
||||
import { useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
export const useAppRequests = (appId: string, limit?: number, offset?: number, sort?: string, filter?: AppRequestFilter) => {
|
||||
const fetchAppRequests = useEndpoint('GET', '/apps/app-request');
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['app-requests', appId, limit, offset],
|
||||
queryFn: async () => fetchAppRequests({ appId, q: filter, sort, limit, offset }),
|
||||
gcTime: 0,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useTranslation } from '@rocket.chat/ui-contexts';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { Utilities } from '../../../../ee/lib/misc/Utilities';
|
||||
|
||||
type AppTranslationFunction = {
|
||||
(key: string, ...replaces: unknown[]): string;
|
||||
has: (key: string | undefined) => boolean;
|
||||
};
|
||||
|
||||
export const useAppTranslation = (appId: string): AppTranslationFunction => {
|
||||
const t = useTranslation();
|
||||
|
||||
const tApp = useCallback(
|
||||
(key: string, ...args: unknown[]) => {
|
||||
if (!key) {
|
||||
return '';
|
||||
}
|
||||
const appKey = Utilities.getI18nKeyForApp(key, appId);
|
||||
|
||||
if (t.has(appKey)) {
|
||||
return t(appKey, ...args);
|
||||
}
|
||||
if (t.has(key)) {
|
||||
return t(key, ...args);
|
||||
}
|
||||
return key;
|
||||
},
|
||||
[t, appId],
|
||||
);
|
||||
|
||||
return Object.assign(tApp, {
|
||||
has: useCallback(
|
||||
(key: string | undefined) => {
|
||||
if (!key) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return t.has(Utilities.getI18nKeyForApp(key, appId)) || t.has(key);
|
||||
},
|
||||
[t, appId],
|
||||
),
|
||||
});
|
||||
};
|
||||
39
apps/meteor/client/views/marketplace/hooks/useApps.spec.ts
Normal file
39
apps/meteor/client/views/marketplace/hooks/useApps.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { App } from '@rocket.chat/core-typings';
|
||||
import type { UseQueryResult } from '@tanstack/react-query';
|
||||
|
||||
import { storeQueryFunction } from './useApps';
|
||||
import { createFakeApp } from '../../../../tests/mocks/data';
|
||||
import { createFakeAppInstalledMarketplace, createFakeAppPrivate } from '../../../../tests/mocks/data/marketplace';
|
||||
|
||||
describe(`when an app installed from the Marketplace, but has since been unpublished`, () => {
|
||||
it(`should still be present in the installed app data provided`, () => {
|
||||
const marketplaceMockQuery = {
|
||||
data: [createFakeApp({ id: 'marketplace-1' }), createFakeAppInstalledMarketplace({ id: 'marketplace-2' })],
|
||||
isFetched: true,
|
||||
} as unknown as UseQueryResult<App[], unknown>;
|
||||
|
||||
const instanceMockQuery = {
|
||||
data: [
|
||||
marketplaceMockQuery.data?.[1],
|
||||
createFakeAppInstalledMarketplace({ id: 'marketplace-3' }), // This app has been installed via Marketplace but has been unpublished since
|
||||
createFakeAppPrivate({ id: 'private-1' }),
|
||||
],
|
||||
isFetched: true,
|
||||
} as unknown as UseQueryResult<App[], unknown>;
|
||||
|
||||
const [marketplaceList, installedList, privateList] = storeQueryFunction(marketplaceMockQuery, instanceMockQuery);
|
||||
|
||||
expect(marketplaceList.find((app) => app.id === 'marketplace-1')).toBeTruthy();
|
||||
expect(marketplaceList.find((app) => app.id === 'marketplace-2')).toBeTruthy();
|
||||
expect(marketplaceList.find((app) => app.id === 'marketplace-3')).toBeUndefined();
|
||||
expect(marketplaceList).toHaveLength(2);
|
||||
|
||||
expect(installedList.find((app) => app.id === 'marketplace-1')).toBeUndefined();
|
||||
expect(installedList.find((app) => app.id === 'marketplace-2')).toBeTruthy();
|
||||
expect(installedList.find((app) => app.id === 'marketplace-3')).toBeTruthy();
|
||||
expect(installedList).toHaveLength(2);
|
||||
|
||||
expect(privateList.find((app) => app.id === 'private-1')).toBeTruthy();
|
||||
expect(privateList).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
180
apps/meteor/client/views/marketplace/hooks/useApps.ts
Normal file
180
apps/meteor/client/views/marketplace/hooks/useApps.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { useDebouncedCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import { useInvalidateLicense } from '@rocket.chat/ui-client';
|
||||
import { usePermission, useStream } from '@rocket.chat/ui-contexts';
|
||||
import type { UseQueryOptions, UseQueryResult } from '@tanstack/react-query';
|
||||
import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useContext, useEffect } from 'react';
|
||||
|
||||
import { useInvalidateAppsCountQueryCallback } from './useAppsCountQuery';
|
||||
import { AppsContext } from '../../../contexts/AppsContext';
|
||||
import { useIsEnterprise } from '../../../hooks/useIsEnterprise';
|
||||
import { marketplaceQueryKeys } from '../../../lib/queryKeys';
|
||||
import type { App } from '../types';
|
||||
|
||||
const sortByName = (apps: App[]): App[] => apps.toSorted((a, b) => (a.name.toLowerCase() > b.name.toLowerCase() ? 1 : -1));
|
||||
|
||||
/**
|
||||
* Aggregates result data from marketplace request and instance installed into their appropriate lists
|
||||
*
|
||||
* Exporting for better testing
|
||||
*/
|
||||
export const storeQueryFunction = (
|
||||
marketplace: UseQueryResult<App[], unknown>,
|
||||
instance: UseQueryResult<App[], unknown>,
|
||||
): [App[], App[], App[]] => {
|
||||
if (!marketplace.isFetched && !instance.isFetched) {
|
||||
throw new Error('Apps not loaded');
|
||||
}
|
||||
|
||||
const marketplaceApps: App[] = [];
|
||||
const installedApps: App[] = [];
|
||||
const privateApps: App[] = [];
|
||||
const clonedData = [...(instance.data || [])];
|
||||
|
||||
sortByName(marketplace.data || []).forEach((app) => {
|
||||
const appIndex = clonedData.findIndex(({ id }) => id === app.id);
|
||||
const [installedApp] = appIndex > -1 ? clonedData.splice(appIndex, 1) : [];
|
||||
|
||||
const record = {
|
||||
...app,
|
||||
...(installedApp && {
|
||||
private: installedApp.private,
|
||||
clusterStatus: installedApp.clusterStatus,
|
||||
installed: true,
|
||||
status: installedApp.status,
|
||||
version: installedApp.version,
|
||||
licenseValidation: installedApp.licenseValidation,
|
||||
migrated: installedApp.migrated,
|
||||
installedAddon: installedApp.addon,
|
||||
}),
|
||||
bundledIn: app.bundledIn,
|
||||
marketplaceVersion: app.version,
|
||||
};
|
||||
|
||||
if (installedApp) {
|
||||
if (installedApp.private) {
|
||||
privateApps.push(record);
|
||||
} else {
|
||||
installedApps.push(record);
|
||||
}
|
||||
}
|
||||
|
||||
marketplaceApps.push(record);
|
||||
});
|
||||
|
||||
sortByName(clonedData).forEach((app) => {
|
||||
if (app.private) {
|
||||
privateApps.push(app);
|
||||
return;
|
||||
}
|
||||
|
||||
installedApps.push(app);
|
||||
});
|
||||
|
||||
return [marketplaceApps, installedApps, privateApps];
|
||||
};
|
||||
|
||||
export const useApps = <
|
||||
TData = {
|
||||
installedApps: App[];
|
||||
marketplaceApps: App[];
|
||||
privateApps: App[];
|
||||
},
|
||||
>(
|
||||
options?: Omit<
|
||||
UseQueryOptions<
|
||||
{
|
||||
installedApps: App[];
|
||||
marketplaceApps: App[];
|
||||
privateApps: App[];
|
||||
},
|
||||
Error,
|
||||
TData,
|
||||
ReturnType<typeof marketplaceQueryKeys.appsStored>
|
||||
>,
|
||||
'queryKey' | 'queryFn' | 'enabled' | 'placeholderData'
|
||||
>,
|
||||
) => {
|
||||
const orchestrator = useContext(AppsContext);
|
||||
|
||||
if (!orchestrator) throw new Error('Apps Orchestrator is not available');
|
||||
|
||||
const canManageApps = usePermission('manage-apps');
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: isEnterprise } = useIsEnterprise();
|
||||
|
||||
const invalidateAppsCountQuery = useInvalidateAppsCountQueryCallback();
|
||||
const invalidateLicenseQuery = useInvalidateLicense();
|
||||
|
||||
const stream = useStream('apps');
|
||||
|
||||
const invalidate = useDebouncedCallback(
|
||||
() => {
|
||||
queryClient.invalidateQueries({ queryKey: marketplaceQueryKeys.appsInstance() });
|
||||
invalidateAppsCountQuery();
|
||||
},
|
||||
100,
|
||||
[queryClient, invalidateAppsCountQuery],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return stream('apps', ([key]) => {
|
||||
if (['app/added', 'app/removed', 'app/updated', 'app/statusUpdate', 'app/settingUpdated'].includes(key)) {
|
||||
invalidate();
|
||||
}
|
||||
if (['app/added', 'app/removed'].includes(key) && !isEnterprise) {
|
||||
invalidateLicenseQuery();
|
||||
}
|
||||
});
|
||||
}, [invalidate, invalidateLicenseQuery, isEnterprise, stream]);
|
||||
|
||||
const marketplace = useQuery({
|
||||
queryKey: marketplaceQueryKeys.appsMarketplace(canManageApps),
|
||||
queryFn: async () => {
|
||||
const result = await orchestrator.getAppsFromMarketplace(canManageApps);
|
||||
if (result.error && typeof result.error === 'string') {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
return result.apps;
|
||||
},
|
||||
staleTime: Infinity,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const instance = useQuery({
|
||||
queryKey: marketplaceQueryKeys.appsInstance(canManageApps),
|
||||
queryFn: async () => {
|
||||
const result = await orchestrator.getInstalledApps().then((result: App[]) =>
|
||||
result.map((current: App) => ({
|
||||
...current,
|
||||
installed: true,
|
||||
})),
|
||||
);
|
||||
return result;
|
||||
},
|
||||
staleTime: Infinity,
|
||||
refetchOnMount: 'always',
|
||||
});
|
||||
|
||||
return useQuery({
|
||||
queryKey: marketplaceQueryKeys.appsStored(instance.data, marketplace.data),
|
||||
queryFn: async () => {
|
||||
if (marketplace.isError) {
|
||||
throw marketplace.error;
|
||||
}
|
||||
|
||||
const [marketplaceAppsData, installedAppsData, privateAppsData] = storeQueryFunction(marketplace, instance);
|
||||
|
||||
return {
|
||||
installedApps: installedAppsData,
|
||||
marketplaceApps: marketplaceAppsData,
|
||||
privateApps: privateAppsData,
|
||||
};
|
||||
},
|
||||
enabled: marketplace.isFetched && instance.isFetched,
|
||||
placeholderData: keepPreviousData,
|
||||
...options,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import { useQueryClient, useQuery } from '@tanstack/react-query';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
export const useAppsCountQuery = (context: MarketplaceRouteContext) => {
|
||||
const getAppsCount = useEndpoint('GET', '/apps/count');
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['apps/count', context],
|
||||
|
||||
queryFn: async () => {
|
||||
const data = await getAppsCount();
|
||||
|
||||
const numberOfEnabledApps = context === 'private' ? data.totalPrivateEnabled : data.totalMarketplaceEnabled;
|
||||
const enabledAppsLimit = context === 'private' ? data.maxPrivateApps : data.maxMarketplaceApps;
|
||||
const hasUnlimitedApps = enabledAppsLimit === -1;
|
||||
|
||||
return {
|
||||
hasUnlimitedApps,
|
||||
enabled: numberOfEnabledApps,
|
||||
limit: enabledAppsLimit,
|
||||
// tooltip,
|
||||
};
|
||||
},
|
||||
|
||||
staleTime: 10_000,
|
||||
});
|
||||
};
|
||||
|
||||
export const useInvalidateAppsCountQueryCallback = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useCallback(() => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['apps/count'],
|
||||
});
|
||||
}, [queryClient]);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import { useContext } from 'react';
|
||||
|
||||
import { AppsContext } from '../../../contexts/AppsContext';
|
||||
|
||||
export const useAppsOrchestration = () => useContext(AppsContext);
|
||||
11
apps/meteor/client/views/marketplace/hooks/useAppsReload.ts
Normal file
11
apps/meteor/client/views/marketplace/hooks/useAppsReload.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { marketplaceQueryKeys } from '../../../lib/queryKeys';
|
||||
|
||||
export const useAppsReload = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: marketplaceQueryKeys.all });
|
||||
}, [queryClient]);
|
||||
};
|
||||
68
apps/meteor/client/views/marketplace/hooks/useCategories.ts
Normal file
68
apps/meteor/client/views/marketplace/hooks/useCategories.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useCategoryFlatList } from './useCategoryFlatList';
|
||||
import { useCategoryToggle } from './useCategoryToggle';
|
||||
import { AppClientOrchestratorInstance } from '../../../apps/orchestrator';
|
||||
import type {
|
||||
CategoryDropDownGroups,
|
||||
CategoryDropdownItem,
|
||||
CategoryDropDownListProps,
|
||||
CategoryOnSelected,
|
||||
selectedCategoriesList,
|
||||
} from '../definitions/CategoryDropdownDefinitions';
|
||||
import { handleAPIError } from '../helpers/handleAPIError';
|
||||
|
||||
export const useCategories = (): [CategoryDropDownGroups, selectedCategoriesList, selectedCategoriesList, CategoryOnSelected] => {
|
||||
const { t } = useTranslation();
|
||||
const [categories, setCategories] = useState<CategoryDropDownListProps['categories']>([]);
|
||||
|
||||
const fetchCategories = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const fetchedCategories = await AppClientOrchestratorInstance.getCategories();
|
||||
|
||||
const mappedCategories = fetchedCategories
|
||||
.filter((currentCategory) => !currentCategory.hidden)
|
||||
.map((currentCategory) => ({
|
||||
id: currentCategory.id,
|
||||
label: currentCategory.title,
|
||||
checked: false,
|
||||
}));
|
||||
|
||||
setCategories([
|
||||
{
|
||||
items: [
|
||||
{
|
||||
id: 'all',
|
||||
label: t('All_categories'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t('Filter_by_category'),
|
||||
items: mappedCategories,
|
||||
},
|
||||
]);
|
||||
} catch (e: any) {
|
||||
handleAPIError(e);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCategoriesWrapper = async (): Promise<void> => {
|
||||
await fetchCategories();
|
||||
};
|
||||
|
||||
fetchCategoriesWrapper();
|
||||
}, [fetchCategories]);
|
||||
|
||||
const onSelected = useCategoryToggle(setCategories);
|
||||
const flatCategories = useCategoryFlatList(categories);
|
||||
const originalSize = useCategoryFlatList(categories).length;
|
||||
|
||||
const selectedCategories = useMemo(
|
||||
() => flatCategories.filter((category) => Boolean(category.checked)),
|
||||
[flatCategories],
|
||||
) as (CategoryDropdownItem & { checked: true })[];
|
||||
return [categories, selectedCategories, originalSize === selectedCategories.length ? [] : selectedCategories, onSelected];
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import type { CategoryDropdownItem, CategoryDropDownListProps } from '../definitions/CategoryDropdownDefinitions';
|
||||
|
||||
export const useCategoryFlatList = (categories: CategoryDropDownListProps['categories']): CategoryDropdownItem[] =>
|
||||
useMemo(() => categories.flatMap((group) => group.items).filter(({ id }) => id !== 'all'), [categories]);
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import type { CategoryDropdownItem, CategoryDropDownListProps } from '../definitions/CategoryDropdownDefinitions';
|
||||
|
||||
export const useCategoryToggle = (
|
||||
setData: Dispatch<SetStateAction<CategoryDropDownListProps['categories']>>,
|
||||
): CategoryDropDownListProps['onSelected'] => {
|
||||
const onSelected = useCallback(
|
||||
(item: CategoryDropdownItem) =>
|
||||
setData((prev) => {
|
||||
const categories = prev.flatMap((group) => group.items);
|
||||
|
||||
const categoriesWithoutAll = categories.filter(({ id }) => id !== 'all');
|
||||
const allCategoriesOption = categories.find(({ id }) => id === 'all');
|
||||
const toggledCategory = categories.find(({ id }) => id === item.id);
|
||||
|
||||
const isAllCategoriesToggled = item.id === 'all';
|
||||
if (isAllCategoriesToggled) {
|
||||
categoriesWithoutAll.forEach((currentItem) => {
|
||||
currentItem.checked = !item.checked;
|
||||
});
|
||||
}
|
||||
|
||||
if (toggledCategory) {
|
||||
toggledCategory.checked = !toggledCategory.checked;
|
||||
}
|
||||
|
||||
if (allCategoriesOption && categoriesWithoutAll.some((currentCategory) => currentCategory.checked === false)) {
|
||||
allCategoriesOption.checked = false;
|
||||
}
|
||||
|
||||
return [...prev];
|
||||
}),
|
||||
[setData],
|
||||
);
|
||||
|
||||
return onSelected;
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { OperationResult } from '@rocket.chat/rest-typings';
|
||||
import { useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import type { UseQueryResult } from '@tanstack/react-query';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
export const useFeaturedApps = (): UseQueryResult<OperationResult<'GET', '/apps/featured-apps'>> => {
|
||||
const featuredApps = useEndpoint('GET', '/apps/featured-apps');
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['featured-apps'],
|
||||
queryFn: () => featuredApps(),
|
||||
});
|
||||
};
|
||||
130
apps/meteor/client/views/marketplace/hooks/useFilteredApps.ts
Normal file
130
apps/meteor/client/views/marketplace/hooks/useFilteredApps.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import type { PaginatedResult } from '@rocket.chat/rest-typings';
|
||||
|
||||
import { useApps } from './useApps';
|
||||
import { filterAppsByCategories } from '../helpers/filterAppsByCategories';
|
||||
import { filterAppsByDisabled } from '../helpers/filterAppsByDisabled';
|
||||
import { filterAppsByEnabled } from '../helpers/filterAppsByEnabled';
|
||||
import { filterAppsByEnterprise } from '../helpers/filterAppsByEnterprise';
|
||||
import { filterAppsByFree } from '../helpers/filterAppsByFree';
|
||||
import { filterAppsByPaid } from '../helpers/filterAppsByPaid';
|
||||
import { filterAppsByText } from '../helpers/filterAppsByText';
|
||||
import { sortAppsByAlphabeticalOrInverseOrder } from '../helpers/sortAppsByAlphabeticalOrInverseOrder';
|
||||
import { sortAppsByClosestOrFarthestModificationDate } from '../helpers/sortAppsByClosestOrFarthestModificationDate';
|
||||
import type { App } from '../types';
|
||||
|
||||
export const useFilteredApps = ({
|
||||
text,
|
||||
current,
|
||||
itemsPerPage,
|
||||
categories = [],
|
||||
purchaseType,
|
||||
sortingMethod,
|
||||
status,
|
||||
context,
|
||||
}: {
|
||||
text: string;
|
||||
current: number;
|
||||
itemsPerPage: number;
|
||||
categories: string[];
|
||||
purchaseType: string;
|
||||
isEnterpriseOnly?: boolean;
|
||||
sortingMethod: string;
|
||||
status: string;
|
||||
context?: string;
|
||||
}) =>
|
||||
useApps({
|
||||
select: ({
|
||||
marketplaceApps,
|
||||
installedApps,
|
||||
privateApps,
|
||||
}): PaginatedResult<{
|
||||
items: App[];
|
||||
shouldShowSearchText: boolean;
|
||||
allApps: App[];
|
||||
totalAppsLength: number;
|
||||
}> => {
|
||||
const apps = (() => {
|
||||
switch (context) {
|
||||
case 'premium':
|
||||
case 'explore':
|
||||
case 'requested':
|
||||
return marketplaceApps;
|
||||
case 'private':
|
||||
return privateApps;
|
||||
default:
|
||||
return installedApps;
|
||||
}
|
||||
})();
|
||||
|
||||
const fallback = (apps: App[]) => apps;
|
||||
|
||||
const sortingMethods: Record<string, (apps: App[]) => App[]> = {
|
||||
urf: (apps: App[]) =>
|
||||
apps.toSorted(
|
||||
(firstApp, secondApp) => (secondApp?.appRequestStats?.totalUnseen || 0) - (firstApp?.appRequestStats?.totalUnseen || 0),
|
||||
),
|
||||
url: (apps: App[]) =>
|
||||
apps.toSorted(
|
||||
(firstApp, secondApp) => (firstApp?.appRequestStats?.totalUnseen || 0) - (secondApp?.appRequestStats?.totalUnseen || 0),
|
||||
),
|
||||
az: (apps: App[]) => apps.toSorted((firstApp, secondApp) => sortAppsByAlphabeticalOrInverseOrder(firstApp.name, secondApp.name)),
|
||||
za: (apps: App[]) => apps.toSorted((firstApp, secondApp) => sortAppsByAlphabeticalOrInverseOrder(secondApp.name, firstApp.name)),
|
||||
mru: (apps: App[]) =>
|
||||
apps.toSorted((firstApp, secondApp) => sortAppsByClosestOrFarthestModificationDate(firstApp.modifiedAt, secondApp.modifiedAt)),
|
||||
lru: (apps: App[]) =>
|
||||
apps.toSorted((firstApp, secondApp) => sortAppsByClosestOrFarthestModificationDate(secondApp.modifiedAt, firstApp.modifiedAt)),
|
||||
};
|
||||
|
||||
const filterByPurchaseType: Record<string, (apps: App[]) => App[]> = {
|
||||
all: fallback,
|
||||
paid: (apps: App[]) => apps.filter(filterAppsByPaid),
|
||||
premium: (apps: App[]) => apps.filter(filterAppsByEnterprise),
|
||||
free: (apps: App[]) => apps.filter(filterAppsByFree),
|
||||
};
|
||||
|
||||
const filterByStatus: Record<string, (apps: App[]) => App[]> = {
|
||||
all: fallback,
|
||||
enabled: (apps: App[]) => apps.filter(filterAppsByEnabled),
|
||||
disabled: (apps: App[]) => apps.filter(filterAppsByDisabled),
|
||||
};
|
||||
|
||||
const filterByContext: Record<string, (apps: App[]) => App[]> = {
|
||||
explore: fallback,
|
||||
installed: fallback,
|
||||
private: fallback,
|
||||
premium: (apps: App[]) => apps.filter(({ categories }) => categories.includes('Premium')),
|
||||
requested: (apps: App[]) => apps.filter(({ appRequestStats, installed }) => Boolean(appRequestStats) && !installed),
|
||||
};
|
||||
|
||||
type appsFilterFunction = (apps: App[]) => App[];
|
||||
const pipeAppsFilter =
|
||||
(...functions: appsFilterFunction[]) =>
|
||||
(initialValue: App[]) =>
|
||||
functions.reduce((currentAppsList, currentFilterFunction) => currentFilterFunction(currentAppsList), initialValue);
|
||||
|
||||
const filtered = pipeAppsFilter(
|
||||
context ? filterByContext[context] : fallback,
|
||||
filterByPurchaseType[purchaseType],
|
||||
filterByStatus[status],
|
||||
categories.length ? (apps: App[]) => apps.filter((app) => filterAppsByCategories(app, categories)) : fallback,
|
||||
text ? (apps: App[]) => apps.filter(({ name }) => filterAppsByText(name, text)) : fallback,
|
||||
sortingMethods[sortingMethod],
|
||||
)(apps);
|
||||
|
||||
const shouldShowSearchText = !!text;
|
||||
const total = filtered.length;
|
||||
const offset = current > total ? 0 : current;
|
||||
const end = current + itemsPerPage;
|
||||
const slice = filtered.slice(offset, end);
|
||||
|
||||
return {
|
||||
items: slice,
|
||||
offset,
|
||||
total: filtered.length,
|
||||
totalAppsLength: apps.length,
|
||||
count: slice.length,
|
||||
shouldShowSearchText,
|
||||
allApps: filtered,
|
||||
};
|
||||
},
|
||||
});
|
||||
138
apps/meteor/client/views/marketplace/hooks/useInstallApp.tsx
Normal file
138
apps/meteor/client/views/marketplace/hooks/useInstallApp.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import type { App, AppPermission } from '@rocket.chat/core-typings';
|
||||
import { useRouter, useSetModal, useUpload } from '@rocket.chat/ui-contexts';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { useAppsReload } from './useAppsReload';
|
||||
import { AppClientOrchestratorInstance } from '../../../apps/orchestrator';
|
||||
import { useIsEnterprise } from '../../../hooks/useIsEnterprise';
|
||||
import AppExemptModal from '../AppExemptModal';
|
||||
import AppPermissionsReviewModal from '../AppPermissionsReviewModal';
|
||||
import AppUpdateModal from '../AppUpdateModal';
|
||||
import { useAppsCountQuery } from './useAppsCountQuery';
|
||||
import { handleAPIError } from '../helpers/handleAPIError';
|
||||
import { handleInstallError } from '../helpers/handleInstallError';
|
||||
import { getManifestFromZippedApp } from '../lib/getManifestFromZippedApp';
|
||||
|
||||
export const useInstallApp = (file: File): { install: () => void; isInstalling: boolean } => {
|
||||
const reloadAppsList = useAppsReload();
|
||||
const setModal = useSetModal();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const appCountQuery = useAppsCountQuery('private');
|
||||
|
||||
const uploadAppEndpoint = useUpload('/apps');
|
||||
const uploadUpdateEndpoint = useUpload('/apps/update');
|
||||
const { data } = useIsEnterprise();
|
||||
|
||||
const [isInstalling, setInstalling] = useState(false);
|
||||
|
||||
const { mutate: sendFile } = useMutation({
|
||||
mutationKey: ['apps/installPrivateApp'],
|
||||
|
||||
mutationFn: ({ permissionsGranted, appFile, appId }: { permissionsGranted: AppPermission[]; appFile: File; appId?: string }) => {
|
||||
const fileData = new FormData();
|
||||
fileData.append('app', appFile, appFile.name);
|
||||
fileData.append('permissions', JSON.stringify(permissionsGranted));
|
||||
|
||||
if (appId) {
|
||||
return uploadUpdateEndpoint(fileData);
|
||||
}
|
||||
|
||||
return uploadAppEndpoint(fileData) as any;
|
||||
},
|
||||
|
||||
onSuccess: (data: { app: App }) => {
|
||||
router.navigate({
|
||||
name: 'marketplace',
|
||||
params: {
|
||||
context: 'private',
|
||||
page: 'info',
|
||||
id: data.app.id,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
onError: (e) => {
|
||||
handleAPIError(e);
|
||||
},
|
||||
|
||||
onSettled: () => {
|
||||
setInstalling(false);
|
||||
setModal(null);
|
||||
reloadAppsList();
|
||||
},
|
||||
});
|
||||
|
||||
const cancelAction = useCallback(() => {
|
||||
setInstalling(false);
|
||||
setModal(null);
|
||||
}, [setInstalling, setModal]);
|
||||
|
||||
const isAppInstalled = async (appId: string) => {
|
||||
try {
|
||||
const app = await AppClientOrchestratorInstance.getApp(appId);
|
||||
return !!app || false;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleAppPermissionsReview = async (permissions: AppPermission[], appFile: File, appId?: string) => {
|
||||
setModal(
|
||||
<AppPermissionsReviewModal
|
||||
appPermissions={permissions}
|
||||
onCancel={cancelAction}
|
||||
onConfirm={(permissionsGranted) => sendFile({ permissionsGranted, appFile, appId })}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
|
||||
const uploadFile = async (appFile: File, { id, permissions }: { id: string; permissions: AppPermission[] }) => {
|
||||
const isInstalled = await isAppInstalled(id);
|
||||
|
||||
const isExempt = !data?.isEnterprise && isInstalled;
|
||||
if (isInstalled && isExempt) {
|
||||
return setModal(<AppExemptModal appName={appFile.name} onCancel={cancelAction} />);
|
||||
}
|
||||
|
||||
if (isInstalled) {
|
||||
return setModal(<AppUpdateModal cancel={cancelAction} confirm={() => handleAppPermissionsReview(permissions, appFile, id)} />);
|
||||
}
|
||||
|
||||
await handleAppPermissionsReview(permissions, appFile);
|
||||
};
|
||||
|
||||
const extractManifestFromAppFile = async (appFile: File) => {
|
||||
try {
|
||||
return getManifestFromZippedApp(appFile);
|
||||
} catch (error) {
|
||||
handleInstallError(error as Error);
|
||||
}
|
||||
};
|
||||
|
||||
const install = async () => {
|
||||
setInstalling(true);
|
||||
|
||||
if (!appCountQuery.data) {
|
||||
return cancelAction();
|
||||
}
|
||||
|
||||
const appFile = file;
|
||||
|
||||
if (!appFile) {
|
||||
return cancelAction();
|
||||
}
|
||||
|
||||
const manifest = await extractManifestFromAppFile(appFile);
|
||||
|
||||
if (!manifest) {
|
||||
return cancelAction();
|
||||
}
|
||||
|
||||
return uploadFile(appFile, manifest);
|
||||
};
|
||||
|
||||
return { install, isInstalling };
|
||||
};
|
||||
46
apps/meteor/client/views/marketplace/hooks/useLogs.ts
Normal file
46
apps/meteor/client/views/marketplace/hooks/useLogs.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { OperationResult } from '@rocket.chat/rest-typings';
|
||||
import { useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import type { UseQueryResult } from '@tanstack/react-query';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { marketplaceQueryKeys } from '../../../lib/queryKeys';
|
||||
|
||||
export const useLogs = ({
|
||||
appId,
|
||||
current,
|
||||
itemsPerPage,
|
||||
logLevel,
|
||||
method,
|
||||
startDate,
|
||||
endDate,
|
||||
instanceId,
|
||||
}: {
|
||||
appId: string;
|
||||
current: number;
|
||||
itemsPerPage: number;
|
||||
logLevel?: '0' | '1' | '2';
|
||||
method?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
instanceId?: string;
|
||||
}): UseQueryResult<OperationResult<'GET', '/apps/:id/logs'>> => {
|
||||
const query = useMemo(
|
||||
() => ({
|
||||
count: itemsPerPage,
|
||||
offset: current,
|
||||
...(logLevel && { logLevel }),
|
||||
...(method && { method }),
|
||||
...(startDate && { startDate }),
|
||||
...(endDate && { endDate }),
|
||||
...(instanceId && { instanceId }),
|
||||
}),
|
||||
[itemsPerPage, current, logLevel, method, startDate, endDate, instanceId],
|
||||
);
|
||||
const logs = useEndpoint('GET', '/apps/:id/logs', { id: appId });
|
||||
|
||||
return useQuery({
|
||||
queryKey: marketplaceQueryKeys.appLogs(appId, query),
|
||||
queryFn: () => logs(query),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { OperationResult } from '@rocket.chat/rest-typings';
|
||||
import { useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import type { UseQueryResult } from '@tanstack/react-query';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
export const useLogsDistinctValues = (appId: string): UseQueryResult<OperationResult<'GET', '/apps/:id/logs/distinctValues'>> => {
|
||||
const getValues = useEndpoint('GET', '/apps/:id/logs/distinctValues', { id: appId });
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['app-logs-filter-distinct-values', appId],
|
||||
queryFn: async () => getValues(),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { App, AppPermission } from '@rocket.chat/core-typings';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { useAppsOrchestration } from './useAppsOrchestration';
|
||||
import { handleAPIError } from '../helpers/handleAPIError';
|
||||
import { warnAppInstall } from '../helpers/warnAppInstall';
|
||||
import { warnStatusChange } from '../helpers/warnStatusChange';
|
||||
|
||||
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({
|
||||
mutationFn: ({ 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({
|
||||
mutationFn: ({ 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;
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { App } from '@rocket.chat/core-typings';
|
||||
import { useSetModal } from '@rocket.chat/ui-contexts';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import type { AppPermissionsReviewModalProps } from '../AppPermissionsReviewModal';
|
||||
import AppPermissionsReviewModal from '../AppPermissionsReviewModal';
|
||||
|
||||
export const useOpenAppPermissionsReviewModal: (params: {
|
||||
app: App;
|
||||
onCancel: AppPermissionsReviewModalProps['onCancel'];
|
||||
onConfirm: AppPermissionsReviewModalProps['onConfirm'];
|
||||
}) => () => void = ({ app, onCancel, onConfirm }) => {
|
||||
const setModal = useSetModal();
|
||||
|
||||
return useCallback(() => {
|
||||
const handleCancel = () => {
|
||||
setModal(null);
|
||||
onCancel();
|
||||
};
|
||||
|
||||
const handleConfirm: typeof onConfirm = (appPermissions) => {
|
||||
setModal(null);
|
||||
onConfirm(appPermissions);
|
||||
};
|
||||
|
||||
return setModal(<AppPermissionsReviewModal appPermissions={app.permissions} onCancel={handleCancel} onConfirm={handleConfirm} />);
|
||||
}, [app.permissions, onCancel, onConfirm, setModal]);
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { App } from '@rocket.chat/core-typings';
|
||||
import { useSetModal } from '@rocket.chat/ui-contexts';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import IframeModal from '../IframeModal';
|
||||
import { useAppsOrchestration } from './useAppsOrchestration';
|
||||
import { handleAPIError } from '../helpers/handleAPIError';
|
||||
|
||||
export const useOpenIncompatibleModal = () => {
|
||||
const setModal = useSetModal();
|
||||
|
||||
const appsOrchestrator = useAppsOrchestration();
|
||||
|
||||
if (!appsOrchestrator) {
|
||||
throw new Error('Apps orchestrator is not available');
|
||||
}
|
||||
|
||||
return useCallback(
|
||||
async (app: App, actionName: string, cancelAction: () => void) => {
|
||||
const handleCancel = () => {
|
||||
setModal(null);
|
||||
cancelAction();
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
setModal(null);
|
||||
cancelAction();
|
||||
};
|
||||
|
||||
try {
|
||||
const incompatibleData = await appsOrchestrator.buildIncompatibleExternalUrl(app.id, app.marketplaceVersion, actionName);
|
||||
setModal(<IframeModal url={incompatibleData.url} cancel={handleCancel} confirm={handleConfirm} />);
|
||||
} catch (e) {
|
||||
handleAPIError(e);
|
||||
}
|
||||
},
|
||||
[appsOrchestrator, setModal],
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import { useLicense } from '@rocket.chat/ui-client';
|
||||
|
||||
export const usePrivateAppsEnabled = () => (useLicense({ loadValues: true }).data?.limits?.privateApps?.max ?? 0) !== 0;
|
||||
18
apps/meteor/client/views/marketplace/hooks/useRadioToggle.ts
Normal file
18
apps/meteor/client/views/marketplace/hooks/useRadioToggle.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import type { RadioDropDownItem, RadioDropDownOnSelected, RadioDropDownSetData } from '../definitions/RadioDropDownDefinitions';
|
||||
|
||||
export const useRadioToggle = (setData: RadioDropDownSetData): RadioDropDownOnSelected => {
|
||||
const onSelected = useCallback(
|
||||
(item: RadioDropDownItem) => {
|
||||
setData((prevState) => {
|
||||
prevState.items.forEach((currentItem) => {
|
||||
currentItem.checked = currentItem === item;
|
||||
});
|
||||
return { ...prevState };
|
||||
});
|
||||
},
|
||||
[setData],
|
||||
);
|
||||
return onSelected;
|
||||
};
|
||||
Reference in New Issue
Block a user