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

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -0,0 +1,146 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ReactNode } from 'react';
import AppDetailsPage from './AppDetailsPage';
import { AppClientOrchestratorInstance } from '../../../apps/orchestrator';
import { useAppInfo } from '../hooks/useAppInfo';
jest.mock('../hooks/useAppInfo', () => ({
useAppInfo: jest.fn(),
}));
jest.mock('@rocket.chat/ui-contexts', () => {
const originalModule = jest.requireActual('@rocket.chat/ui-contexts');
return {
...originalModule,
useRouter: () => ({ navigate: jest.fn() }),
useToastMessageDispatch: () => jest.fn(),
usePermission: () => true,
useRouteParameter: () => 'settings',
};
});
jest.mock('@rocket.chat/ui-client', () => {
const originalModule = jest.requireActual('@rocket.chat/ui-client');
return {
...originalModule,
PageHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
PageFooter: ({ children, isDirty }: { children: ReactNode; isDirty: boolean }) => isDirty && <div>{children}</div>,
};
});
jest.mock('./AppDetailsPageHeader', () => ({
__esModule: true,
default: () => <div>AppDetailsPageHeader</div>,
}));
jest.mock('../../../apps/orchestrator', () => ({
AppClientOrchestratorInstance: {
setAppSettings: jest.fn(),
},
}));
const wrapper = mockAppRoot().withTranslations('en', 'core', { Save_changes: 'Save changes' });
describe('AppDetailsPage', () => {
beforeEach(() => {
(useAppInfo as jest.Mock).mockReturnValue({
id: 'app123',
name: 'Test App',
installed: true,
settings: {
setting1: { id: 'setting1', value: 'old-value', packageValue: 'default-value', type: 'string' },
},
privacyPolicySummary: '',
permissions: [],
tosLink: '',
privacyLink: '',
});
(AppClientOrchestratorInstance.setAppSettings as jest.Mock).mockReset();
});
it('should not display the Save button initially', async () => {
render(<AppDetailsPage id='app123' />, {
wrapper: wrapper.build(),
});
await waitFor(() => {
expect(screen.queryByRole('button', { name: 'Save changes' })).not.toBeInTheDocument();
});
});
it('should display the Save button when a setting is changed', async () => {
render(<AppDetailsPage id='app123' />, {
wrapper: wrapper.build(),
});
const settingInput = screen.getByLabelText('setting1');
await userEvent.clear(settingInput);
await userEvent.type(settingInput, 'new-value');
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Save changes' })).toBeVisible();
});
});
it('should disable the Save button during submission', async () => {
(AppClientOrchestratorInstance.setAppSettings as jest.Mock).mockResolvedValueOnce(new Promise((resolve) => setTimeout(resolve, 500)));
render(<AppDetailsPage id='app123' />, {
wrapper: wrapper.build(),
});
const settingInput = screen.getByLabelText('setting1');
await userEvent.clear(settingInput);
await userEvent.type(settingInput, 'new-value');
const saveButton = screen.getByRole('button', { name: 'Save changes' });
await userEvent.click(saveButton);
await waitFor(() => {
expect(saveButton).toBeDisabled();
});
});
it('should hide the Save button after successful save', async () => {
(AppClientOrchestratorInstance.setAppSettings as jest.Mock).mockResolvedValueOnce(new Promise((resolve) => setTimeout(resolve, 500)));
render(<AppDetailsPage id='app123' />, {
wrapper: wrapper.build(),
});
const settingInput = screen.getByLabelText('setting1');
await userEvent.clear(settingInput);
await userEvent.type(settingInput, 'new-value');
const saveButton = screen.getByRole('button', { name: 'Save changes' });
await userEvent.click(saveButton);
await waitFor(() => {
expect(screen.queryByRole('button', { name: 'Save changes' })).not.toBeInTheDocument();
});
});
it('should call setAppSettings with updated setting value', async () => {
(AppClientOrchestratorInstance.setAppSettings as jest.Mock).mockResolvedValueOnce(new Promise((resolve) => setTimeout(resolve, 500)));
render(<AppDetailsPage id='app123' />, {
wrapper: wrapper.build(),
});
const settingInput = screen.getByLabelText('setting1');
await userEvent.clear(settingInput);
await userEvent.type(settingInput, 'new-value');
const saveButton = screen.getByRole('button', { name: 'Save changes' });
await userEvent.click(saveButton);
await waitFor(() => {
expect(AppClientOrchestratorInstance.setAppSettings as jest.Mock).toHaveBeenCalledWith('app123', [
{ id: 'setting1', packageValue: 'default-value', type: 'string', value: 'new-value' },
]);
});
});
});

View File

@@ -0,0 +1,171 @@
import type { App, SettingValue } from '@rocket.chat/core-typings';
import { Button, ButtonGroup, Box } from '@rocket.chat/fuselage';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { Page, PageFooter, PageHeader, PageScrollableContentWithShadow } from '@rocket.chat/ui-client';
import { useTranslation, useRouteParameter, useToastMessageDispatch, usePermission, useRouter } from '@rocket.chat/ui-contexts';
import { useMemo, useCallback } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import AppDetailsPageHeader from './AppDetailsPageHeader';
import AppDetailsPageLoading from './AppDetailsPageLoading';
import AppDetailsPageTabs from './AppDetailsPageTabs';
import { handleAPIError } from '../helpers/handleAPIError';
import { useAppInfo } from '../hooks/useAppInfo';
import AppDetails from './tabs/AppDetails';
import AppInstances from './tabs/AppInstances';
import AppLogs from './tabs/AppLogs';
import { AppLogsFilterContextualBar } from './tabs/AppLogs/Filters/AppLogsFilterContextualBar';
import { useAppLogsFilterForm } from './tabs/AppLogs/useAppLogsFilterForm';
import AppReleases from './tabs/AppReleases';
import AppRequests from './tabs/AppRequests/AppRequests';
import AppSecurity from './tabs/AppSecurity/AppSecurity';
import AppSettings from './tabs/AppSettings';
import { useCompactMode } from './useCompactMode';
import { AppClientOrchestratorInstance } from '../../../apps/orchestrator';
type AppDetailsPageFormData = Record<string, SettingValue>;
export type AppDetailsPageProps = {
id: App['id'];
};
const AppDetailsPage = ({ id }: AppDetailsPageProps) => {
const t = useTranslation();
const router = useRouter();
const dispatchToastMessage = useToastMessageDispatch();
const isAdminUser = usePermission('manage-apps');
const tab = useRouteParameter('tab');
const context = useRouteParameter('context');
const contextualBar = useRouteParameter('contextualBar');
const appData = useAppInfo(id, context || '');
const compactMode = useCompactMode();
const handleReturn = useStableCallback((): void => {
if (!context) {
return;
}
router.navigate({
name: 'marketplace',
params: { context, page: 'list' },
});
});
const handleReturnToLogs = useStableCallback((): void => {
if (!context) {
return;
}
router.navigate(
{
name: 'marketplace',
params: { ...router.getRouteParameters(), contextualBar: '' },
},
{ replace: true },
);
});
const { installed, settings, privacyPolicySummary, permissions, tosLink, privacyLink, name } = appData || {};
const isSecurityVisible = Boolean(privacyPolicySummary || permissions || tosLink || privacyLink);
const reducedSettings = useMemo((): AppDetailsPageFormData => {
return Object.values(settings || {}).reduce(
(ret: AppDetailsPageFormData, { id, value, packageValue }) => ({ ...ret, [id]: value ?? packageValue }),
{},
);
}, [settings]);
const settingsFormMethods = useForm<AppDetailsPageFormData>({ values: reducedSettings });
const {
handleSubmit,
reset,
formState: { isDirty, isSubmitting },
} = settingsFormMethods;
const logsFilterFormMethods = useAppLogsFilterForm();
const saveAppSettings = useCallback(
async (data: AppDetailsPageFormData) => {
try {
await AppClientOrchestratorInstance.setAppSettings(
id,
Object.values(settings || {}).map((setting) => ({
...setting,
value: data[setting.id],
})),
);
reset(data);
dispatchToastMessage({ type: 'success', message: t('App_Settings_Saved_Successfully', { appName: name }) });
} catch (e: any) {
handleAPIError(e);
}
},
[id, settings, reset, dispatchToastMessage, t, name],
);
return (
<Page flexDirection='row'>
<Page flexDirection='column' height='full'>
<PageHeader title={t('App_Info')} onClickBack={handleReturn} />
<PageScrollableContentWithShadow paddingInline={24} paddingBlockStart={24} paddingBlockEnd={0} height='full'>
<Box width='full' alignSelf='center' height='full' display='flex' flexDirection='column'>
{!appData && <AppDetailsPageLoading />}
{appData && (
<>
<AppDetailsPageHeader app={appData} />
<AppDetailsPageTabs
context={context || ''}
installed={installed}
isSecurityVisible={isSecurityVisible}
settings={settings}
tab={tab}
hasCluster={!!appData.clusterStatus}
/>
{Boolean(!tab || tab === 'details') && <AppDetails app={appData} />}
{tab === 'requests' && <AppRequests id={id} isAdminUser={isAdminUser} />}
{tab === 'security' && isSecurityVisible && (
<AppSecurity
privacyPolicySummary={privacyPolicySummary}
appPermissions={permissions}
tosLink={tosLink}
privacyLink={privacyLink}
/>
)}
{tab === 'releases' && <AppReleases id={id} />}
{Boolean(tab === 'settings' && settings && Object.values(settings).length) && (
<FormProvider {...settingsFormMethods}>
<AppSettings settings={settings || {}} />
</FormProvider>
)}
{(tab === 'logs' || tab === 'logs-filter') && (
<FormProvider {...logsFilterFormMethods}>
<AppLogs id={id} />
</FormProvider>
)}
{tab === 'instances' && <AppInstances id={id} />}
</>
)}
</Box>
</PageScrollableContentWithShadow>
<PageFooter isDirty={isDirty}>
<ButtonGroup>
<Button onClick={() => reset()}>{t('Cancel')}</Button>
{installed && isAdminUser && (
<Button primary loading={isSubmitting} onClick={handleSubmit(saveAppSettings)}>
{t('Save_changes')}
</Button>
)}
</ButtonGroup>
</PageFooter>
</Page>
{compactMode && contextualBar === 'filter-logs' && (
<FormProvider {...logsFilterFormMethods}>
<AppLogsFilterContextualBar appId={id} onClose={handleReturnToLogs} />
</FormProvider>
)}
</Page>
);
};
export default AppDetailsPage;

View File

@@ -0,0 +1,102 @@
import type { App } from '@rocket.chat/core-typings';
import { Box, Tag } from '@rocket.chat/fuselage';
import { AppAvatar } from '@rocket.chat/ui-avatar';
import { formatDistanceToNow } from 'date-fns';
import { useTranslation } from 'react-i18next';
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, installed } = app;
return installed ? version : marketplaceVersion;
};
export type AppDetailsPageHeaderProps = { app: App };
const AppDetailsPageHeader = ({ app }: AppDetailsPageHeaderProps) => {
const { t } = useTranslation();
const {
iconFileData,
name,
author,
iconFileContent,
installed,
modifiedAt,
bundledIn,
versionIncompatible,
isSubscribed,
shortDescription,
} = app;
const lastUpdated = modifiedAt && formatDistanceToNow(new Date(modifiedAt), { addSuffix: true });
const incompatibleStatus = versionIncompatible ? appIncompatibleStatusProps() : undefined;
return (
<Box color='default' display='flex' flexDirection='row' marginBlockEnd={20} width='full'>
<Box marginInlineEnd={32}>
<AppAvatar size='x124' iconFileContent={iconFileContent} iconFileData={iconFileData} />
</Box>
<Box display='flex' flexDirection='column'>
<Box display='flex' flexDirection='row' alignItems='center' marginBlockEnd={8}>
<Box fontScale='h1' marginInlineEnd={8}>
{name}
</Box>
{bundledIn && Boolean(bundledIn.length) && <BundleChips bundledIn={bundledIn} />}
</Box>
{shortDescription && (
<Box fontScale='p1' marginBlockEnd={16}>
{shortDescription}
</Box>
)}
<Box display='flex' flexDirection='row' alignItems='center' marginBlockEnd={16}>
<AppStatus app={app} installed={installed} isAppDetailsPage />
{(installed || isSubscribed) && <AppMenu app={app} isAppDetailsPage />}
</Box>
<Box fontScale='c1' display='flex' flexDirection='row' color='hint' alignItems='center'>
{author?.name}
<Box marginInline={16} color='disabled'>
|
</Box>
<Box>{t('Version_version', { version: versioni18nKey(app) })}</Box>
{lastUpdated && (
<>
<Box marginInline={16} color='disabled'>
|
</Box>
<Box>
{t('Marketplace_app_last_updated', {
lastUpdated,
})}
</Box>
</>
)}
{versionIncompatible && (
<>
<Box marginInline={16} color='disabled'>
|
</Box>
<Box>
<Tag
title={incompatibleStatus?.tooltipText}
variant={incompatibleStatus?.label === 'Disabled' ? 'secondary-danger' : 'secondary'}
>
{incompatibleStatus?.label}
</Tag>
</Box>
</>
)}
</Box>
</Box>
</Box>
);
};
export default AppDetailsPageHeader;

View File

@@ -0,0 +1,14 @@
import { Box, Skeleton } from '@rocket.chat/fuselage';
const AppDetailsPageLoading = () => (
<Box display='flex' flexDirection='row' marginBlockEnd={20} width='full'>
<Skeleton variant='rect' width='x120' height='x120' marginInlineEnd={32} />
<Box display='flex' flexDirection='column' justifyContent='space-between' flexGrow={1}>
<Skeleton variant='rect' width='full' height='x32' />
<Skeleton variant='rect' width='full' height='x32' />
<Skeleton variant='rect' width='full' height='x32' />
</Box>
</Box>
);
export default AppDetailsPageLoading;

View File

@@ -0,0 +1,78 @@
import { Tabs, TabsItem } from '@rocket.chat/fuselage';
import { usePermission, useRouter } from '@rocket.chat/ui-contexts';
import { useTranslation } from 'react-i18next';
import type { ISettings } from '../definitions/ISettings';
export type AppDetailsPageTabsProps = {
context: string;
installed: boolean | undefined;
isSecurityVisible: boolean;
settings: ISettings | undefined;
tab: string | undefined;
hasCluster: boolean;
};
const AppDetailsPageTabs = ({
context,
installed = false,
isSecurityVisible,
settings,
tab,
hasCluster = false,
}: AppDetailsPageTabsProps) => {
const { t } = useTranslation();
const isAdminUser = usePermission('manage-apps');
const router = useRouter();
const handleTabClick = (tab: 'details' | 'security' | 'releases' | 'settings' | 'logs' | 'requests' | 'instances') => {
router.navigate(
{
name: 'marketplace',
params: { ...router.getRouteParameters(), tab },
},
{ replace: true },
);
};
return (
<Tabs>
<TabsItem onClick={() => handleTabClick('details')} selected={!tab || tab === 'details'}>
{t('Details')}
</TabsItem>
{isAdminUser && context !== 'private' && (
<TabsItem onClick={() => handleTabClick('requests')} selected={tab === 'requests'}>
{t('Requests')}
</TabsItem>
)}
{isSecurityVisible && (
<TabsItem onClick={() => handleTabClick('security')} selected={tab === 'security'}>
{t('Security')}
</TabsItem>
)}
{context !== 'private' && (
<TabsItem onClick={() => handleTabClick('releases')} selected={tab === 'releases'}>
{t('Releases')}
</TabsItem>
)}
{installed && Boolean(settings && Object.values(settings).length) && isAdminUser && (
<TabsItem onClick={() => handleTabClick('settings')} selected={tab === 'settings'}>
{t('Settings')}
</TabsItem>
)}
{installed && isAdminUser && (
<TabsItem onClick={() => handleTabClick('logs')} selected={tab === 'logs'}>
{t('Logs')}
</TabsItem>
)}
{hasCluster && installed && isAdminUser && (
<TabsItem onClick={() => handleTabClick('instances')} selected={tab === 'instances'}>
{t('Instances')}
</TabsItem>
)}
</Tabs>
);
};
export default AppDetailsPageTabs;

View File

@@ -0,0 +1 @@
export { default } from './AppDetailsPage';

View File

@@ -0,0 +1,148 @@
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 { useTranslation } from 'react-i18next';
import AppDetailsAPIs from './AppDetailsAPIs';
import { normalizeUrl } from './normalizeUrl';
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';
export type AppDetailsProps = {
app: AppInfo;
};
const AppDetails = ({ app }: AppDetailsProps) => {
const { t } = useTranslation();
const {
author: { homepage, support } = {},
detailedDescription,
description,
categories = [],
screenshots,
apis,
documentationUrl: documentation,
addon,
installedAddon,
installed,
} = app;
const isMarkdown = detailedDescription && Object.keys(detailedDescription).length !== 0 && detailedDescription.rendered;
const isCarouselVisible = screenshots && Boolean(screenshots.length);
const normalizedHomepageUrl = homepage ? normalizeUrl(homepage) : undefined;
const normalizedSupportUrl = support ? normalizeUrl(support) : undefined;
const normalizedDocumentationUrl = documentation ? normalizeUrl(documentation) : undefined;
const appAddon = installed ? installedAddon : addon;
const { data: workspaceHasAddon = false } = useHasLicenseModule(appAddon);
const openExternalLink = useExternalLink();
return (
<Box marginBlockStart='36px' maxWidth='x640' width='full' marginInline='auto' color='default'>
{appAddon && !workspaceHasAddon && (
<Callout
marginBlock={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' marginBlock={16}>
{t(`Apps_License_Message_${key}` as TranslationKey)}
</Callout>
))}
{Object.entries(app.licenseValidation.errors).map(([key]) => (
<Callout key={key} type='danger' marginBlock={16}>
{t(`Apps_License_Message_${key}` as TranslationKey)}
</Callout>
))}
</>
)}
<Box display='flex' flexDirection='column'>
<Margins block={16}>
{isCarouselVisible && <ScreenshotCarouselAnchor screenshots={screenshots} />}
<Box is='section'>
<Box fontScale='h4' marginBlockEnd={8} color='titles-labels'>
{t('Description')}
</Box>
<Box
dangerouslySetInnerHTML={{
__html: isMarkdown
? DOMPurify.sanitize(detailedDescription.rendered, purifyOptions)
: DOMPurify.sanitize(description, purifyOptions),
}}
withRichContent
/>
</Box>
<Box is='section'>
<Box fontScale='h4' marginBlockEnd={8} color='titles-labels'>
{t('Categories')}
</Box>
<Box display='flex' flexDirection='row' flexWrap='wrap' justifyContent='start' alignItems='center'>
{categories?.map((current) => (
<Chip key={current} textTransform='uppercase' margin={4}>
{current}
</Chip>
))}
</Box>
</Box>
<Box is='section'>
<Box fontScale='h4' marginBlockEnd={8}>
{t('Contact')}
</Box>
<Box display='flex' flexDirection='row' flexGrow={1} justifyContent='space-around' flexWrap='wrap' marginBlockEnd={24}>
<Box display='flex' flexDirection='column' marginInlineEnd={12} flexGrow={1}>
<Box fontScale='h4' color='hint'>
{t('Author_Site')}
</Box>
{normalizedHomepageUrl ? <ExternalLink to={normalizedHomepageUrl}>{homepage}</ExternalLink> : homepage}
</Box>
<Box display='flex' flexDirection='column' flexGrow={1}>
<Box fontScale='h4' color='hint'>
{t('Support')}
</Box>
{normalizedSupportUrl ? <ExternalLink to={normalizedSupportUrl}>{support}</ExternalLink> : support}
</Box>
</Box>
<>
<Box fontScale='h4' color='hint'>
{t('Documentation')}
</Box>
{normalizedDocumentationUrl ? <ExternalLink to={normalizedDocumentationUrl}>{documentation}</ExternalLink> : documentation}
</>
</Box>
{apis?.length ? (
<Box is='section'>
<AppDetailsAPIs apis={apis || []} />
</Box>
) : null}
</Margins>
</Box>
</Box>
);
};
export default AppDetails;

View File

@@ -0,0 +1,52 @@
import type { IApiEndpointMetadata } from '@rocket.chat/apps-engine/definition/api';
import { Box } from '@rocket.chat/fuselage';
import { useAbsoluteUrl } from '@rocket.chat/ui-contexts';
import { Fragment } from 'react';
import { useTranslation } from 'react-i18next';
import { apiCurlGetter } from '../../../helpers/apiCurlGetter';
export type AppDetailsAPIsProps = {
apis: IApiEndpointMetadata[];
};
const AppDetailsAPIs = ({ apis }: AppDetailsAPIsProps) => {
const { t } = useTranslation();
const absoluteUrl = useAbsoluteUrl();
const getApiCurl = apiCurlGetter(absoluteUrl);
return (
<>
<Box display='flex' flexDirection='column'>
<Box fontScale='h4' marginBlock={12}>
{t('APIs')}
</Box>
{apis.map((api) => (
<Box key={api.path} marginBlock={8}>
<Box fontScale='p2m'>
{api.methods.join(' | ').toUpperCase()} {api.path}
</Box>
{api.methods.map((method, index) => (
<Box key={index}>
<Box withRichContent>
<pre>
<code>
{getApiCurl(method, api).map((curlAddress, index) => (
<Fragment key={index}>
{curlAddress}
<br />
</Fragment>
))}
</code>
</pre>
</Box>
</Box>
))}
</Box>
))}
</Box>
</>
);
};
export default AppDetailsAPIs;

View File

@@ -0,0 +1 @@
export { default } from './AppDetails';

View File

@@ -0,0 +1,13 @@
import { it } from '@jest/globals';
import { normalizeUrl } from './normalizeUrl';
it.each([
['https://rocket.chat', 'https://rocket.chat'],
['//rocket.chat', 'https://rocket.chat'],
['rocket.chat', 'https://rocket.chat'],
['rocketchat@rocket.chat', 'mailto:rocketchat@rocket.chat'],
['plain_text', undefined],
])('should normalize %o as %o', (input, output) => {
expect(normalizeUrl(input)).toBe(output);
});

View File

@@ -0,0 +1,25 @@
import { parse } from '@rocket.chat/message-parser';
export const normalizeUrl = (url: string): string | undefined => {
if (url.startsWith('http')) {
return url;
}
if (url.startsWith('//')) {
return `https:${url}`;
}
const parsedUrl = parse(url);
if (parsedUrl[0].type === 'PARAGRAPH') {
if (parsedUrl[0].value[0].type === 'LINK') {
if (parsedUrl[0].value[0].value.src.value.startsWith('//')) {
return `https:${parsedUrl[0].value[0].value.src.value}`;
}
return parsedUrl[0].value[0].value.src.value;
}
}
return undefined;
};

View File

@@ -0,0 +1,23 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { composeStories } from '@storybook/react';
import { render } from '@testing-library/react';
import { axe } from 'jest-axe';
import * as stories from './AppInstances.stories';
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
test.each(testCases)(`renders AppInstances without crashing`, async (_storyname, Story) => {
const view = render(<Story />, { wrapper: mockAppRoot().build() });
expect(view.baseElement).toMatchSnapshot();
});
test.each(testCases)('AppInstances should have no a11y violations', async (_storyname, Story) => {
const { container } = render(<Story />, { wrapper: mockAppRoot().build() });
// Today we do not have exactly a pattern to handle menu cells that don't have a header
const results = await axe(container, { rules: { 'empty-table-header': { enabled: false } } });
expect(results).toHaveNoViolations();
});

View File

@@ -0,0 +1,70 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import type { Meta, StoryObj } from '@storybook/react';
import AppInstances from './AppInstances';
const statusPossibleStates = [
'unknown',
'constructed',
'initialized',
'auto_enabled',
'manually_enabled',
'compiler_error_disabled',
'invalid_license_disabled',
'invalid_installation_disabled',
'error_disabled',
'manually_disabled',
'invalid_settings_disabled',
'disabled',
];
const statusTranslations = {
App_status_auto_enabled: 'Enabled',
App_status_constructed: 'Constructed',
App_status_disabled: 'Disabled',
App_status_error_disabled: 'Disabled: Uncaught Error',
App_status_initialized: 'Initialized',
App_status_invalid_license_disabled: 'Disabled: Invalid License',
App_status_invalid_settings_disabled: 'Disabled: Configuration Needed',
App_status_manually_disabled: 'Disabled: Manually',
App_status_manually_enabled: 'Enabled',
App_status_unknown: 'Unknown',
App_status_invalid_installation_disabled: 'Disabled: Invalid Installation',
Workspace_instance: 'Workspace instance',
};
export default {
component: AppInstances,
} satisfies Meta<typeof AppInstances>;
type Story = StoryObj<typeof AppInstances>;
export const Default: Story = {
decorators: [
mockAppRoot()
.withEndpoint('GET', '/apps/:id/status', () => ({
status: 'disabled',
// Using any since the actual values from the AppStatus Enum cannot be used because it was exported as a type
clusterStatus: statusPossibleStates.map((status, i) => ({ instanceId: `instance-id-${i}`, status })) as any,
}))
.withTranslations('en', 'core', statusTranslations)
.buildStoryDecorator(),
],
args: {
id: 'app-id',
},
};
export const NoResults: Story = {
decorators: [
mockAppRoot()
.withEndpoint('GET', '/apps/:id/status', () => ({
status: 'disabled',
clusterStatus: [],
}))
.buildStoryDecorator(),
],
args: {
id: 'app-id',
},
};

View File

@@ -0,0 +1,105 @@
import type { AppStatus } from '@rocket.chat/apps';
import { AppStatusUtils } from '@rocket.chat/apps-engine/definition/AppStatus';
import { Box, Palette, Tag } from '@rocket.chat/fuselage';
import {
GenericMenu,
CustomScrollbars,
GenericTable,
GenericTableBody,
GenericTableCell,
GenericTableHeader,
GenericTableHeaderCell,
GenericTableRow,
} from '@rocket.chat/ui-client';
import { useRouter } from '@rocket.chat/ui-contexts';
import { useTranslation } from 'react-i18next';
import GenericNoResults from '../../../../../components/GenericNoResults';
import AccordionLoading from '../../../components/AccordionLoading';
import { useAppInstances } from '../../../hooks/useAppInstances';
type AppInstanceProps = {
id: string;
};
const AppInstances = ({ id }: AppInstanceProps) => {
const { t } = useTranslation();
const { data, isSuccess, isError, isLoading } = useAppInstances({ appId: id });
const getStatusColor = (status: AppStatus) => {
if (AppStatusUtils.isDisabled(status) || AppStatusUtils.isError(status)) {
return Palette.text['font-danger'].toString();
}
return Palette.text['font-default'].toString();
};
const router = useRouter();
const handleSelectLogs = (instanceId: string) => {
router.navigate(
{
name: 'marketplace',
params: { ...router.getRouteParameters(), tab: 'logs' },
search: { instanceId },
},
{ replace: true },
);
};
return (
<Box height='full' width='full' marginInline='auto' color='default' paddingBlockStart={24}>
{isLoading && <AccordionLoading />}
{isError && (
<Box maxWidth='x600' alignSelf='center'>
{t('App_not_found')}
</Box>
)}
{isSuccess && data.clusterStatus && data.clusterStatus.length > 0 && (
<CustomScrollbars>
<GenericTable width='full'>
<GenericTableHeader>
<GenericTableHeaderCell key='instanceId'>{t('Workspace_instance')}</GenericTableHeaderCell>
<GenericTableHeaderCell key='status'>{t('Status')}</GenericTableHeaderCell>
<GenericTableHeaderCell key='actions' width={64} />
</GenericTableHeader>
<GenericTableBody>
{data?.clusterStatus?.map((instance) => (
<GenericTableRow key={instance.instanceId}>
<GenericTableCell>{instance.instanceId}</GenericTableCell>
<GenericTableCell>
<Box justifyContent='flex-start' display='flex'>
<Tag medium color={getStatusColor(instance.status)}>
{t(`App_status_${instance.status}`)}
</Tag>
</Box>
</GenericTableCell>
<GenericTableCell>
<GenericMenu
title='Actions'
items={[
{
content: t('View_Logs'),
onClick: () => handleSelectLogs(instance.instanceId),
id: 'view-logs',
icon: 'desktop-text',
},
]}
/>
</GenericTableCell>
</GenericTableRow>
))}
</GenericTableBody>
</GenericTable>
</CustomScrollbars>
)}
{isSuccess && (!data.clusterStatus || data.clusterStatus.length === 0) && (
<CustomScrollbars>
<GenericNoResults />
</CustomScrollbars>
)}
</Box>
);
};
export default AppInstances;

View File

@@ -0,0 +1,41 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`renders AppInstances without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-css-wuaqgn"
>
<span
class="rcx-skeleton rcx-skeleton--rect rcx-css-jti8fm"
/>
<span
class="rcx-skeleton rcx-skeleton--rect rcx-css-jti8fm"
/>
<span
class="rcx-skeleton rcx-skeleton--rect rcx-css-jti8fm"
/>
</div>
</div>
</body>
`;
exports[`renders AppInstances without crashing 2`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-css-wuaqgn"
>
<span
class="rcx-skeleton rcx-skeleton--rect rcx-css-jti8fm"
/>
<span
class="rcx-skeleton rcx-skeleton--rect rcx-css-jti8fm"
/>
<span
class="rcx-skeleton rcx-skeleton--rect rcx-css-jti8fm"
/>
</div>
</div>
</body>
`;

View File

@@ -0,0 +1 @@
export { default } from './AppInstances';

View File

@@ -0,0 +1,143 @@
import type { ILogItem } from '@rocket.chat/core-typings';
import { Box, Pagination } from '@rocket.chat/fuselage';
import { CustomScrollbars, usePagination } from '@rocket.chat/ui-client';
import { useRouter } from '@rocket.chat/ui-contexts';
import { useEffect, useMemo, useReducer } from 'react';
import { useTranslation } from 'react-i18next';
import AppLogsItem from './AppLogsItem';
import { CollapsiblePanel } from './Components/CollapsiblePanel';
import { AppLogsFilter } from './Filters/AppLogsFilter';
import { useAppLogsFilterFormContext } from './useAppLogsFilterForm';
import GenericError from '../../../../../components/GenericError';
import GenericNoResults from '../../../../../components/GenericNoResults';
import AccordionLoading from '../../../components/AccordionLoading';
import { useLogs } from '../../../hooks/useLogs';
function expandedReducer(
expandedStates: { id: string; expanded: boolean }[],
action:
| { type: 'update'; id: string; expanded: boolean }
| { type: 'expand-all' }
| { type: 'reset-all' }
| { type: 'reset'; logs: ILogItem[] },
) {
switch (action.type) {
case 'update':
return expandedStates.map((state) => (state.id === action.id ? { ...state, expanded: action.expanded } : state));
case 'expand-all':
return expandedStates.map((state) => ({ ...state, expanded: true }));
case 'reset':
return action.logs.map((log) => ({ id: log._id, expanded: false }));
case 'reset-all':
return expandedStates.map((state) => ({ ...state, expanded: false }));
default:
return expandedStates;
}
}
export type AppLogsProps = { id: string };
const AppLogs = ({ id }: AppLogsProps) => {
const { t } = useTranslation();
const router = useRouter();
const { instanceId: instanceLogsFilter } = router.getSearchParameters();
const { watch, setValue } = useAppLogsFilterFormContext();
useEffect(() => {
if (instanceLogsFilter) {
setValue('instance', instanceLogsFilter);
}
}, [instanceLogsFilter, setValue]);
const { startTime, endTime, startDate, endDate, event, severity, instance } = watch();
const { current, itemsPerPage, setItemsPerPage: onSetItemsPerPage, setCurrent: onSetCurrent, ...paginationProps } = usePagination();
const [expandedStates, dispatch] = useReducer(expandedReducer, []);
const handleExpand = ({ id, expanded }: { id: string; expanded: boolean }) => dispatch({ id, expanded, type: 'update' });
const handleExpandAll = () => dispatch({ type: 'expand-all' });
const handleCollapseAll = () => dispatch({ type: 'reset-all' });
const { data, isSuccess, isError, error, refetch, isFetching } = useLogs({
appId: id,
current,
itemsPerPage,
...(instance !== 'all' && { instanceId: instance }),
...(severity !== 'all' && { logLevel: severity }),
...(event !== 'all' && { method: event }),
...(startTime && startDate && { startDate: new Date(`${startDate}T${startTime}`).toISOString() }),
...(endTime && endDate && { endDate: new Date(`${endDate}T${endTime}`).toISOString() }),
});
useEffect(() => {
if (isSuccess) {
dispatch({ type: 'reset', logs: data.logs });
}
}, [data, isSuccess]);
const parsedError = useMemo(() => {
if (error) {
// TODO: Check why tanstack expects a default Error but we return {error: string}
if ((error as unknown as { error: string }).error === 'Invalid date range') {
return t('error-invalid-dates');
}
return t('Something_Went_Wrong');
}
}, [error, t]);
return (
<>
<Box paddingBlock={16}>
<AppLogsFilter
appId={id}
noResults={isFetching || !isSuccess || data?.logs?.length === 0}
isLoading={isFetching}
expandAll={() => handleExpandAll()}
collapseAll={() => handleCollapseAll()}
refetchLogs={() => refetch()}
/>
</Box>
{isFetching && <AccordionLoading />}
{isError && <GenericError title={parsedError} />}
{!isFetching && isSuccess && data?.logs?.length === 0 && <GenericNoResults />}
{!isFetching && isSuccess && data?.logs?.length > 0 && (
<CustomScrollbars>
<CollapsiblePanel width='100%' alignSelf='center'>
{data?.logs?.map((log, index) => (
<AppLogsItem
regionId={log._id}
expanded={expandedStates.find((state) => state.id === log._id)?.expanded || false}
onExpand={handleExpand}
key={`${index}-${log._createdAt}`}
{...log}
/>
))}
</CollapsiblePanel>
</CustomScrollbars>
)}
<Pagination
divider
current={current}
itemsPerPage={itemsPerPage}
count={data?.total || 0}
onSetItemsPerPage={onSetItemsPerPage}
onSetCurrent={onSetCurrent}
{...paginationProps}
/>
</>
);
};
export default AppLogs;

View File

@@ -0,0 +1,20 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { composeStories } from '@storybook/react';
import { render } from '@testing-library/react';
import { axe } from 'jest-axe';
import * as stories from './AppLogsItem.stories';
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
test.each(testCases)(`renders AppLogsItem without crashing`, async (_storyname, Story) => {
const view = render(<Story />, { wrapper: mockAppRoot().build() });
expect(view.baseElement).toMatchSnapshot();
});
test.each(testCases)('AppLogsItem should have no a11y violations', async (_storyname, Story) => {
const { container } = render(<Story />, { wrapper: mockAppRoot().build() });
const results = await axe(container);
expect(results).toHaveNoViolations();
});

View File

@@ -0,0 +1,42 @@
import type { StoryObj, Meta } from '@storybook/react';
import type { ComponentProps } from 'react';
import AppLogsItem from './AppLogsItem';
import { CollapsiblePanel } from './Components/CollapsiblePanel';
export default {
component: AppLogsItem,
decorators: [(fn) => <CollapsiblePanel style={{ padding: 24 }}>{fn()}</CollapsiblePanel>],
args: {
_id: '683da1e32025cfca7b3d8238',
appId: 'ce0e318b-ffc0-4ce4-832b-f1b464beb22a',
method: 'app:checkPostMessageSent',
entries: [
{
caller: 'anonymous OR constructor -> handleApp',
severity: 'debug',
method: 'app:checkPostMessageSent',
timestamp: '2025-06-02T13:06:43.772Z',
args: ["'checkPostMessageSent' is being called..."],
},
{
caller: 'anonymous OR constructor',
severity: 'debug',
method: 'app:checkPostMessageSent',
timestamp: '2025-06-02T13:06:43.777Z',
args: ["'checkPostMessageSent' was successfully called! The result is:", 'false'],
},
],
startTime: '2025-06-02T13:06:43.771Z',
endTime: '2025-06-02T13:06:43.777Z',
totalTime: 6,
_createdAt: '2025-06-02T13:06:43.777Z',
instanceId: 'b97ce445-b9ff-4513-8206-966afd799cd6',
_updatedAt: '2025-06-02T13:06:43.778Z',
},
parameters: {
layout: 'fullscreen',
},
} satisfies Meta<ComponentProps<typeof AppLogsItem>>;
export const Simple: StoryObj<ComponentProps<typeof AppLogsItem>> = {};

View File

@@ -0,0 +1,67 @@
import type { ILogItem } from '@rocket.chat/core-typings';
import { Box, Divider } from '@rocket.chat/fuselage';
import { useRef } from 'react';
import { useTranslation } from 'react-i18next';
import AppLogsItemEntry from './AppLogsItemEntry';
import { AppsLogItemField } from './AppLogsItemField';
import { CollapseButton } from './Components/CollapseButton';
import { CollapsibleRegion } from './Components/CollapsibleRegion';
import { useFormatDateAndTime } from '../../../../../hooks/useFormatDateAndTime';
export type AppLogsItemProps = {
regionId: string;
expanded: boolean;
onExpand: ({ id }: { id: string; expanded: boolean }) => void;
} & ILogItem;
const AppLogsItem = ({ regionId, expanded, onExpand, ...props }: AppLogsItemProps) => {
const { t } = useTranslation();
const title = (
<>
{props.entries.map(({ severity, timestamp, caller, args }, index) => {
const parsedArgs = args.map((arg) => (typeof arg === 'string' ? arg : JSON.stringify(arg))).join(' ');
return (
<Box
lineHeight={20}
marginBlockEnd={4}
fontFamily='mono'
key={`${index}-${severity}-${timestamp}-${caller}`}
>{`${timestamp} ${severity} ${caller} ${parsedArgs}`}</Box>
);
})}
</>
);
const handleClick = () => {
onExpand({ id: regionId, expanded: !expanded });
};
const anchorRef = useRef<HTMLDivElement>(null);
const formatDateAndTime = useFormatDateAndTime();
return (
<>
<CollapseButton regionId={regionId} expanded={expanded} onClick={handleClick}>
<Box ref={anchorRef}>{title}</Box>
</CollapseButton>
<CollapsibleRegion expanded={expanded} id={regionId} paddingBlockStart={expanded ? 16 : '0px'} marginInlineStart={36}>
{props.instanceId && <AppsLogItemField marginBlockStart={0} field={props.instanceId} label='Instance' />}
{props.totalTime !== undefined && <AppsLogItemField field={`${props.totalTime}ms`} label={t('Total_time')} />}
{props.startTime && <AppsLogItemField field={formatDateAndTime(Date.parse(props.startTime))} label={t('Time')} />}
{props.method && <AppsLogItemField field={props.method} label={t('Event')} />}
<Box marginBlockStart={16} display='flex' color='default' flexDirection='column'>
<Box fontWeight={700}>{t('Full_log')}</Box>
</Box>
<AppLogsItemEntry fullLog={props} />
</CollapsibleRegion>
<Box is='dt'>
<Divider marginBlock={0} />
</Box>
</>
);
};
export default AppLogsItem;

View File

@@ -0,0 +1,27 @@
import type { ILogItem } from '@rocket.chat/core-typings';
import { Box } from '@rocket.chat/fuselage';
import DOMPurify from 'dompurify';
import { useHighlightedCode } from '../../../../../hooks/useHighlightedCode';
export type AppLogsItemEntryProps = {
fullLog: ILogItem;
};
const AppLogsItemEntry = ({ fullLog }: AppLogsItemEntryProps) => {
return (
<Box color='default'>
<Box withRichContent width='full'>
<pre>
<code
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(useHighlightedCode('json', JSON.stringify(fullLog, null, 2))),
}}
/>
</pre>
</Box>
</Box>
);
};
export default AppLogsItemEntry;

View File

@@ -0,0 +1,16 @@
import { Box } from '@rocket.chat/fuselage';
import type { ComponentProps, ReactNode } from 'react';
export type AppsLogItemFieldProps = {
field: ReactNode | string;
label: string;
} & ComponentProps<typeof Box>;
export const AppsLogItemField = ({ field, label, ...props }: AppsLogItemFieldProps) => {
return (
<Box marginBlock={16} display='flex' color='default' flexDirection='column' {...props}>
<Box fontWeight={700}>{label}</Box>
{field}
</Box>
);
};

View File

@@ -0,0 +1,43 @@
import { css } from '@rocket.chat/css-in-js';
import { Box, Chevron, Palette } from '@rocket.chat/fuselage';
import type { CSSProperties, ReactNode } from 'react';
export type CollapseButtonProps = {
children: ReactNode;
regionId: string;
expanded?: boolean;
onClick: () => void;
};
export const CollapseButton = ({ regionId, children, expanded, onClick }: CollapseButtonProps) => {
const clickable = css`
background: ${Palette.surface['surface-light']};
&:hover {
background: ${Palette.surface['surface-tint']};
}
`;
const style: CSSProperties = { whiteSpace: 'nowrap', textOverflow: 'ellipsis', overflow: 'hidden' };
return (
<Box is='dt' style={style}>
<Box
is='button'
role='button'
onClick={onClick}
className={clickable}
aria-expanded={expanded}
aria-controls={regionId}
display='flex'
flexDirection='row'
width='full'
focusable
color={Palette.text['font-default']}
>
<Chevron size={32} down={!expanded} up={expanded} style={{ alignSelf: 'flex-start' }} />
<Box paddingBlock='x4' paddingInline='x4' fontWeight='700'>
{children}
</Box>
</Box>
</Box>
);
};

View File

@@ -0,0 +1,40 @@
import type { StoryFn } from '@storybook/react';
import { CollapseButton } from './CollapseButton';
import { CollapsiblePanel } from './CollapsiblePanel';
import { CollapsibleRegion } from './CollapsibleRegion';
export default {
component: CollapsiblePanel,
args: {
expanded: true,
},
parameters: {
layout: 'centered',
},
};
const Template: StoryFn = (args) => {
return (
<CollapsiblePanel>
<CollapseButton
onClick={() => {
args.expanded = !args.expanded;
}}
expanded={args.expanded}
regionId='collapse-item'
>
Click Me
</CollapseButton>
<CollapsibleRegion expanded={args.expanded} id='collapse-item'>
<p>This is the content of the panel that can be activated.</p>
<button>Click Me</button>
<p>More content can go here.</p>
</CollapsibleRegion>
</CollapsiblePanel>
);
};
export const Default = {
render: Template,
};

View File

@@ -0,0 +1,12 @@
import { Box } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';
export type CollapsiblePanelProps = ComponentProps<typeof Box>;
export const CollapsiblePanel = (props: CollapsiblePanelProps) => {
return (
<Box {...props} is='dl'>
{props.children}
</Box>
);
};

View File

@@ -0,0 +1,26 @@
import { css } from '@rocket.chat/css-in-js';
import { Box } from '@rocket.chat/fuselage';
import type { ComponentProps, ReactNode } from 'react';
export type CollapsibleRegionProps = {
children: ReactNode;
expanded?: boolean;
} & ComponentProps<typeof Box>;
export const CollapsibleRegion = ({ children, expanded, ...props }: CollapsibleRegionProps) => {
return (
<Box
{...props}
maxHeight={expanded ? 'fit-content' : 0}
className={[
css`
transition: all 0.18s ease;
`,
]}
overflowY='hidden'
is='dd'
>
<Box role='region'>{children}</Box>
</Box>
);
};

View File

@@ -0,0 +1,44 @@
import { Box } from '@rocket.chat/fuselage';
import { mockAppRoot } from '@rocket.chat/mock-providers';
import type { Meta } from '@storybook/react';
import { FormProvider } from 'react-hook-form';
import { action } from 'storybook/actions';
import { AppLogsFilter } from './AppLogsFilter';
import { useAppLogsFilterForm } from '../useAppLogsFilterForm';
export default {
component: AppLogsFilter,
args: {},
decorators: [
mockAppRoot()
.withEndpoint('GET', '/apps/:id/logs/distinctValues', () => ({
success: true,
instanceIds: ['instance-1', 'instance-2', 'instance-3'],
methods: ['method-1', 'method-2', 'method-3'],
}))
.buildStoryDecorator(),
(fn) => {
const methods = useAppLogsFilterForm();
return (
<FormProvider {...methods}>
<Box padding={16}>{fn()}</Box>
</FormProvider>
);
},
],
parameters: {
layout: 'fullscreen',
},
} satisfies Meta<typeof AppLogsFilter>;
export const Default = () => (
<AppLogsFilter
appId='app-id'
expandAll={action('expandAll')}
collapseAll={action('collapseAll')}
refetchLogs={action('refetchLogs')}
isLoading={false}
/>
);

View File

@@ -0,0 +1,139 @@
import { Box, Button, IconButton, Label } from '@rocket.chat/fuselage';
import { useRouter, useSetModal } from '@rocket.chat/ui-contexts';
import { Controller } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import AppsLogsFilterOptions from './AppLogsFilterOptions';
import CompactFilterOptions from './CompactFilterOptions';
import { EventFilterSelect } from './EventFilterSelect';
import { InstanceFilterSelect } from './InstanceFilterSelect';
import { SeverityFilterSelect } from './SeverityFilterSelect';
import { TimeFilterSelect } from './TimeFilterSelect';
import { useCompactMode } from '../../../useCompactMode';
import { useAppLogsFilterFormContext } from '../useAppLogsFilterForm';
import { ExportLogsModal } from './ExportLogsModal';
type AppsLogsFilterProps = {
appId: string;
expandAll: () => void;
collapseAll: () => void;
refetchLogs: () => void;
isLoading: boolean;
noResults?: boolean;
};
export const AppLogsFilter = ({ appId, expandAll, collapseAll, refetchLogs, isLoading, noResults = false }: AppsLogsFilterProps) => {
const { t } = useTranslation();
const { control, getValues } = useAppLogsFilterFormContext();
const setModal = useSetModal();
const router = useRouter();
const openContextualBar = () => {
router.navigate(
{
name: 'marketplace',
params: { ...router.getRouteParameters(), contextualBar: 'filter-logs' },
},
{ replace: true },
);
};
const openAllLogs = () => expandAll();
const refreshLogs = () => {
refetchLogs();
};
const onExportConfirm = (url: string) => {
window.open(url, '_blank', 'noopener noreferrer');
};
const openExportModal = () => {
setModal(<ExportLogsModal onClose={() => setModal(null)} filterValues={getValues()} onConfirm={onExportConfirm} />);
};
const compactMode = useCompactMode();
return (
<Box display='flex' alignItems='flex-end' flexDirection='row' width='full' flexWrap='wrap' alignContent='flex-end'>
<Box display='flex' flexDirection='column' marginInlineEnd={10} flexGrow={1}>
<Label id='eventFilterLabel' htmlFor='eventFilter'>
{t('Event')}
</Label>
<Controller
control={control}
name='event'
render={({ field }) => <EventFilterSelect appId={appId} aria-labelledby='eventFilterLabel' id='eventFilter' {...field} />}
/>
</Box>
{!compactMode && (
<>
<Box display='flex' flexDirection='column' marginInlineEnd={10} flexGrow={1}>
<Label id='timeFilterLabel' htmlFor='timeFilter'>
{t('Time')}
</Label>
<TimeFilterSelect id='timeFilter' aria-labelledby='timeFilterLabel' />
</Box>
<Box display='flex' flexDirection='column' marginInlineEnd={10} flexGrow={1}>
<Label id='instanceFilterLabel' htmlFor='instanceFilter'>
{t('Instance')}
</Label>
<Controller
control={control}
name='instance'
render={({ field }) => (
<InstanceFilterSelect appId={appId} aria-labelledby='instanceFilterLabel' id='instanceFilter' {...field} />
)}
/>
</Box>
<Box display='flex' flexDirection='column' marginInlineEnd={10} flexGrow={1}>
<Label>{t('Severity')}</Label>
<Controller control={control} name='severity' render={({ field }) => <SeverityFilterSelect id='severityFilter' {...field} />} />
</Box>
<IconButton
title={isLoading ? t('Loading') : t('Refresh_logs')}
disabled={isLoading}
icon='refresh'
secondary
marginInlineEnd={10}
onClick={() => refreshLogs()}
/>
<IconButton
title={noResults ? t('No_data_to_export') : t('Export')}
icon='circle-arrow-down'
disabled={noResults}
secondary
marginInlineEnd={10}
onClick={() => openExportModal()}
aria-label={noResults ? t('No_data_to_export') : t('Export')}
aria-disabled={noResults}
/>
<AppsLogsFilterOptions onExpandAll={openAllLogs} onCollapseAll={collapseAll} />
</>
)}
{compactMode && (
<>
<Button alignSelf='flex-end' icon='customize' secondary marginInlineEnd={10} onClick={() => openContextualBar()}>
{t('Filters')}
</Button>
<CompactFilterOptions
isLoading={isLoading}
onExportLogs={openExportModal}
onExpandAll={openAllLogs}
onCollapseAll={collapseAll}
onRefreshLogs={refreshLogs}
/>
</>
)}
</Box>
);
};

View File

@@ -0,0 +1,46 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { composeStories } from '@storybook/react';
import { render, screen } from '@testing-library/react';
import { axe } from 'jest-axe';
import * as stories from './AppLogsFilter.stories';
const { Default } = composeStories(stories);
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
test.each(testCases)(`renders AppLogsItem without crashing`, async (_storyname, Story) => {
const view = render(<Story />, {
wrapper: mockAppRoot().build(),
});
expect(view.baseElement).toMatchSnapshot();
});
test.each(testCases)('AppLogsItem should have no a11y violations', async (_storyname, Story) => {
const { container } = render(<Story />, { wrapper: mockAppRoot().build() });
/**
** Disable 'nested-interactive' rule because our `Select` component is still not a11y compliant
**/
const results = await axe(container, { rules: { 'nested-interactive': { enabled: false } } });
expect(results).toHaveNoViolations();
});
it('Should show filter button in contextual bar', async () => {
render(<Default />, {
wrapper: mockAppRoot().build(),
});
expect(screen.getByRole('button', { name: 'Filters' })).toBeVisible();
});
it('Should not show instance, time, and severity filters', async () => {
render(<Default />, {
wrapper: mockAppRoot().build(),
});
expect(screen.queryByRole('button', { name: 'Time' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Severity' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Instance' })).not.toBeInTheDocument();
});

View File

@@ -0,0 +1,141 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { composeStories } from '@storybook/react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import * as stories from './AppLogsFilterContextualBar.stories';
const { Default } = composeStories(stories);
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
// UserEvents will throw a timeout without the advanceTimers option
jest.useFakeTimers({ advanceTimers: true });
jest.setSystemTime(new Date('2017-05-19T12:20:00'));
test.each(testCases)(`renders AppLogsItem without crashing`, async (_storyname, Story) => {
const view = render(<Story />, {
wrapper: mockAppRoot().build(),
});
expect(view.baseElement).toMatchSnapshot();
});
test.each(testCases)('AppLogsItem should have no a11y violations', async (_storyname, Story) => {
const { container } = render(<Story />, { wrapper: mockAppRoot().build() });
/**
** Disable 'nested-interactive' rule because our `Select` component is still not a11y compliant
**/
const results = await axe(container, { rules: { 'nested-interactive': { enabled: false } } });
expect(results).toHaveNoViolations();
});
it('Instance select should have correct options', async () => {
render(<Default />, {
wrapper: mockAppRoot().build(),
});
const select = screen.getByLabelText('Instance');
await userEvent.click(select);
expect(screen.getByRole('option', { name: 'All' })).toBeVisible();
expect(screen.getByRole('option', { name: 'instance-1' })).toBeVisible();
expect(screen.getByRole('option', { name: 'instance-2' })).toBeVisible();
expect(screen.getByRole('option', { name: 'instance-3' })).toBeVisible();
});
it('Time select should not have custom time range', async () => {
render(<Default />, {
wrapper: mockAppRoot().build(),
});
const select = screen.getByLabelText('Time');
await userEvent.click(select);
expect(screen.queryByRole('option', { name: 'Custom_time_range' })).not.toBeInTheDocument();
});
const defaultTimeRangesAndValues = [
{ name: 'Last_5_minutes', value: ['2017-05-19', '2017-05-19', '12:15', '12:20'] },
{ name: 'Last_15_minutes', value: ['2017-05-19', '2017-05-19', '12:05', '12:20'] },
{ name: 'Last_30_minutes', value: ['2017-05-19', '2017-05-19', '11:50', '12:20'] },
{ name: 'Last_1_hour', value: ['2017-05-19', '2017-05-19', '11:20', '12:20'] },
{ name: 'This_week', value: ['2017-05-14', '2017-05-20', '00:00', '23:59'] },
];
describe('Time range', () => {
defaultTimeRangesAndValues.forEach(({ name, value }) => {
it(`Should update time range when ${name} is selected`, async () => {
render(<Default />, { wrapper: mockAppRoot().build() });
const startDate = screen.getByLabelText('Start_Date');
const endDate = screen.getByLabelText('End_Date');
const startTime = screen.getByLabelText('Start_Time');
const endTime = screen.getByLabelText('End_Time');
const timeSelect = screen.getByLabelText('Time');
await userEvent.click(timeSelect);
expect(screen.getByRole('option', { name })).toBeVisible();
await userEvent.click(screen.getByRole('option', { name }));
expect(startDate).toHaveValue(value[0]);
expect(endDate).toHaveValue(value[1]);
expect(startTime).toHaveValue(value[2]);
expect(endTime).toHaveValue(value[3]);
});
});
defaultTimeRangesAndValues.forEach(({ name, value }) => {
it(`Should manually set ${name}`, async () => {
render(<Default />, { wrapper: mockAppRoot().build() });
const startDate = screen.getByLabelText('Start_Date');
const endDate = screen.getByLabelText('End_Date');
const startTime = screen.getByLabelText('Start_Time');
const endTime = screen.getByLabelText('End_Time');
await userEvent.type(startDate, value[0]);
await userEvent.type(endDate, value[1]);
await userEvent.type(startTime, value[2]);
await userEvent.type(endTime, value[3]);
expect(startDate).toHaveValue(value[0]);
expect(endDate).toHaveValue(value[1]);
expect(startTime).toHaveValue(value[2]);
expect(endTime).toHaveValue(value[3]);
});
});
});
it('Should clear time range', async () => {
render(<Default />, { wrapper: mockAppRoot().build() });
const startDate = screen.getByLabelText('Start_Date');
const endDate = screen.getByLabelText('End_Date');
const startTime = screen.getByLabelText('Start_Time');
const endTime = screen.getByLabelText('End_Time');
const timeSelect = screen.getByLabelText('Time');
await userEvent.click(timeSelect);
expect(screen.getByRole('option', { name: 'Last_30_minutes' })).toBeVisible();
await userEvent.click(screen.getByRole('option', { name: 'Last_30_minutes' }));
expect(startDate).toHaveValue('2017-05-19');
expect(endDate).toHaveValue('2017-05-19');
expect(startTime).toHaveValue('11:50');
expect(endTime).toHaveValue('12:20');
expect(screen.getByRole('button', { name: 'Clear_filters' })).toBeVisible();
await userEvent.click(screen.getByRole('button', { name: 'Clear_filters' }));
expect(startDate).toHaveValue('');
expect(endDate).toHaveValue('');
expect(startTime).toHaveValue('');
expect(endTime).toHaveValue('');
});

View File

@@ -0,0 +1,46 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { Contextualbar } from '@rocket.chat/ui-client';
import type { Meta } from '@storybook/react';
import { FormProvider, useForm } from 'react-hook-form';
import { action } from 'storybook/actions';
import { AppLogsFilterContextualBar } from './AppLogsFilterContextualBar';
export default {
component: AppLogsFilterContextualBar,
args: {},
decorators: [
mockAppRoot()
.withEndpoint('GET', '/apps/:id/logs/distinctValues', () => ({
success: true,
instanceIds: ['instance-1', 'instance-2', 'instance-3'],
methods: ['method-1', 'method-2', 'method-3'],
}))
.buildStoryDecorator(),
(fn) => {
const methods = useForm({
defaultValues: {
instanceId: 'instance-1',
method: 'method-1',
severity: 'all',
event: 'all',
startDate: '',
endDate: '',
startTime: '',
endTime: '',
},
});
return (
<FormProvider {...methods}>
<Contextualbar height='100vh'> {fn()}</Contextualbar>
</FormProvider>
);
},
],
parameters: {
layout: 'fullscreen',
},
} satisfies Meta<typeof AppLogsFilterContextualBar>;
export const Default = () => <AppLogsFilterContextualBar appId='app-id' onClose={action('onClose')} />;

View File

@@ -0,0 +1,82 @@
import { Box, Button, Label } from '@rocket.chat/fuselage';
import {
ContextualbarHeader,
ContextualbarIcon,
ContextualbarTitle,
ContextualbarClose,
ContextualbarScrollableContent,
ContextualbarDialog,
ContextualbarFooter,
} from '@rocket.chat/ui-client';
import { Controller } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import DateTimeFilter from './DateTimeFilter';
import { InstanceFilterSelect } from './InstanceFilterSelect';
import { SeverityFilterSelect } from './SeverityFilterSelect';
import { TimeFilterSelect } from './TimeFilterSelect';
import { useAppLogsFilterFormContext } from '../useAppLogsFilterForm';
export type AppLogsFilterContextualBarProps = {
appId: string;
onClose: () => void;
};
export const AppLogsFilterContextualBar = ({ appId, onClose = () => undefined }: AppLogsFilterContextualBarProps) => {
const { t } = useTranslation();
const { control, reset } = useAppLogsFilterFormContext();
return (
<ContextualbarDialog onClose={onClose}>
<ContextualbarHeader>
<ContextualbarIcon name='customize' />
<ContextualbarTitle>{t('Filters')}</ContextualbarTitle>
<ContextualbarClose onClick={onClose} />
</ContextualbarHeader>
<ContextualbarScrollableContent is='form'>
<Box display='flex' flexDirection='column' marginInlineEnd={10} flexGrow={1}>
<Label id='timeFilterLabel' htmlFor='timeFilter'>
{t('Time')}
</Label>
<TimeFilterSelect aria-labelledby='timeFilterLabel' id='timeFilter' compactView={true} />
</Box>
<Box display='flex' flexDirection='column' marginInlineEnd={10} flexGrow={1}>
<Label htmlFor='timeFilter'>{t('Logs_from')}</Label>
<DateTimeFilter control={control} type='start' />
</Box>
<Box display='flex' flexDirection='column' marginInlineEnd={10} flexGrow={1}>
<Label htmlFor='timeFilter'>{t('Until')}</Label>
<DateTimeFilter control={control} type='end' />
</Box>
<Box display='flex' flexDirection='column' marginInlineEnd={10} flexGrow={1}>
<Label id='instanceFilterLabel' htmlFor='instanceFilter'>
{t('Instance')}
</Label>
<Controller
control={control}
name='instance'
render={({ field }) => (
<InstanceFilterSelect appId={appId} aria-labelledby='instanceFilterLabel' id='instanceFilter' {...field} />
)}
/>
</Box>
<Box display='flex' flexDirection='column' marginInlineEnd={10} flexGrow={1}>
<Label id='severityFilterLabel' htmlFor='severityFilter'>
{t('Severity')}
</Label>
<Controller
control={control}
name='severity'
render={({ field }) => <SeverityFilterSelect aria-labelledby='severityFilterLabel' id='severityFilter' {...field} />}
/>
</Box>
</ContextualbarScrollableContent>
<ContextualbarFooter>
<Button secondary width='full' aria-label={t('Clear_filters')} onClick={() => reset()}>
{t('Clear_filters')}
</Button>
</ContextualbarFooter>
</ContextualbarDialog>
);
};

View File

@@ -0,0 +1,69 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { composeStories } from '@storybook/react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import * as stories from './AppLogsFilter.stories';
const { Default } = composeStories(stories);
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
jest.mock('@rocket.chat/fuselage-hooks', () => {
const originalModule = jest.requireActual('@rocket.chat/fuselage-hooks');
return {
...originalModule,
useBreakpoints: () => ['xs', 'sm', 'md', 'lg', 'xl', 'xxl'],
};
});
test.each(testCases)(`renders AppLogsItem without crashing`, async (_storyname, Story) => {
const view = render(<Story />, {
wrapper: mockAppRoot().build(),
});
expect(view.baseElement).toMatchSnapshot();
});
test.each(testCases)('AppLogsItem should have no a11y violations', async (_storyname, Story) => {
const { container } = render(<Story />, { wrapper: mockAppRoot().build() });
/**
** Disable 'nested-interactive' rule because our `Select` component is still not a11y compliant
**/
const results = await axe(container, { rules: { 'nested-interactive': { enabled: false } } });
expect(results).toHaveNoViolations();
});
it('Instance select should have correct options', async () => {
render(<Default />, {
wrapper: mockAppRoot().build(),
});
const select = screen.getByLabelText('Instance');
await userEvent.click(select);
expect(screen.getByRole('option', { name: 'All' })).toBeVisible();
expect(screen.getByRole('option', { name: 'instance-1' })).toBeVisible();
expect(screen.getByRole('option', { name: 'instance-2' })).toBeVisible();
expect(screen.getByRole('option', { name: 'instance-3' })).toBeVisible();
});
it('Time select should open modal when custom time range is selected', async () => {
render(<Default />, {
wrapper: mockAppRoot().build(),
});
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
const select = screen.getByLabelText('Time');
await userEvent.click(select);
expect(screen.getByRole('option', { name: 'Custom_time_range' })).toBeVisible();
await userEvent.click(screen.getByRole('option', { name: 'Custom_time_range' }));
expect(screen.getByRole('dialog')).toBeVisible();
});

View File

@@ -0,0 +1,30 @@
import { GenericMenu } from '@rocket.chat/ui-client';
import { useTranslation } from 'react-i18next';
export type AppsLogsFilterOptionsProps = {
onExpandAll: () => void;
onCollapseAll: () => void;
};
const AppsLogsFilterOptions = ({ onExpandAll, onCollapseAll, ...props }: AppsLogsFilterOptionsProps) => {
const { t } = useTranslation();
const items = [
{
id: 'expandAll',
icon: 'arrow-expand' as const,
content: t('Expand_all'),
onClick: onExpandAll,
},
{
id: 'collapseAll',
icon: 'arrow-collapse' as const,
content: t('Collapse_all'),
onClick: onCollapseAll,
},
];
return <GenericMenu large title={t('Options')} items={items} {...props} />;
};
export default AppsLogsFilterOptions;

View File

@@ -0,0 +1,53 @@
import { GenericMenu } from '@rocket.chat/ui-client';
import { useTranslation } from 'react-i18next';
export type CompactFilterOptionsProps = {
onExpandAll: () => void;
onCollapseAll: () => void;
onRefreshLogs: () => void;
onExportLogs: () => void;
isLoading: boolean;
};
const CompactFilterOptions = ({
onExportLogs,
onExpandAll,
onCollapseAll,
onRefreshLogs,
isLoading,
...props
}: CompactFilterOptionsProps) => {
const { t } = useTranslation();
const items = [
{
id: 'exportLogs',
icon: 'circle-arrow-down' as const,
content: t('Export'),
onClick: onExportLogs,
},
{
id: 'expandAll',
icon: 'arrow-expand' as const,
content: t('Expand_all'),
onClick: onExpandAll,
},
{
id: 'collapseAll',
icon: 'arrow-collapse' as const,
content: t('Collapse_all'),
onClick: onCollapseAll,
},
{
id: 'refreshLogs',
icon: 'refresh' as const,
content: t('Refresh_logs'),
onClick: onRefreshLogs,
disabled: isLoading,
},
];
return <GenericMenu title={t('Options')} large items={items} {...props} />;
};
export default CompactFilterOptions;

View File

@@ -0,0 +1,44 @@
import { Box, InputBox, Margins } from '@rocket.chat/fuselage';
import type { Control } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
export type DateTimeFilterProps = {
type: 'start' | 'end';
id?: string;
control: Control<{ startDate?: string; startTime?: string; endDate?: string; endTime?: string }>;
error?: boolean;
};
const DateTimeFilter = ({ type, control, id, error }: DateTimeFilterProps) => {
const { t } = useTranslation();
return (
<Box display='flex' flexDirection='row' id={id}>
<Margins inlineEnd={4}>
<Controller
control={control}
name={type === 'start' ? 'startDate' : 'endDate'}
render={({ field }) => (
<InputBox
aria-label={type === 'start' ? t('Start_Date') : t('End_Date')}
type='date'
{...field}
error={error ? t('Required_field') : undefined}
/>
)}
/>
</Margins>
<Margins inlineStart={4}>
<Controller
control={control}
name={type === 'start' ? 'startTime' : 'endTime'}
render={({ field }) => <InputBox aria-label={type === 'start' ? t('Start_Time') : t('End_Time')} type='time' {...field} />}
/>
</Margins>
</Box>
);
};
export default DateTimeFilter;

View File

@@ -0,0 +1,49 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { composeStories } from '@storybook/react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import * as stories from './DateTimeModal.stories';
const { Default } = composeStories(stories);
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
test.each(testCases)(`renders AppLogsItem without crashing`, async (_storyname, Story) => {
const view = render(<Story />, {
wrapper: mockAppRoot().build(),
});
expect(view.baseElement).toMatchSnapshot();
});
test.each(testCases)('AppLogsItem should have no a11y violations', async (_storyname, Story) => {
const { container } = render(<Story />, { wrapper: mockAppRoot().build() });
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should not enable apply button when start Date and end Date are not selected', async () => {
render(<Default />, { wrapper: mockAppRoot().build() });
const startDate = screen.getByLabelText('Start_Date');
const endDate = screen.getByLabelText('End_Date');
const startTime = screen.getByLabelText('Start_Time');
const endTime = screen.getByLabelText('End_Time');
expect(startDate).toBeInTheDocument();
expect(endDate).toBeInTheDocument();
expect(startTime).toBeInTheDocument();
expect(endTime).toBeInTheDocument();
await userEvent.type(startTime, '00:00');
await userEvent.type(endTime, '00:00');
const button = screen.getByRole('button', { name: 'Apply' });
expect(button).toBeDisabled();
await userEvent.type(startDate, '2022-01-01');
await userEvent.type(endDate, '2022-01-02');
expect(button).toBeEnabled();
});

View File

@@ -0,0 +1,31 @@
import { Box } from '@rocket.chat/fuselage';
import { mockAppRoot } from '@rocket.chat/mock-providers';
import type { Meta, StoryFn } from '@storybook/react';
import type { ComponentProps } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { action } from 'storybook/actions';
import { DateTimeModal } from './DateTimeModal';
export default {
component: DateTimeModal,
decorators: [
mockAppRoot().buildStoryDecorator(),
(fn) => {
const methods = useForm({});
return (
<FormProvider {...methods}>
<Box padding={16}>{fn()}</Box>
</FormProvider>
);
},
],
parameters: {
layout: 'fullscreen',
},
} satisfies Meta<typeof DateTimeModal>;
export const Default: StoryFn<ComponentProps<typeof DateTimeModal>> = () => (
<DateTimeModal onClose={action('onClose')} onSave={action('onSave')} />
);

View File

@@ -0,0 +1,73 @@
import {
Box,
Button,
Label,
Modal,
ModalClose,
ModalContent,
ModalFooter,
ModalFooterControllers,
ModalHeader,
ModalTitle,
} from '@rocket.chat/fuselage';
import type { ReactNode } from 'react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import DateTimeFilter from './DateTimeFilter';
export type DateTimeModalFormData = {
startDate?: string;
startTime?: string;
endDate?: string;
endTime?: string;
};
export type DateTimeModalProps = {
onClose: () => void;
onSave: (value: DateTimeModalFormData) => void;
confirmDisabled?: boolean;
defaultValues?: DateTimeModalFormData;
};
export const DateTimeModal = ({ onSave, onClose, defaultValues }: DateTimeModalProps): ReactNode => {
const { t } = useTranslation();
const { control, getValues, watch } = useForm<DateTimeModalFormData>({ defaultValues });
const handleSave = (): void => {
onSave({
startDate: getValues('startDate'),
startTime: getValues('startTime'),
endDate: getValues('endDate'),
endTime: getValues('endTime'),
});
};
return (
<Modal title={t('Custom_time_range')}>
<ModalHeader>
<ModalTitle>{t('Custom_time_range')}</ModalTitle>
<ModalClose onClick={onClose} />
</ModalHeader>
<ModalContent>
<Box display='flex' flexDirection='column' marginInlineEnd={10} marginBlockEnd={24} flexGrow={1}>
<Label htmlFor='timeFilter'>{t('Logs_from')}</Label>
<DateTimeFilter control={control} type='start' />
</Box>
<Box display='flex' flexDirection='column' marginInlineEnd={10} flexGrow={1}>
<Label htmlFor='timeFilter'>{t('Until')}</Label>
<DateTimeFilter control={control} type='end' />
</Box>
</ModalContent>
<ModalFooter>
<ModalFooterControllers>
<Button onClick={onClose}>{t('Cancel')}</Button>
<Button primary disabled={!watch('startDate') || !watch('endDate')} onClick={handleSave}>
{t('Apply')}
</Button>
</ModalFooterControllers>
</ModalFooter>
</Modal>
);
};

View File

@@ -0,0 +1,26 @@
import type { SelectOption } from '@rocket.chat/fuselage';
import { InputBoxSkeleton, Select } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useLogsDistinctValues } from '../../../../hooks/useLogsDistinctValues';
export type EventFilterSelectProps = Omit<ComponentProps<typeof Select>, 'options'> & { appId: string };
export const EventFilterSelect = ({ appId, ...props }: EventFilterSelectProps) => {
const { t } = useTranslation();
const { data, isPending } = useLogsDistinctValues(appId);
const options: SelectOption[] = useMemo(() => {
const mappedData: [string, string][] = data?.methods?.map((id: string) => [id, id]) || [];
return [['all', t('All')], ...mappedData];
}, [data, t]);
if (isPending) {
return <InputBoxSkeleton aria-labelledby={props['aria-labelledby']} aria-busy />;
}
return <Select options={options} {...props} />;
};

View File

@@ -0,0 +1,60 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { composeStories } from '@storybook/react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import * as stories from './ExportLogsModal.stories';
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
const onConfirm = jest.fn();
const { Default } = composeStories(stories);
afterEach(() => {
jest.clearAllMocks();
});
test.each(testCases)(`renders without crashing`, async (_storyname, Story) => {
const view = render(<Story />, {
wrapper: mockAppRoot().build(),
});
expect(view.baseElement).toMatchSnapshot();
});
test.each(testCases)('Should have no a11y violations', async (_storyname, Story) => {
const { container } = render(<Story />, { wrapper: mockAppRoot().build() });
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should send the correct payload to the endpoint', async () => {
render(<Default onConfirm={onConfirm} />, {
wrapper: mockAppRoot().build(),
});
expect(screen.getByRole('button', { name: 'Download' })).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Download' }));
expect(onConfirm).toHaveBeenCalledTimes(1);
expect(onConfirm).toHaveBeenCalledWith('/api/apps/undefined/export-logs?count=2000&type=json');
});
it('should include instance filter in the payload to endpoint', async () => {
render(
<Default
onConfirm={onConfirm}
filterValues={{
instance: '123',
}}
/>,
{
wrapper: mockAppRoot().build(),
},
);
expect(screen.getByRole('button', { name: 'Download' })).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Download' }));
expect(onConfirm).toHaveBeenCalledTimes(1);
expect(onConfirm).toHaveBeenCalledWith('/api/apps/undefined/export-logs?instanceId=123&count=2000&type=json');
});

View File

@@ -0,0 +1,39 @@
import { Box } from '@rocket.chat/fuselage';
import { mockAppRoot } from '@rocket.chat/mock-providers';
import type { StoryObj, Meta } from '@storybook/react';
import type { ComponentProps } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { action } from 'storybook/actions';
import { ExportLogsModal } from './ExportLogsModal';
export default {
component: ExportLogsModal,
args: {
onClose: action('onClose'),
filterValues: {
severity: 'all',
event: '',
startDate: '',
endDate: '',
},
onConfirm: () => action('onConfirm'),
},
decorators: [
mockAppRoot().buildStoryDecorator(),
(fn) => {
const methods = useForm({});
return (
<FormProvider {...methods}>
<Box padding={16}>{fn()}</Box>
</FormProvider>
);
},
],
parameters: {
layout: 'fullscreen',
},
} satisfies Meta<typeof ExportLogsModal>;
export const Default: StoryObj<ComponentProps<typeof ExportLogsModal>> = {};

View File

@@ -0,0 +1,210 @@
import {
Box,
Button,
Field,
FieldLabel,
FieldRow,
Label,
Modal,
ModalClose,
ModalContent,
ModalFooter,
ModalFooterControllers,
ModalHeader,
ModalTitle,
NumberInput,
RadioButton,
} from '@rocket.chat/fuselage';
import { useRouteParameter } from '@rocket.chat/ui-contexts';
import { Controller, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import type { AppLogsFilterFormData } from '../useAppLogsFilterForm';
export type ExportLogsModalProps = {
onClose: () => void;
filterValues: AppLogsFilterFormData;
onConfirm: (url: string) => void;
};
type FormDataType = {
type: 'json' | 'csv';
count: 'max' | 'custom';
customExportAmount: number;
};
export const ExportLogsModal = ({ onClose, filterValues, onConfirm }: ExportLogsModalProps) => {
const { t } = useTranslation();
const appId = useRouteParameter('id');
const {
control,
watch,
getValues,
formState: { isValid },
} = useForm<FormDataType>({
defaultValues: {
type: 'json',
count: 'max',
customExportAmount: 100,
},
});
const type = watch('type');
const count = watch('count');
const getFileUrl = ({
severity,
event,
instance,
startDate,
endDate,
count,
type,
startTime,
endTime,
}: AppLogsFilterFormData & { type: 'json' | 'csv'; count: number }): string => {
let baseUrl = `/api/apps/${appId}/export-logs?`;
if (severity && severity !== 'all') {
baseUrl += `logLevel=${severity}&`;
}
if (event && event !== 'all') {
baseUrl += `method=${event}&`;
}
if (instance && instance !== 'all') {
baseUrl += `instanceId=${instance}&`;
}
if (startDate) {
baseUrl += `startDate=${new Date(`${startDate}T${startTime}`).toISOString()}&`;
}
if (endDate) {
baseUrl += `endDate=${new Date(`${endDate}T${endTime}`).toISOString()}&`;
}
if (count) {
baseUrl += `count=${count}&`;
}
baseUrl += `type=${type}`;
return baseUrl;
};
const handleConfirm = (): void => {
const url = getFileUrl({ ...filterValues, type, count: count === 'max' ? 2000 : getValues('customExportAmount') });
onConfirm(url);
onClose();
};
return (
<Modal>
<ModalHeader>
<ModalTitle>{t('Export')}</ModalTitle>
<ModalClose onClick={onClose} />
</ModalHeader>
<ModalContent>
<Box display='flex' flexDirection='column' marginInlineEnd={10} marginBlockEnd={24} flexGrow={1}>
<Label>{t('Format')}</Label>
<Field>
<FieldRow>
<FieldLabel htmlFor='JSONField'>{t('JSON')}</FieldLabel>
<Controller
name='type'
control={control}
render={({ field: { value, onChange, ...field } }) => (
<RadioButton
id='json'
{...field}
onChange={() => onChange('json')}
aria-describedby='JSONField'
checked={value === 'json'}
/>
)}
/>
</FieldRow>
</Field>
<Field>
<FieldRow>
<FieldLabel htmlFor='plainTextField'>{t('CSV')}</FieldLabel>
<Controller
name='type'
control={control}
render={({ field: { value, onChange, ...field } }) => (
<RadioButton
id='csv'
{...field}
onChange={() => onChange('csv')}
aria-describedby='plainTextField'
checked={value === 'csv'}
/>
)}
/>
</FieldRow>
</Field>
</Box>
<Box display='flex' flexDirection='column' marginInlineEnd={10} marginBlockEnd={24} flexGrow={1}>
<Label>{t('Export_most_recent_logs')}</Label>
<Field>
<FieldRow>
<FieldLabel htmlFor='maxLogsExport'>{t('Max_logs_export')}</FieldLabel>
<Controller
name='count'
control={control}
render={({ field: { value, onChange, ...field } }) => (
<RadioButton
id='max'
{...field}
onChange={() => onChange('max')}
aria-describedby='maxLogsExport'
checked={value === 'max'}
/>
)}
/>
</FieldRow>
</Field>
<Field>
<FieldRow>
<FieldLabel htmlFor='customMaxLogs'>{t('Limit_number_of_logs')}</FieldLabel>
<Controller
name='count'
control={control}
render={({ field: { value, onChange, ...field } }) => (
<RadioButton
id='custom'
{...field}
onChange={() => onChange('custom')}
aria-describedby='customMaxLogs'
checked={value === 'custom'}
/>
)}
/>
</FieldRow>
<Controller
name='customExportAmount'
control={control}
rules={{ required: count === 'custom', min: 1, max: 2000 }}
render={({ field }) => (
<NumberInput
id='limit'
min={1}
max={2000}
required={count === 'custom'}
disabled={count !== 'custom'}
placeholder='100'
{...field}
aria-describedby='limitNumber'
/>
)}
/>
</Field>
</Box>
</ModalContent>
<ModalFooter>
<ModalFooterControllers>
<Button onClick={onClose}>{t('Cancel')}</Button>
<Button primary disabled={!isValid} onClick={handleConfirm}>
{t('Download')}
</Button>
</ModalFooterControllers>
</ModalFooter>
</Modal>
);
};

View File

@@ -0,0 +1,26 @@
import type { SelectOption } from '@rocket.chat/fuselage';
import { InputBoxSkeleton, Select } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useLogsDistinctValues } from '../../../../hooks/useLogsDistinctValues';
export type InstanceFilterSelectProps = Omit<ComponentProps<typeof Select>, 'options'> & { appId: string };
export const InstanceFilterSelect = ({ appId, ...props }: InstanceFilterSelectProps) => {
const { t } = useTranslation();
const { data, isPending } = useLogsDistinctValues(appId);
const options: SelectOption[] = useMemo(() => {
const mappedData: [string, string][] = data?.instanceIds?.map((id: string) => [id, id]) || [];
return [['all', t('All')], ...mappedData];
}, [data, t]);
if (isPending) {
return <InputBoxSkeleton aria-labelledby={props['aria-labelledby']} aria-busy />;
}
return <Select options={options} {...props} />;
};

View File

@@ -0,0 +1,16 @@
import type { SelectOption } from '@rocket.chat/fuselage';
import { Select } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';
import { useTranslation } from 'react-i18next';
export const SeverityFilterSelect = ({ ...props }: Omit<ComponentProps<typeof Select>, 'options'>) => {
const { t } = useTranslation();
const options = [
['all', t('All')],
['0', t('Warning')],
['1', t('Info')],
['2', t('Debug')],
] as SelectOption[];
return <Select {...props} aria-label={t('Severity')} options={options} />;
};

View File

@@ -0,0 +1,157 @@
import type { SelectOption } from '@rocket.chat/fuselage';
import { Select } from '@rocket.chat/fuselage';
import { useSetModal } from '@rocket.chat/ui-contexts';
import { endOfDay, endOfWeek, startOfDay, startOfWeek, subMinutes, format } from 'date-fns';
import { useState, type ComponentProps } from 'react';
import { Controller } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import type { DateTimeModalFormData } from './DateTimeModal';
import { DateTimeModal } from './DateTimeModal';
import { useAppLogsFilterFormContext } from '../useAppLogsFilterForm';
type DateRange = {
start: Date;
end: Date;
};
type DateRangeAction = 'all' | 'today' | 'last5Minutes' | 'last15Minutes' | 'last30Minutes' | 'last1Hour' | 'thisWeek' | 'custom';
export type TimeFilterSelectProps = { compactView?: boolean } & Omit<ComponentProps<typeof Select>, 'onChange' | 'options'>;
export const TimeFilterSelect = ({ compactView = false, ...props }: TimeFilterSelectProps) => {
const { setValue, control, getValues } = useAppLogsFilterFormContext();
const { t } = useTranslation();
const setModal = useSetModal();
const [customTimeRangeLabel, setCustomTimeRangeOptionLabel] = useState<string>(t('Custom_time_range'));
const getTimeZoneOffset = (): string => {
const offset = new Date().getTimezoneOffset();
const absOffset = Math.abs(offset);
return `${offset < 0 ? '+' : '-'}${`00${Math.floor(absOffset / 60)}`.slice(-2)}:${`00${absOffset % 60}`.slice(-2)}`;
};
const dateRangeReducer = (action: DateRangeAction): DateRange | undefined => {
const now = new Date();
switch (action) {
case 'today': {
return {
start: startOfDay(now),
end: endOfDay(now),
};
}
case 'last5Minutes': {
return {
start: subMinutes(now, 5),
end: now,
};
}
case 'last15Minutes': {
return {
start: subMinutes(now, 15),
end: now,
};
}
case 'last30Minutes': {
return {
start: subMinutes(now, 30),
end: now,
};
}
case 'last1Hour': {
return {
start: subMinutes(now, 60),
end: now,
};
}
case 'thisWeek': {
return {
start: startOfWeek(now),
end: endOfWeek(now),
};
}
}
};
const onModalSave = (values: DateTimeModalFormData): void => {
const { startDate, startTime, endDate, endTime } = values;
if (!startDate || !endDate) {
setCustomTimeRangeOptionLabel(t('Custom_time_range'));
setModal(null);
return;
}
setValue('startDate', startDate);
setValue('startTime', startTime || '00:00');
setValue('endDate', endDate);
setValue('endTime', endTime || '00:00');
const startIso = new Date(`${startDate}T${startTime || '00:00'}${getTimeZoneOffset()}`);
const endIso = new Date(`${endDate}T${endTime || '00:00'}${getTimeZoneOffset()}`);
const formattedStartDate = format(new Date(startIso), 'MMM dd, yyyy');
const formattedEndDate = format(new Date(endIso), 'MMM dd, yyyy');
setCustomTimeRangeOptionLabel(
`${formattedStartDate}${startTime && startTime !== '00:00' ? `, ${startTime}` : ''} - ${formattedEndDate}${endTime && endTime !== '00:00' ? `, ${endTime}` : ''}`,
);
setModal(null);
};
const onModalClose = (): void => {
setValue('timeFilter', 'all');
setCustomTimeRangeOptionLabel(t('Custom_time_range'));
setModal(null);
};
const handleChange = (action: DateRangeAction): void => {
setValue('timeFilter', action);
if (action === 'all') {
setValue('startTime', undefined);
setValue('startDate', undefined);
setValue('endTime', undefined);
setValue('endDate', undefined);
return;
}
if (action === 'custom') {
const { startDate = '', startTime = '00:00', endDate = '', endTime = '00:00' } = getValues();
// Doing this since the modal is not in the form context, and it is simpler just to pass the default values to a new useForm call
setModal(<DateTimeModal onSave={onModalSave} onClose={onModalClose} defaultValues={{ startDate, startTime, endDate, endTime }} />);
return;
}
const newRange = dateRangeReducer(action);
if (newRange?.start) {
setValue('startTime', format(newRange?.start, 'HH:mm'));
setValue('startDate', format(newRange?.start, 'yyyy-MM-dd'));
}
if (newRange?.end) {
setValue('endTime', format(newRange?.end, 'HH:mm'));
setValue('endDate', format(newRange?.end, 'yyyy-MM-dd'));
}
};
const options = [
['all', t('All_time'), true],
['today', t('Today')],
['last5Minutes', t('Last_5_minutes')],
['last15Minutes', t('Last_15_minutes')],
['last30Minutes', t('Last_30_minutes')],
['last1Hour', t('Last_1_hour')],
['thisWeek', t('This_week')],
...(compactView ? [] : [['custom', customTimeRangeLabel]]),
] as SelectOption[];
return (
<Controller
control={control}
name='timeFilter'
render={({ field }) => <Select {...props} {...field} onChange={(val) => handleChange(val as DateRangeAction)} options={options} />}
/>
);
};

View File

@@ -0,0 +1,68 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`renders AppLogsItem without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-css-4jy2w6"
>
<div
class="rcx-box rcx-box--full rcx-css-rovs2w"
>
<div
class="rcx-box rcx-box--full rcx-css-1rfw4fq"
>
<label
class="rcx-box rcx-box--full rcx-label"
for="eventFilter"
id="eventFilterLabel"
>
Event
</label>
<div
aria-busy="true"
aria-labelledby="eventFilterLabel"
class="rcx-box rcx-box--full rcx-skeleton__input"
>
<span
class="rcx-skeleton rcx-skeleton--text rcx-css-1qcz93u"
/>
</div>
</div>
<button
class="rcx-box rcx-box--full rcx-button--secondary rcx-button rcx-css-qv9v2f"
type="button"
>
<span
class="rcx-button--content"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-customize rcx-icon rcx-css-1hdf9ok"
>
</i>
Filters
</span>
</button>
<button
aria-expanded="false"
aria-haspopup="true"
aria-label="Options"
class="rcx-box rcx-box--full rcx-button--large-square rcx-button--square rcx-button--icon rcx-button"
id="react-aria-_r_0_"
title="Options"
type="button"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-menu rcx-icon rcx-css-1x2l7ts"
>
</i>
</button>
</div>
</div>
</div>
</body>
`;

View File

@@ -0,0 +1,455 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`renders AppLogsItem without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-vertical-bar rcx-css-glyll0"
>
<span
data-focus-scope-start="true"
hidden=""
/>
<div
style="position: relative; user-select: auto; width: 380px; height: 100%; max-width: 50%; min-width: 380px; min-height: 100%; box-sizing: border-box; flex-shrink: 0;"
>
<div
aria-labelledby="contextualbarTitle"
class="rcx-box rcx-box--full rcx-vertical-bar rcx-css-nlfrt3"
role="dialog"
tabindex="-1"
>
<div
class="rcx-box rcx-box--full rcx-css-lm07yg"
>
<div
class="rcx-box rcx-box--full rcx-css-1bdyjik rcx-css-1l4fxfb"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-customize rcx-icon rcx-css-1yal9it"
>
</i>
<h5
class="rcx-box rcx-box--full rcx-css-1spko5i"
id="contextualbarTitle"
>
Filters
</h5>
<button
aria-label="Close"
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-ue04py"
data-qa="ContextualbarActionClose"
type="button"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-cross rcx-icon rcx-css-4pvxx3"
>
</i>
</button>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-css-1svuzur"
>
<div
class="rcx-box rcx-box--full rcx-css-1pd039f rcx-css-1cb6i7s"
data-overlayscrollbars="host"
>
<div
class="os-size-observer"
>
<div
class="os-size-observer-listener"
/>
</div>
<div
class=""
data-overlayscrollbars-viewport="scrollbarHidden overflowXHidden overflowYHidden"
style="margin-right: 0px; margin-bottom: 0px; margin-left: 0px; top: 0px; left: 0px; width: calc(100% + 0px); padding: 0px 0px 0px 0px;"
tabindex="-1"
>
<form
class="rcx-box rcx-box--full rcx-css-zg7o7h"
>
<div
class="rcx-box rcx-box--full rcx-css-1jggbrp rcx-css-1hr7rzd"
>
<label
class="rcx-box rcx-box--full rcx-label"
for="timeFilter"
id="timeFilterLabel"
>
Time
</label>
<div
class="rcx-box rcx-box--full rcx-css-1mbgoc8"
>
<div
aria-hidden="true"
data-a11y-ignore="aria-hidden-focus"
data-react-aria-prevent-focus="true"
data-testid="hidden-select-container"
style="border: 0px; clip-path: inset(50%); height: 1px; margin: -1px; overflow: hidden; padding: 0px; position: absolute; width: 1px; white-space: nowrap;"
>
<label>
<select
name="timeFilter"
tabindex="-1"
>
<option />
<option
value="all"
>
all
</option>
<option
value="today"
>
today
</option>
<option
value="last5Minutes"
>
last5Minutes
</option>
<option
value="last15Minutes"
>
last15Minutes
</option>
<option
value="last30Minutes"
>
last30Minutes
</option>
<option
value="last1Hour"
>
last1Hour
</option>
<option
value="thisWeek"
>
thisWeek
</option>
</select>
</label>
</div>
</div>
<button
aria-expanded="false"
aria-haspopup="listbox"
aria-labelledby="react-aria-_r_8_ timeFilterLabel"
class="rcx-box rcx-box--full rcx-box--animated rcx-select rcx-css-1vw6rc6"
id="timeFilter"
type="button"
>
<span
class="rcx-box rcx-box--full rcx-css-az37y0"
id="react-aria-_r_8_"
/>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-chevron-down rcx-icon rcx-css-l5txo2"
>
</i>
</button>
</div>
<div
class="rcx-box rcx-box--full rcx-css-1jggbrp rcx-css-1hr7rzd"
>
<label
class="rcx-box rcx-box--full rcx-label"
for="timeFilter"
>
Logs_from
</label>
<div
class="rcx-box rcx-box--full rcx-css-omrq7i"
>
<label
class="rcx-box rcx-box--full rcx-label rcx-box rcx-box--full rcx-box--animated rcx-input-box__wrapper rcx-css-sdt442"
>
<input
aria-label="Start_Date"
class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--undecorated rcx-input-box--type-date rcx-input-box"
name="startDate"
size="1"
type="date"
value=""
/>
<span
class="rcx-box rcx-box--full rcx-input-box__addon"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-calendar rcx-icon rcx-css-4pvxx3"
>
</i>
</span>
</label>
<label
class="rcx-box rcx-box--full rcx-label rcx-box rcx-box--full rcx-box--animated rcx-input-box__wrapper rcx-css-nc7kyx"
>
<input
aria-label="Start_Time"
class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--undecorated rcx-input-box--type-time rcx-input-box"
name="startTime"
size="1"
type="time"
value=""
/>
<span
class="rcx-box rcx-box--full rcx-input-box__addon"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-clock rcx-icon rcx-css-4pvxx3"
>
</i>
</span>
</label>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-css-1jggbrp rcx-css-1hr7rzd"
>
<label
class="rcx-box rcx-box--full rcx-label"
for="timeFilter"
>
Until
</label>
<div
class="rcx-box rcx-box--full rcx-css-omrq7i"
>
<label
class="rcx-box rcx-box--full rcx-label rcx-box rcx-box--full rcx-box--animated rcx-input-box__wrapper rcx-css-sdt442"
>
<input
aria-label="End_Date"
class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--undecorated rcx-input-box--type-date rcx-input-box"
name="endDate"
size="1"
type="date"
value=""
/>
<span
class="rcx-box rcx-box--full rcx-input-box__addon"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-calendar rcx-icon rcx-css-4pvxx3"
>
</i>
</span>
</label>
<label
class="rcx-box rcx-box--full rcx-label rcx-box rcx-box--full rcx-box--animated rcx-input-box__wrapper rcx-css-nc7kyx"
>
<input
aria-label="End_Time"
class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--undecorated rcx-input-box--type-time rcx-input-box"
name="endTime"
size="1"
type="time"
value=""
/>
<span
class="rcx-box rcx-box--full rcx-input-box__addon"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-clock rcx-icon rcx-css-4pvxx3"
>
</i>
</span>
</label>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-css-1jggbrp rcx-css-1hr7rzd"
>
<label
class="rcx-box rcx-box--full rcx-label"
for="instanceFilter"
id="instanceFilterLabel"
>
Instance
</label>
<div
aria-busy="true"
aria-labelledby="instanceFilterLabel"
class="rcx-box rcx-box--full rcx-skeleton__input"
>
<span
class="rcx-skeleton rcx-skeleton--text rcx-css-1qcz93u"
/>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-css-1jggbrp rcx-css-1hr7rzd"
>
<label
class="rcx-box rcx-box--full rcx-label"
for="severityFilter"
id="severityFilterLabel"
>
Severity
</label>
<div
class="rcx-box rcx-box--full rcx-css-1mbgoc8"
>
<div
aria-hidden="true"
data-a11y-ignore="aria-hidden-focus"
data-react-aria-prevent-focus="true"
data-testid="hidden-select-container"
style="border: 0px; clip-path: inset(50%); height: 1px; margin: -1px; overflow: hidden; padding: 0px; position: absolute; width: 1px; white-space: nowrap;"
>
<label>
<select
name="severity"
tabindex="-1"
>
<option />
<option
value="all"
>
all
</option>
<option
value="0"
>
0
</option>
<option
value="1"
>
1
</option>
<option
value="2"
>
2
</option>
</select>
</label>
</div>
</div>
<button
aria-expanded="false"
aria-haspopup="listbox"
aria-label="Severity"
aria-labelledby="react-aria-_r_g_ react-aria-_r_b_ severityFilterLabel"
class="rcx-box rcx-box--full rcx-box--animated rcx-select rcx-css-1vw6rc6"
id="severityFilter"
type="button"
>
<span
class="rcx-box rcx-box--full rcx-css-1ah8hdc"
id="react-aria-_r_g_"
>
All
</span>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-chevron-down rcx-icon rcx-css-l5txo2"
>
</i>
</button>
</div>
</form>
</div>
<div
class="os-scrollbar os-scrollbar-horizontal os-theme-dark os-scrollbar-auto-hide os-scrollbar-auto-hide-hidden os-scrollbar-handle-interactive os-scrollbar-cornerless os-scrollbar-unusable"
style="--os-scroll-percent: 0; --os-viewport-percent: 0; --os-scroll-direction: 0;"
>
<div
class="os-scrollbar-track"
>
<div
class="os-scrollbar-handle"
/>
</div>
</div>
<div
class="os-scrollbar os-scrollbar-vertical os-theme-dark os-scrollbar-auto-hide os-scrollbar-auto-hide-hidden os-scrollbar-handle-interactive os-scrollbar-cornerless os-scrollbar-unusable"
style="--os-scroll-percent: 0; --os-viewport-percent: 0; --os-scroll-direction: 0;"
>
<div
class="os-scrollbar-track"
>
<div
class="os-scrollbar-handle"
/>
</div>
</div>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-css-f51umh"
>
<button
aria-label="Clear_filters"
class="rcx-box rcx-box--full rcx-button--secondary rcx-button rcx-css-1qcz93u"
type="button"
>
<span
class="rcx-button--content"
>
Clear_filters
</span>
</button>
</div>
</div>
<div>
<div
style="position: absolute; user-select: none; width: 100%; height: 10px; top: -5px; left: 0px; cursor: row-resize;"
/>
<div
style="position: absolute; user-select: none; width: 10px; height: 100%; top: 0px; cursor: col-resize; right: -5px;"
/>
<div
style="position: absolute; user-select: none; width: 100%; height: 10px; left: 0px; cursor: row-resize; bottom: -5px;"
/>
<div
style="position: absolute; user-select: none; width: 3px; height: 100%; top: 0px; left: 0px; cursor: col-resize; z-index: 99;"
>
<div
class="rcx-box rcx-box--full rcx-css-124vum9"
/>
</div>
<div
style="position: absolute; user-select: none; width: 20px; height: 20px; z-index: 1; right: -10px; top: -10px; cursor: ne-resize;"
/>
<div
style="position: absolute; user-select: none; width: 20px; height: 20px; z-index: 1; right: -10px; bottom: -10px; cursor: se-resize;"
/>
<div
style="position: absolute; user-select: none; width: 20px; height: 20px; z-index: 1; left: -10px; bottom: -10px; cursor: sw-resize;"
/>
<div
style="position: absolute; user-select: none; width: 20px; height: 20px; z-index: 1; left: -10px; top: -10px; cursor: nw-resize;"
/>
</div>
</div>
<span
data-focus-scope-end="true"
hidden=""
/>
</div>
</div>
</body>
`;

View File

@@ -0,0 +1,261 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`renders AppLogsItem without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-css-4jy2w6"
>
<div
class="rcx-box rcx-box--full rcx-css-rovs2w"
>
<div
class="rcx-box rcx-box--full rcx-css-1rfw4fq"
>
<label
class="rcx-box rcx-box--full rcx-label"
for="eventFilter"
id="eventFilterLabel"
>
Event
</label>
<div
aria-busy="true"
aria-labelledby="eventFilterLabel"
class="rcx-box rcx-box--full rcx-skeleton__input"
>
<span
class="rcx-skeleton rcx-skeleton--text rcx-css-1qcz93u"
/>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-css-1rfw4fq"
>
<label
class="rcx-box rcx-box--full rcx-label"
for="timeFilter"
id="timeFilterLabel"
>
Time
</label>
<div
class="rcx-box rcx-box--full rcx-css-1mbgoc8"
>
<div
aria-hidden="true"
data-a11y-ignore="aria-hidden-focus"
data-react-aria-prevent-focus="true"
data-testid="hidden-select-container"
style="border: 0px; clip-path: inset(50%); height: 1px; margin: -1px; overflow: hidden; padding: 0px; position: absolute; width: 1px; white-space: nowrap;"
>
<label>
<select
name="timeFilter"
tabindex="-1"
>
<option />
<option
value="all"
>
all
</option>
<option
value="today"
>
today
</option>
<option
value="last5Minutes"
>
last5Minutes
</option>
<option
value="last15Minutes"
>
last15Minutes
</option>
<option
value="last30Minutes"
>
last30Minutes
</option>
<option
value="last1Hour"
>
last1Hour
</option>
<option
value="thisWeek"
>
thisWeek
</option>
<option
value="custom"
>
custom
</option>
</select>
</label>
</div>
</div>
<button
aria-expanded="false"
aria-haspopup="listbox"
aria-labelledby="react-aria-_r_7_ timeFilterLabel"
class="rcx-box rcx-box--full rcx-box--animated rcx-select rcx-css-1vw6rc6"
id="timeFilter"
type="button"
>
<span
class="rcx-box rcx-box--full rcx-css-1ah8hdc"
id="react-aria-_r_7_"
>
All_time
</span>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-chevron-down rcx-icon rcx-css-l5txo2"
>
</i>
</button>
</div>
<div
class="rcx-box rcx-box--full rcx-css-1rfw4fq"
>
<label
class="rcx-box rcx-box--full rcx-label"
for="instanceFilter"
id="instanceFilterLabel"
>
Instance
</label>
<div
aria-busy="true"
aria-labelledby="instanceFilterLabel"
class="rcx-box rcx-box--full rcx-skeleton__input"
>
<span
class="rcx-skeleton rcx-skeleton--text rcx-css-1qcz93u"
/>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-css-1rfw4fq"
>
<label
class="rcx-box rcx-box--full rcx-label"
>
Severity
</label>
<div
class="rcx-box rcx-box--full rcx-css-1mbgoc8"
>
<div
aria-hidden="true"
data-a11y-ignore="aria-hidden-focus"
data-react-aria-prevent-focus="true"
data-testid="hidden-select-container"
style="border: 0px; clip-path: inset(50%); height: 1px; margin: -1px; overflow: hidden; padding: 0px; position: absolute; width: 1px; white-space: nowrap;"
>
<label>
<select
name="severity"
tabindex="-1"
>
<option />
<option
value="all"
>
all
</option>
<option
value="0"
>
0
</option>
<option
value="1"
>
1
</option>
<option
value="2"
>
2
</option>
</select>
</label>
</div>
</div>
<button
aria-expanded="false"
aria-haspopup="listbox"
aria-label="Severity"
aria-labelledby="react-aria-_r_f_ react-aria-_r_a_"
class="rcx-box rcx-box--full rcx-box--animated rcx-select rcx-css-1vw6rc6"
id="severityFilter"
type="button"
>
<span
class="rcx-box rcx-box--full rcx-css-1ah8hdc"
id="react-aria-_r_f_"
>
All
</span>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-chevron-down rcx-icon rcx-css-l5txo2"
>
</i>
</button>
</div>
<button
class="rcx-box rcx-box--full rcx-button--large-square rcx-button--icon-secondary rcx-button--square rcx-button--icon rcx-button rcx-css-1itpmo6"
title="Refresh_logs"
type="button"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-refresh rcx-icon rcx-css-1x2l7ts"
>
</i>
</button>
<button
aria-disabled="false"
aria-label="Export"
class="rcx-box rcx-box--full rcx-button--large-square rcx-button--icon-secondary rcx-button--square rcx-button--icon rcx-button rcx-css-1itpmo6"
title="Export"
type="button"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-circle-arrow-down rcx-icon rcx-css-1x2l7ts"
>
</i>
</button>
<button
aria-expanded="false"
aria-haspopup="true"
aria-label="Options"
class="rcx-box rcx-box--full rcx-button--large-square rcx-button--square rcx-button--icon rcx-button"
id="react-aria-_r_g_"
title="Options"
type="button"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-menu rcx-icon rcx-css-1x2l7ts"
>
</i>
</button>
</div>
</div>
</div>
</body>
`;

View File

@@ -0,0 +1,198 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`renders AppLogsItem without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-css-4jy2w6"
>
<dialog
aria-modal="true"
class="rcx-box rcx-box--full rcx-modal"
open=""
title="Custom_time_range"
>
<div
class="rcx-box rcx-box--full rcx-modal__inner rcx-css-qtwduz"
>
<header
class="rcx-box rcx-box--full rcx-modal__header"
>
<div
class="rcx-box rcx-box--full rcx-modal__header-inner"
>
<h2
class="rcx-box rcx-box--full rcx-modal__title rcx-css-trljwa rcx-css-lma364"
>
Custom_time_range
</h2>
<button
aria-label="Close"
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-trljwa rcx-css-lma364"
type="button"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-cross rcx-icon rcx-css-4pvxx3"
>
</i>
</button>
</div>
</header>
<div
class="rcx-box rcx-box--full rcx-modal__content rcx-css-1vw7itl"
>
<div
class="rcx-box rcx-box--full rcx-modal__content-wrapper rcx-css-56vo9k"
>
<div
class="rcx-box rcx-box--full rcx-css-6ua2r5"
>
<label
class="rcx-box rcx-box--full rcx-label"
for="timeFilter"
>
Logs_from
</label>
<div
class="rcx-box rcx-box--full rcx-css-omrq7i"
>
<label
class="rcx-box rcx-box--full rcx-label rcx-box rcx-box--full rcx-box--animated rcx-input-box__wrapper rcx-css-sdt442"
>
<input
aria-label="Start_Date"
class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--undecorated rcx-input-box--type-date rcx-input-box"
name="startDate"
size="1"
type="date"
/>
<span
class="rcx-box rcx-box--full rcx-input-box__addon"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-calendar rcx-icon rcx-css-4pvxx3"
>
</i>
</span>
</label>
<label
class="rcx-box rcx-box--full rcx-label rcx-box rcx-box--full rcx-box--animated rcx-input-box__wrapper rcx-css-nc7kyx"
>
<input
aria-label="Start_Time"
class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--undecorated rcx-input-box--type-time rcx-input-box"
name="startTime"
size="1"
type="time"
/>
<span
class="rcx-box rcx-box--full rcx-input-box__addon"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-clock rcx-icon rcx-css-4pvxx3"
>
</i>
</span>
</label>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-css-1rfw4fq"
>
<label
class="rcx-box rcx-box--full rcx-label"
for="timeFilter"
>
Until
</label>
<div
class="rcx-box rcx-box--full rcx-css-omrq7i"
>
<label
class="rcx-box rcx-box--full rcx-label rcx-box rcx-box--full rcx-box--animated rcx-input-box__wrapper rcx-css-sdt442"
>
<input
aria-label="End_Date"
class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--undecorated rcx-input-box--type-date rcx-input-box"
name="endDate"
size="1"
type="date"
/>
<span
class="rcx-box rcx-box--full rcx-input-box__addon"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-calendar rcx-icon rcx-css-4pvxx3"
>
</i>
</span>
</label>
<label
class="rcx-box rcx-box--full rcx-label rcx-box rcx-box--full rcx-box--animated rcx-input-box__wrapper rcx-css-nc7kyx"
>
<input
aria-label="End_Time"
class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--undecorated rcx-input-box--type-time rcx-input-box"
name="endTime"
size="1"
type="time"
/>
<span
class="rcx-box rcx-box--full rcx-input-box__addon"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-clock rcx-icon rcx-css-4pvxx3"
>
</i>
</span>
</label>
</div>
</div>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-modal__footer rcx-css-17mu816"
>
<div
class="rcx-button-group rcx-button-group--align-end"
role="group"
>
<button
class="rcx-box rcx-box--full rcx-button"
type="button"
>
<span
class="rcx-button--content"
>
Cancel
</span>
</button>
<button
class="rcx-box rcx-box--full rcx-button--primary rcx-button"
disabled=""
type="button"
>
<span
class="rcx-button--content"
>
Apply
</span>
</button>
</div>
</div>
</div>
</dialog>
</div>
</div>
</body>
`;

View File

@@ -0,0 +1,234 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`renders without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-css-4jy2w6"
>
<dialog
aria-modal="true"
class="rcx-box rcx-box--full rcx-modal"
open=""
>
<div
class="rcx-box rcx-box--full rcx-modal__inner rcx-css-qtwduz"
>
<header
class="rcx-box rcx-box--full rcx-modal__header"
>
<div
class="rcx-box rcx-box--full rcx-modal__header-inner"
>
<h2
class="rcx-box rcx-box--full rcx-modal__title rcx-css-trljwa rcx-css-lma364"
>
Export
</h2>
<button
aria-label="Close"
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-trljwa rcx-css-lma364"
type="button"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-cross rcx-icon rcx-css-4pvxx3"
>
</i>
</button>
</div>
</header>
<div
class="rcx-box rcx-box--full rcx-modal__content rcx-css-1vw7itl"
>
<div
class="rcx-box rcx-box--full rcx-modal__content-wrapper rcx-css-56vo9k"
>
<div
class="rcx-box rcx-box--full rcx-css-6ua2r5"
>
<label
class="rcx-box rcx-box--full rcx-label"
>
Format
</label>
<div
class="rcx-box rcx-box--full rcx-field"
>
<span
class="rcx-box rcx-box--full rcx-field__row"
>
<label
class="rcx-box rcx-box--full rcx-field__label rcx-label"
for="JSONField"
>
JSON
</label>
<label
class="rcx-box rcx-box--full rcx-radio-button"
>
<input
aria-describedby="JSONField"
checked=""
class="rcx-box rcx-box--full rcx-radio-button__input"
id="json"
name="type"
type="radio"
/>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-radio-button__fake"
/>
</label>
</span>
</div>
<div
class="rcx-box rcx-box--full rcx-field"
>
<span
class="rcx-box rcx-box--full rcx-field__row"
>
<label
class="rcx-box rcx-box--full rcx-field__label rcx-label"
for="plainTextField"
>
CSV
</label>
<label
class="rcx-box rcx-box--full rcx-radio-button"
>
<input
aria-describedby="plainTextField"
class="rcx-box rcx-box--full rcx-radio-button__input"
id="csv"
name="type"
type="radio"
/>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-radio-button__fake"
/>
</label>
</span>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-css-6ua2r5"
>
<label
class="rcx-box rcx-box--full rcx-label"
>
Export_most_recent_logs
</label>
<div
class="rcx-box rcx-box--full rcx-field"
>
<span
class="rcx-box rcx-box--full rcx-field__row"
>
<label
class="rcx-box rcx-box--full rcx-field__label rcx-label"
for="maxLogsExport"
>
Max_logs_export
</label>
<label
class="rcx-box rcx-box--full rcx-radio-button"
>
<input
aria-describedby="maxLogsExport"
checked=""
class="rcx-box rcx-box--full rcx-radio-button__input"
id="max"
name="count"
type="radio"
/>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-radio-button__fake"
/>
</label>
</span>
</div>
<div
class="rcx-box rcx-box--full rcx-field"
>
<span
class="rcx-box rcx-box--full rcx-field__row"
>
<label
class="rcx-box rcx-box--full rcx-field__label rcx-label"
for="customMaxLogs"
>
Limit_number_of_logs
</label>
<label
class="rcx-box rcx-box--full rcx-radio-button"
>
<input
aria-describedby="customMaxLogs"
class="rcx-box rcx-box--full rcx-radio-button__input"
id="custom"
name="count"
type="radio"
/>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-radio-button__fake"
/>
</label>
</span>
<input
aria-describedby="limitNumber"
class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--type-number rcx-input-box"
disabled=""
id="limit"
max="2000"
min="1"
name="customExportAmount"
placeholder="100"
size="1"
type="number"
value="100"
/>
</div>
</div>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-modal__footer rcx-css-17mu816"
>
<div
class="rcx-button-group rcx-button-group--align-end"
role="group"
>
<button
class="rcx-box rcx-box--full rcx-button"
type="button"
>
<span
class="rcx-button--content"
>
Cancel
</span>
</button>
<button
class="rcx-box rcx-box--full rcx-button--primary rcx-button"
disabled=""
type="button"
>
<span
class="rcx-button--content"
>
Download
</span>
</button>
</div>
</div>
</div>
</dialog>
</div>
</div>
</body>
`;

View File

@@ -0,0 +1,130 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`renders AppLogsItem without crashing 1`] = `
<body>
<div>
<dl
class="rcx-box rcx-box--full"
style="padding: 24px;"
>
<dt
class="rcx-box rcx-box--full"
style="white-space: nowrap; text-overflow: ellipsis; overflow: hidden;"
>
<button
class="rcx-box rcx-box--full rcx-box--focusable rcx-css-file93 rcx-css-ylkbm1"
role="button"
>
<span
class="rcx-box rcx-box--full rcx-chevron--down rcx-chevron"
style="align-self: flex-start;"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-chevron-down rcx-icon rcx-css-p4voah"
>
</i>
</span>
<div
class="rcx-box rcx-box--full rcx-css-1caf7pm"
>
<div
class="rcx-box rcx-box--full"
>
<div
class="rcx-box rcx-box--full rcx-css-ebjo7"
>
2025-06-02T13:06:43.772Z debug anonymous OR constructor -&gt; handleApp 'checkPostMessageSent' is being called...
</div>
<div
class="rcx-box rcx-box--full rcx-css-ebjo7"
>
2025-06-02T13:06:43.777Z debug anonymous OR constructor 'checkPostMessageSent' was successfully called! The result is: false
</div>
</div>
</div>
</button>
</dt>
<dd
class="rcx-box rcx-box--full rcx-css-1i9pl35 rcx-css-s21r8p"
>
<div
class="rcx-box rcx-box--full"
role="region"
>
<div
class="rcx-box rcx-box--full rcx-css-x4u86i"
>
<div
class="rcx-box rcx-box--full rcx-css-9j23vx"
>
Instance
</div>
b97ce445-b9ff-4513-8206-966afd799cd6
</div>
<div
class="rcx-box rcx-box--full rcx-css-1v8dhib"
>
<div
class="rcx-box rcx-box--full rcx-css-9j23vx"
>
Total_time
</div>
6ms
</div>
<div
class="rcx-box rcx-box--full rcx-css-1v8dhib"
>
<div
class="rcx-box rcx-box--full rcx-css-9j23vx"
>
Time
</div>
June 2nd, 2025 1:06 PM
</div>
<div
class="rcx-box rcx-box--full rcx-css-1v8dhib"
>
<div
class="rcx-box rcx-box--full rcx-css-9j23vx"
>
Event
</div>
app:checkPostMessageSent
</div>
<div
class="rcx-box rcx-box--full rcx-css-1cp3dqt"
>
<div
class="rcx-box rcx-box--full rcx-css-9j23vx"
>
Full_log
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-css-n501yz"
>
<div
class="rcx-box rcx-box--full rcx-box--with-block-elements rcx-box--with-inline-elements rcx-css-1qcz93u"
>
<pre>
<code>
Loading
</code>
</pre>
</div>
</div>
</div>
</dd>
<dt
class="rcx-box rcx-box--full"
>
<hr
class="rcx-box rcx-box--full rcx-divider rcx-css-1nqmhgo"
/>
</dt>
</dl>
</div>
</body>
`;

View File

@@ -0,0 +1 @@
export { default } from './AppLogs';

View File

@@ -0,0 +1,28 @@
import { useForm, useFormContext } from 'react-hook-form';
export type AppLogsFilterFormData = {
startDate?: string;
endDate?: string;
startTime?: string;
endTime?: string;
instance?: string;
severity?: 'all' | '0' | '1' | '2';
event?: string;
timeFilter?: string;
};
export const useAppLogsFilterForm = () =>
useForm<AppLogsFilterFormData>({
defaultValues: {
severity: 'all',
instance: 'all',
timeFilter: 'all',
event: 'all',
startDate: '',
endDate: '',
startTime: '',
endTime: '',
},
});
export const useAppLogsFilterFormContext = () => useFormContext<AppLogsFilterFormData>();

View File

@@ -0,0 +1,47 @@
import type { App } from '@rocket.chat/core-typings';
import { Accordion } from '@rocket.chat/fuselage';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import AppReleasesItem from './AppReleasesItem';
import AccordionLoading from '../../../components/AccordionLoading';
export type AppReleasesProps = { id: App['id'] };
const AppReleases = ({ id }: AppReleasesProps) => {
const getVersions = useEndpoint('GET', '/apps/:id/versions', { id });
const { t } = useTranslation();
const { data, isLoading, isFetched } = useQuery({
queryKey: ['apps', id, 'versions'],
queryFn: async () => {
const { apps } = await getVersions();
if (apps.length === 0) {
throw new Error(t('No_results_found'));
}
return apps;
},
meta: {
apiErrorToastMessage: true,
},
});
return (
<>
<Accordion width='100%' alignSelf='center'>
{isLoading && <AccordionLoading />}
{isFetched && (
<>
{data?.map((release) => (
<AppReleasesItem release={release} key={release.version} />
))}
</>
)}
</Accordion>
</>
);
};
export default AppReleases;

View File

@@ -0,0 +1,47 @@
import { AccordionItem, Box } from '@rocket.chat/fuselage';
import DOMPurify from 'dompurify';
import { useTranslation } from 'react-i18next';
import { useTimeAgo } from '../../../../../hooks/useTimeAgo';
import { purifyOptions } from '../../../lib/purifyOptions';
type IRelease = {
version: string;
createdDate: string;
detailedChangelog: {
raw: string;
rendered: string;
};
};
type ReleaseItemProps = {
release: IRelease;
};
const AppReleasesItem = ({ release, ...props }: ReleaseItemProps) => {
const { t } = useTranslation();
const formatDate = useTimeAgo();
const title = (
<Box display='flex' flexDirection='row'>
<Box is='h4' fontScale='p1b' color='default' marginInlineEnd={24}>
{release.version}
</Box>
<Box is='p' fontScale='p1' color='hint'>
{formatDate(release.createdDate)}
</Box>
</Box>
);
return (
<AccordionItem title={title} {...props}>
{release.detailedChangelog?.rendered ? (
<Box dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(release.detailedChangelog?.rendered, purifyOptions) }} color='default' />
) : (
<Box color='default'>{t('No_release_information_provided')}</Box>
)}
</AccordionItem>
);
};
export default AppReleasesItem;

View File

@@ -0,0 +1 @@
export { default } from './AppReleases';

View File

@@ -0,0 +1,44 @@
import { Badge, Box } from '@rocket.chat/fuselage';
import { UserAvatar } from '@rocket.chat/ui-avatar';
import { usePermission } from '@rocket.chat/ui-contexts';
import { useFormatDateAndTime } from '../../../../../hooks/useFormatDateAndTime';
export type AppRequestItemProps = {
seen: boolean;
name: string;
createdDate: string;
message: string;
username: string;
};
const AppRequestItem = ({ seen, name, createdDate, message, username }: AppRequestItemProps) => {
const formatDateAndTime = useFormatDateAndTime();
const isAdminUser = usePermission('manage-apps');
return (
<Box display='flex' flexDirection='row' paddingBlock={12} paddingInlineEnd={24} marginBlockEnd={8} flexGrow='1'>
<Box marginInlineEnd={8} marginBlockStart={2} display='flex' flexDirection='row' alignItems='flex-start' height='full'>
<Box marginInlineEnd={16} alignSelf='center' height='100%' width='x8'>
{!seen && isAdminUser && <Badge small variant='primary' />}
</Box>
{username && <UserAvatar size='x36' username={username} />}
</Box>
<Box display='flex' flexDirection='column'>
<Box display='flex' flexDirection='row' alignItems='flex-start' marginBlockEnd={4}>
<Box fontScale='p2b' marginInlineEnd={4} lineHeight='initial' color='titles-labels'>
{name}
</Box>
<Box fontScale='c1' color='annotation'>
{formatDateAndTime(createdDate)}
</Box>
</Box>
<Box fontScale='p2' color='default'>
{message}
</Box>
</Box>
</Box>
);
};
export default AppRequestItem;

View File

@@ -0,0 +1,101 @@
import type { App } from '@rocket.chat/core-typings';
import { Box, Pagination, States, StatesSubtitle, StatesTitle } from '@rocket.chat/fuselage';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { SetStateAction } from 'react';
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import AppRequestItem from './AppRequestItem';
import AppRequestsLoading from './AppRequestsLoading';
import { useAppRequests } from '../../../hooks/useAppRequests';
import { useAppsReload } from '../../../hooks/useAppsReload';
type itemsPerPage = 25 | 50 | 100;
export type AppRequestsProps = { id: App['id']; isAdminUser: boolean };
const AppRequests = ({ id, isAdminUser }: AppRequestsProps) => {
const [limit, setLimit] = useState<itemsPerPage>(25);
const [offset, setOffset] = useState<number>(0);
const { isSuccess, data: paginatedAppRequests, isLoading } = useAppRequests(id, limit, offset);
const { t } = useTranslation();
const onSetItemsPerPage = (itemsPerPageOption: SetStateAction<itemsPerPage>) => setLimit(itemsPerPageOption);
const onSetCurrent = (currentItemsOption: SetStateAction<number>) => setOffset(currentItemsOption);
const reloadApps = useAppsReload();
const markSeen = useEndpoint('POST', '/apps/app-request/markAsSeen');
const markAppRequestsAsSeen = useMutation({
mutationKey: ['mark-app-requests-as-seen'],
mutationFn: (unseenRequests: Array<string>) => markSeen({ unseenRequests }),
retry: false,
});
const queryClient = useQueryClient();
useEffect(() => {
return () => {
if (isAdminUser && isSuccess) {
// Marketplace returns data = null if the app was removed, so we need to be sure that the thing
// we are filtering & mapping is an array
const unseenRequests = (paginatedAppRequests.data || []).filter(({ seen }) => !seen).map(({ id }) => id);
if (unseenRequests.length) {
markAppRequestsAsSeen.mutate(unseenRequests, {
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ['app-requests-stats'] });
reloadApps();
},
});
}
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAdminUser, isSuccess, paginatedAppRequests?.data, reloadApps]);
if (isLoading) {
return (
<Box width='full' maxWidth='x608' marginInline='auto' paddingBlockStart={36}>
<AppRequestsLoading />
</Box>
);
}
return (
<Box height='full' display='flex' flexDirection='column'>
<Box width='full' maxWidth='x608' marginInline='auto' paddingBlockStart={36} flexGrow='1'>
{isSuccess && paginatedAppRequests.data?.length ? (
paginatedAppRequests.data.map((request) => (
<AppRequestItem
key={request.id}
seen={request.seen}
name={request.requester.name}
createdDate={request.createdDate}
message={request.message}
username={request.requester.username}
/>
))
) : (
<States>
<StatesTitle>{t('No_requests')}</StatesTitle>
<StatesSubtitle>{t('App_requests_by_workspace')}</StatesSubtitle>
</States>
)}
</Box>
{isSuccess && paginatedAppRequests.data?.length && (
<Pagination
divider
count={paginatedAppRequests.meta.total}
itemsPerPage={paginatedAppRequests.meta.limit}
current={paginatedAppRequests.meta.offset}
onSetItemsPerPage={onSetItemsPerPage}
onSetCurrent={onSetCurrent}
/>
)}
</Box>
);
};
export default AppRequests;

View File

@@ -0,0 +1,21 @@
import { Box, Skeleton } from '@rocket.chat/fuselage';
const AppRequestsLoading = () => {
const appRequestsLoading = Array.from({ length: 5 }, (_, i) => (
<Box key={i} display='flex' flexDirection='row' paddingBlock={12} paddingInlineEnd={24} marginBlockEnd={8}>
<Box is='section' marginInlineEnd={8} marginBlockStart={2} display='flex' flexDirection='row' alignItems='flex-start' height='full'>
<Skeleton variant='rect' height='x36' width='x36' />
</Box>
<Box is='section' display='flex' flexDirection='column'>
<Box display='flex' flexDirection='row' alignItems='flex-start' marginBlockEnd={4}>
<Skeleton variant='rect' height='x16' width='x215' />
</Box>
<Skeleton variant='rect' height='x60' width='x516' />
</Box>
</Box>
));
return <>{appRequestsLoading}</>;
};
export default AppRequestsLoading;

View File

@@ -0,0 +1,57 @@
import type { AppPermission } from '@rocket.chat/core-typings';
import { Box, Margins } from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
import AppSecurityLabel from './AppSecurityLabel';
import AppPermissionsList from '../../../components/AppPermissionsList';
export type AppSecurityProps = {
privacyPolicySummary?: string;
appPermissions?: AppPermission[];
tosLink?: string;
privacyLink?: string;
};
const AppSecurity = ({ privacyPolicySummary, appPermissions, tosLink, privacyLink }: AppSecurityProps) => {
const { t } = useTranslation();
return (
<Box maxWidth='x640' width='full' marginInline='auto' color='default'>
<Box display='flex' flexDirection='column'>
<Margins block={16}>
<Box is='section'>
<AppSecurityLabel>{t('Privacy_summary')}</AppSecurityLabel>
<Box is='p' lineHeight='x20'>
{privacyPolicySummary?.length && privacyPolicySummary}
</Box>
</Box>
<Box is='section'>
<AppSecurityLabel>{t('Permissions')}</AppSecurityLabel>
<Box is='ol' type='1' style={{ listStyleType: 'decimal' }} marginInlineStart={24}>
<AppPermissionsList appPermissions={appPermissions} />
</Box>
</Box>
<Box is='section'>
<AppSecurityLabel>{t('Policies')}</AppSecurityLabel>
<Box display='flex' flexDirection='column'>
{tosLink && (
<Box is='a' href={tosLink} target='_blank'>
{t('Terms_of_use')}
</Box>
)}
{privacyLink && (
<Box is='a' href={privacyLink} target='_blank'>
{t('Privacy_policy')}
</Box>
)}
</Box>
</Box>
</Margins>
</Box>
</Box>
);
};
export default AppSecurity;

View File

@@ -0,0 +1,11 @@
import { Box } from '@rocket.chat/fuselage';
export type AppSecurityLabelProps = { children: string };
const AppSecurityLabel = ({ children }: AppSecurityLabelProps) => (
<Box fontScale='h4' marginBlockEnd={8} color='titles-labels'>
{children}
</Box>
);
export default AppSecurityLabel;

View File

@@ -0,0 +1,58 @@
import type { ISettingSelectValue } from '@rocket.chat/apps-engine/definition/settings';
import type { ISetting } from '@rocket.chat/apps-engine/definition/settings/ISetting';
import { useRouteParameter } from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import MarkdownText from '../../../../../components/MarkdownText';
import MemoizedSetting from '../../../../admin/settings/Setting/MemoizedSetting';
import { useAppTranslation } from '../../../hooks/useAppTranslation';
const AppSetting = ({ id, type, i18nLabel, i18nDescription, values, value, packageValue, ...props }: ISetting) => {
const appId = useRouteParameter('id');
const tApp = useAppTranslation(appId || '');
const label = (i18nLabel && tApp(i18nLabel)) || id || tApp(id);
const hint = useMemo(() => i18nDescription && <MarkdownText content={tApp(i18nDescription)} />, [i18nDescription, tApp]);
const { control } = useFormContext();
let translatedValues: ISettingSelectValue[];
if (values?.length) {
translatedValues = values.map((selectFieldEntry) => {
const { key, i18nLabel } = selectFieldEntry;
if (!i18nLabel) {
return selectFieldEntry;
}
return {
key,
i18nLabel: tApp(i18nLabel),
};
});
}
return (
<Controller
defaultValue={value || packageValue}
name={id}
control={control}
render={({ field: { onChange, value } }) => (
<MemoizedSetting
packageValue={packageValue}
type={type}
label={label}
hint={hint}
_id={id}
{...(translatedValues && { values: translatedValues })}
{...props}
onChangeValue={onChange}
value={value}
/>
)}
/>
);
};
export default AppSetting;

View File

@@ -0,0 +1,48 @@
import { Box, FieldGroup, Accordion, AccordionItem } from '@rocket.chat/fuselage';
import { useRouteParameter } from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';
import AppSetting from './AppSetting';
import type { ISettings } from '../../../definitions/ISettings';
import { useAppTranslation } from '../../../hooks/useAppTranslation';
export type AppSettingsProps = { settings: ISettings };
const AppSettings = ({ settings }: AppSettingsProps) => {
const appId = useRouteParameter('id');
const tApp = useAppTranslation(appId || '');
const groupedSettings = useMemo(() => {
const groups = Object.values(settings).reduce(
(acc, setting) => {
const section = setting.section || 'general';
if (!acc[section]) {
acc[section] = [];
}
acc[section].push(setting);
return acc;
},
{} as Record<string, (typeof settings)[keyof typeof settings][]>,
);
return Object.entries(groups);
}, [settings]);
return (
<Box display='flex' flexDirection='column' maxWidth='x640' width='full' marginInline='auto'>
<Accordion>
{groupedSettings.map(([section, sectionSettings], index) => (
<AccordionItem key={section} title={tApp(section)} defaultExpanded={index === 0}>
<FieldGroup>
{sectionSettings.map((field) => (
<AppSetting key={field.id} {...field} />
))}
</FieldGroup>
</AccordionItem>
))}
</Accordion>
</Box>
);
};
export default AppSettings;

View File

@@ -0,0 +1 @@
export { default } from './AppSettings';

View File

@@ -0,0 +1,31 @@
import { faker } from '@faker-js/faker';
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import AppStatus from './AppStatus';
import { mockedAppsContext } from '../../../../../../tests/mocks/client/marketplace';
import { createFakeApp, createFakeLicenseInfo } from '../../../../../../tests/mocks/data';
it('should look good', async () => {
const app = createFakeApp();
render(<AppStatus app={app} showStatus isAppDetailsPage />, {
wrapper: mockAppRoot()
.withJohnDoe()
.withEndpoint('GET', '/apps/count', async () => ({
maxMarketplaceApps: faker.number.int({ min: 0 }),
installedApps: faker.number.int({ min: 0 }),
maxPrivateApps: faker.number.int({ min: 0 }),
totalMarketplaceEnabled: faker.number.int({ min: 0 }),
totalPrivateEnabled: faker.number.int({ min: 0 }),
}))
.withEndpoint('GET', '/v1/licenses.info', async () => ({
license: createFakeLicenseInfo(),
}))
.wrap(mockedAppsContext)
.build(),
});
await userEvent.click(screen.getByRole('button', { name: 'Request' }));
});

View File

@@ -0,0 +1,181 @@
import type { App } from '@rocket.chat/core-typings';
import { Box, Button, Tag, Margins, Icon } from '@rocket.chat/fuselage';
import { useSafely } from '@rocket.chat/fuselage-hooks';
import { useRouteParameter, usePermission, useSetModal } from '@rocket.chat/ui-contexts';
import { useCallback, useState, memo } from 'react';
import { useTranslation } from 'react-i18next';
import semver from 'semver';
import AppStatusPriceDisplay from './AppStatusPriceDisplay';
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';
import { useAppInstallationHandler } from '../../../hooks/useAppInstallationHandler';
import { useMarketplaceActions } from '../../../hooks/useMarketplaceActions';
export type AppStatusProps = {
app: App;
showStatus?: boolean;
isAppDetailsPage: boolean;
installed?: boolean;
};
const AppStatus = ({ app, showStatus = true, isAppDetailsPage, installed, ...props }: AppStatusProps) => {
const { t } = useTranslation();
const [endUserRequested, setEndUserRequested] = useState(false);
const [loading, setLoading] = useSafely(useState(false));
const [isAppPurchased, setPurchased] = useSafely(useState(!!app?.isPurchased));
const setModal = useSetModal();
const isAdminUser = usePermission('manage-apps');
const context = useRouteParameter('context');
const { price, purchaseType, pricingPlans } = app;
const button = appButtonProps({ ...app, isAdminUser, endUserRequested });
const isAppRequestsPage = context === 'requested';
const shouldShowPriceDisplay = isAppDetailsPage && button && !app.isEnterpriseOnly;
const canUpdate = installed && app?.version && app?.marketplaceVersion && semver.lt(app?.version, app?.marketplaceVersion);
const { data } = useIsEnterprise();
const isEnterprise = data?.isEnterprise ?? false;
const appAddon = app.addon;
const { data: workspaceHasAddon = false } = useHasLicenseModule(appAddon);
const statuses = appMultiStatusProps(app, isAppDetailsPage, context || '', isEnterprise);
const totalSeenRequests = app?.appRequestStats?.totalSeen;
const totalUnseenRequests = app?.appRequestStats?.totalUnseen;
const action = button?.action;
const marketplaceActions = useMarketplaceActions();
const confirmAction = useCallback<AppInstallationHandlerParams['onSuccess']>(
async (action, permissionsGranted) => {
if (action) {
if (action !== 'request') {
await marketplaceActions[action]({ ...app, permissionsGranted });
} else {
setEndUserRequested(true);
}
}
setLoading(false);
},
[app, marketplaceActions, setLoading],
);
const cancelAction = useCallback(() => {
setLoading(false);
setModal(null);
}, [setLoading, setModal]);
const appInstallationHandler = useAppInstallationHandler({
app,
action: action || 'purchase',
isAppPurchased,
onDismiss: cancelAction,
onSuccess: confirmAction,
setIsPurchased: setPurchased,
});
const handleAcquireApp = useCallback(() => {
setLoading(true);
if (isAdminUser && appAddon && !workspaceHasAddon) {
const actionType = button?.action === 'update' ? 'update' : 'install';
return setModal(<AddonRequiredModal actionType={actionType} onDismiss={cancelAction} onInstallAnyway={appInstallationHandler} />);
}
appInstallationHandler();
}, [button?.action, appAddon, appInstallationHandler, cancelAction, isAdminUser, setLoading, setModal, workspaceHasAddon]);
const getStatusVariant = (status: appStatusSpanResponseProps) => {
if (isAppRequestsPage && totalUnseenRequests && status.type === 'primary') {
return 'primary';
}
if (status.type === 'danger') {
return 'secondary-danger';
}
return undefined;
};
const getStatusFontColor = (status: appStatusSpanResponseProps) => {
if (status.type === 'warning') {
return 'status-font-on-warning';
}
if (status.type === 'danger') {
return 'status-font-on-danger';
}
return 'font-default';
};
const handleAppRequestsNumber = (status: appStatusSpanResponseProps) => {
if ((status.label === 'request' || status.label === 'requests') && !installed && isAppRequestsPage) {
let numberOfRequests = 0;
if (totalUnseenRequests >= 0) {
numberOfRequests += totalUnseenRequests;
}
if (totalSeenRequests >= 0) {
numberOfRequests += totalSeenRequests;
}
return numberOfRequests;
}
return null;
};
return (
<Box {...props} display='flex' alignItems='center' marginInlineEnd={8}>
{button && isAppDetailsPage && (!installed || canUpdate) && (
<Box
display='flex'
flexDirection='row'
alignItems='center'
justifyContent='center'
borderRadius='x4'
invisible={!showStatus && !loading}
>
<Button
icon={button.icon}
primary
small
loading={loading}
disabled={action === 'request' && (app?.requestedEndUser || endUserRequested)}
onClick={handleAcquireApp}
marginInlineEnd={8}
>
{t(button.label)}
</Button>
{shouldShowPriceDisplay && !installed && (
<AppStatusPriceDisplay purchaseType={purchaseType} pricingPlans={pricingPlans} price={price} showType={false} />
)}
</Box>
)}
{statuses?.map((status, index) => (
<Margins inlineEnd={index !== statuses.length - 1 ? 8 : undefined} key={index}>
<Tag data-qa-type='app-status-tag' variant={getStatusVariant(status)} title={status.tooltipText ? status.tooltipText : ''}>
<Box display='flex' color={getStatusFontColor(status)} alignItems='center'>
{status.icon && <Icon name={status.icon} size={16} marginInlineEnd={2} />}
{handleAppRequestsNumber(status)} {t(status.label)}
</Box>
</Tag>
</Margins>
))}
</Box>
);
};
export default memo(AppStatus);

View File

@@ -0,0 +1,34 @@
import type { AppPricingPlan, PurchaseType } from '@rocket.chat/core-typings';
import { Box, Margins, Tag } from '@rocket.chat/fuselage';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { formatPriceAndPurchaseType } from '../../../helpers/formatPriceAndPurchaseType';
export type AppStatusPriceDisplayProps = {
purchaseType: PurchaseType;
pricingPlans: AppPricingPlan[];
price: number;
showType?: boolean;
marginInline?: string;
};
const AppStatusPriceDisplay = ({ purchaseType, pricingPlans, price, showType = true }: AppStatusPriceDisplayProps) => {
const { t, i18n } = useTranslation();
const { type, price: formattedPrice } = useMemo(
() => formatPriceAndPurchaseType(purchaseType, pricingPlans, price),
[purchaseType, pricingPlans, price],
);
return (
<Margins inline={4}>
<Tag>
{showType && <Box color='default'>{i18n.exists(type) ? t(type) : type}</Box>}
<Box>{!showType && type === 'Free' ? t(type) : formattedPrice}</Box>
</Tag>
</Margins>
);
};
export default AppStatusPriceDisplay;

View File

@@ -0,0 +1 @@
export { default } from './AppStatus';

View File

@@ -0,0 +1,6 @@
import { useBreakpoints } from '@rocket.chat/fuselage-hooks';
export const useCompactMode = () => {
const breakpoint = useBreakpoints();
return !breakpoint.includes('lg');
};

View File

@@ -0,0 +1,48 @@
import { Box, ModalFooterAnnotation } from '@rocket.chat/fuselage';
import { GenericModal } from '@rocket.chat/ui-client';
import { useTranslation } from 'react-i18next';
import { useExternalLink } from '../../hooks/useExternalLink';
import { useCheckoutUrl } from '../admin/subscription/hooks/useCheckoutUrl';
import { PRICING_LINK } from '../admin/subscription/utils/links';
export type AppExemptModalProps = {
onCancel: () => void;
appName: string;
};
const AppExemptModal = ({ onCancel, appName }: AppExemptModalProps) => {
const { t } = useTranslation();
const openExternalLink = useExternalLink();
const manageSubscriptionUrl = useCheckoutUrl()({ target: 'private-apps-page', action: 'upgrade' });
const goToManageSubscriptionPage = (): void => {
openExternalLink(manageSubscriptionUrl);
onCancel();
};
return (
<GenericModal
title={t('Apps_Cannot_Be_Updated')}
onClose={onCancel}
dontAskAgain={
<ModalFooterAnnotation>
<a target='_blank' rel='noopener noreferrer' href={PRICING_LINK}>
{t('Compare_plans')}
</a>
</ModalFooterAnnotation>
}
variant='warning'
cancelText={t('Cancel')}
confirmText={t('Upgrade')}
onCancel={onCancel}
onConfirm={goToManageSubscriptionPage}
>
<Box marginBlockEnd={28}>{t('Apps_Private_App_Is_Exempt', { appName })}</Box>
{t('Upgrade_subscription_to_enable_private_apps')}
</GenericModal>
);
};
export default AppExemptModal;

View File

@@ -0,0 +1,73 @@
import { Button, ButtonGroup, Field, FieldGroup, FieldLabel, FieldRow, TextInput } from '@rocket.chat/fuselage';
import { PageScrollableContent, Page, PageHeader } from '@rocket.chat/ui-client';
import { useTranslation, useRouter } from '@rocket.chat/ui-contexts';
import { useCallback, useId } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { useInstallApp } from './hooks/useInstallApp';
import { useSingleFileInput } from '../../hooks/useSingleFileInput';
const AppInstallPage = () => {
const t = useTranslation();
const router = useRouter();
const { control, setValue, watch } = useForm<{ file: File }>();
const { file } = watch();
const { install, isInstalling } = useInstallApp(file);
const [handleUploadButtonClick] = useSingleFileInput((value) => setValue('file', value), 'app');
const handleCancel = useCallback(() => {
router.navigate({
name: 'marketplace',
params: {
context: 'private',
page: 'list',
},
});
}, [router]);
const fileField = useId();
return (
<Page flexDirection='column'>
<PageHeader title={t('App_Installation')} />
<PageScrollableContent>
<FieldGroup display='flex' flexDirection='column' alignSelf='center' maxWidth='x600' width='full'>
<Field>
<FieldLabel htmlFor={fileField}>{t('App_Url_to_Install_From_File')}</FieldLabel>
<FieldRow>
<Controller
name='file'
control={control}
render={({ field }) => (
<TextInput
id={fileField}
readOnly
{...field}
value={field.value?.name || ''}
endAddon={
<Button icon='upload' small primary onClick={handleUploadButtonClick} marginBlock='neg-x4' marginInlineEnd='neg-x8'>
{t('Browse_Files')}
</Button>
}
/>
)}
/>
</FieldRow>
</Field>
<Field>
<ButtonGroup>
<Button disabled={!file?.name} loading={isInstalling} onClick={install}>
{t('Install')}
</Button>
<Button onClick={handleCancel}>{t('Cancel')}</Button>
</ButtonGroup>
</Field>
</FieldGroup>
</PageScrollableContent>
</Page>
);
};
export default AppInstallPage;

View File

@@ -0,0 +1,28 @@
import { faker } from '@faker-js/faker';
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen } from '@testing-library/react';
import AppMenu from './AppMenu';
import { mockedAppsContext } from '../../../tests/mocks/client/marketplace';
import { createFakeApp } from '../../../tests/mocks/data';
describe('without app details', () => {
it('should look good', async () => {
const app = createFakeApp();
render(<AppMenu app={app} isAppDetailsPage={false} />, {
wrapper: mockAppRoot()
.withEndpoint('GET', '/apps/count', async () => ({
maxMarketplaceApps: faker.number.int({ min: 0 }),
installedApps: faker.number.int({ min: 0 }),
maxPrivateApps: faker.number.int({ min: 0 }),
totalMarketplaceEnabled: faker.number.int({ min: 0 }),
totalPrivateEnabled: faker.number.int({ min: 0 }),
}))
.wrap(mockedAppsContext)
.build(),
});
expect(screen.getByRole('button', { name: 'More_options' })).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,48 @@
import type { App } from '@rocket.chat/core-typings';
import { MenuItem, MenuItemContent, MenuSection, Menu, Skeleton } from '@rocket.chat/fuselage';
import { useHandleMenuAction } from '@rocket.chat/ui-client';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import type { AppMenuOption } from './hooks/useAppMenu';
import { useAppMenu } from './hooks/useAppMenu';
export type AppMenuProps = {
app: App;
isAppDetailsPage: boolean;
};
const AppMenu = ({ app, isAppDetailsPage }: AppMenuProps) => {
const { t } = useTranslation();
const { isLoading, isAdminUser, sections } = useAppMenu(app, isAppDetailsPage);
const itemsList = sections.reduce((acc, { items }) => [...acc, ...items], [] as AppMenuOption[]);
const onAction = useHandleMenuAction(itemsList);
const disabledKeys = itemsList.filter((item) => item.disabled).map((item) => item.id);
if (isLoading) {
return <Skeleton variant='rect' height='x28' width='x28' />;
}
if (!isAdminUser && app?.installed && sections.length === 0) {
return null;
}
return (
<Menu title={t('More_options')} onAction={onAction} disabledKeys={disabledKeys} detached>
{sections.map(({ items }, idx) => (
<MenuSection key={idx} items={items}>
{items.map((option) => (
<MenuItem key={option.id}>
<MenuItemContent>{option.content}</MenuItemContent>
</MenuItem>
))}
</MenuSection>
))}
</Menu>
);
};
export default memo(AppMenu);

View File

@@ -0,0 +1,36 @@
import type { App } from '@rocket.chat/core-typings';
import { Box } from '@rocket.chat/fuselage';
import { GenericModal } from '@rocket.chat/ui-client';
import { useTranslation } from 'react-i18next';
import AppPermissionsList from './components/AppPermissionsList';
export type AppPermissionsReviewModalProps = {
appPermissions: App['permissions'];
onCancel: () => void;
onConfirm: (permissionsGranted: AppPermissionsReviewModalProps['appPermissions']) => void;
};
const AppPermissionsReviewModal = ({ appPermissions, onCancel, onConfirm }: AppPermissionsReviewModalProps) => {
const { t } = useTranslation();
return (
<GenericModal
variant='warning'
title={t('Apps_Permissions_Review_Modal_Title')}
onCancel={onCancel}
onConfirm={(): void => onConfirm(appPermissions)}
onClose={onCancel}
confirmText={t('Agree')}
>
<Box fontScale='p2' marginBlockEnd={20}>
{t('Apps_Permissions_Review_Modal_Subtitle')}
</Box>
<Box is='ol' type='1' style={{ listStyleType: 'decimal' }} marginInlineStart={24}>
<AppPermissionsList appPermissions={appPermissions} />
</Box>
</GenericModal>
);
};
export default AppPermissionsReviewModal;

View File

@@ -0,0 +1,56 @@
import {
Button,
Modal,
ModalClose,
ModalContent,
ModalFooter,
ModalFooterControllers,
ModalHeader,
ModalIcon,
ModalTitle,
} from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
export type AppUpdateModalProps = {
confirm: () => void;
cancel: () => void;
};
const AppUpdateModal = ({ confirm, cancel, ...props }: AppUpdateModalProps) => {
const { t } = useTranslation();
const handleCloseButtonClick = (): void => {
cancel();
};
const handleCancelButtonClick = (): void => {
cancel();
};
const handleConfirmButtonClick = (): void => {
confirm();
};
return (
<Modal {...props}>
<ModalHeader>
<ModalIcon color='status-font-on-danger' name='info-circled' />
<ModalTitle>{t('Apps_Manual_Update_Modal_Title')}</ModalTitle>
<ModalClose onClick={handleCloseButtonClick} />
</ModalHeader>
<ModalContent fontScale='p2'>{t('Apps_Manual_Update_Modal_Body')}</ModalContent>
<ModalFooter>
<ModalFooterControllers>
<Button secondary onClick={handleCancelButtonClick}>
{t('No')}
</Button>
<Button danger onClick={handleConfirmButtonClick}>
{t('Yes')}
</Button>
</ModalFooterControllers>
</ModalFooter>
</Modal>
);
};
export default AppUpdateModal;

View File

@@ -0,0 +1,23 @@
import type { App } from '@rocket.chat/core-typings';
import { Tag } from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
export 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;

View File

@@ -0,0 +1,53 @@
import {
Button,
Modal,
ModalClose,
ModalContent,
ModalFooter,
ModalFooterControllers,
ModalHeader,
ModalHeaderText,
ModalTitle,
} from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
import { useExternalLink } from '../../../hooks/useExternalLink';
import { GET_ADDONS_LINK } from '../../admin/subscription/utils/links';
export type AddonActionType = 'install' | 'enable' | 'update';
export type AddonRequiredModalProps = {
actionType: AddonActionType;
onDismiss: () => void;
onInstallAnyway: () => void;
};
const AddonRequiredModal = ({ actionType, onDismiss, onInstallAnyway }: AddonRequiredModalProps) => {
const { t } = useTranslation();
const handleOpenLink = useExternalLink();
return (
<Modal>
<ModalHeader>
<ModalHeaderText>
<ModalTitle>{t('Add-on_required')}</ModalTitle>
</ModalHeaderText>
<ModalClose onClick={onDismiss} />
</ModalHeader>
<ModalContent>{t('Add-on_required_modal_enable_content')}</ModalContent>
<ModalFooter>
<ModalFooterControllers>
{['install', 'update'].includes(actionType) && (
<Button onClick={onInstallAnyway}>{actionType === 'install' ? t('Install_anyway') : t('Update_anyway')}</Button>
)}
<Button primary onClick={() => handleOpenLink(GET_ADDONS_LINK)}>
{t('Contact_sales')}
</Button>
</ModalFooterControllers>
</ModalFooter>
</Modal>
);
};
export default AddonRequiredModal;

View File

@@ -0,0 +1,87 @@
import type { App } from '@rocket.chat/core-typings';
import { Badge, Card, CardBody, CardCol, CardControls, CardHeader, CardRow, CardTitle } from '@rocket.chat/fuselage';
import { AppAvatar } from '@rocket.chat/ui-avatar';
import { useRouteParameter, useRouter } from '@rocket.chat/ui-contexts';
import type { KeyboardEvent, MouseEvent } from 'react';
import { memo } from 'react';
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 }) => {
const { name, id, shortDescription, iconFileData, marketplaceVersion, iconFileContent, installed, bundledIn, version } = props;
const router = useRouter();
const context = useRouteParameter('context');
const handleNavigateToAppInfo = () => {
if (!context) {
return;
}
router.navigate({
name: 'marketplace',
params: {
context,
page: 'info',
version: marketplaceVersion || version,
id,
tab: 'details',
},
});
};
const handleKeyDown = (e: KeyboardEvent<HTMLOrSVGElement>) => {
if (!['Enter', 'Space'].includes(e.nativeEvent.code)) {
return;
}
handleNavigateToAppInfo();
};
const preventClickPropagation = (e: MouseEvent<HTMLOrSVGElement>) => {
e.stopPropagation();
};
const canUpdate = installed && version && marketplaceVersion && semver.lt(version, marketplaceVersion);
return (
<div role='listitem' className={className} key={id}>
<Card
data-qa-type='app-row'
horizontal
clickable
role='link'
aria-labelledby={`${id}-title`}
aria-describedby={`${id}-description`}
onClick={handleNavigateToAppInfo}
onKeyDown={handleKeyDown}
tabIndex={0}
>
<CardRow>
<AppAvatar size='x40' iconFileContent={iconFileContent} iconFileData={iconFileData} />
<CardCol>
<CardHeader>
<CardTitle variant='h5' id={`${id}-title`}>
{name}
</CardTitle>
{Boolean(bundledIn?.length) && <BundleChips bundledIn={bundledIn} />}
<AddonChip app={props} />
</CardHeader>
{shortDescription && <CardBody id={`${id}-description`}>{shortDescription}</CardBody>}
</CardCol>
</CardRow>
<CardControls onClick={preventClickPropagation}>
{canUpdate && <Badge small variant='primary' />}
<AppStatus app={props} isAppDetailsPage={false} installed={installed} />
<AppMenu app={props} isAppDetailsPage={false} />
</CardControls>
</Card>
</div>
);
};
export default memo(AppRow);

View File

@@ -0,0 +1,29 @@
import type { App } from '@rocket.chat/core-typings';
import { Box, CardGroup } from '@rocket.chat/fuselage';
import AppRow from './AppRow';
export type AppsListProps = {
apps: App[];
title?: string;
appsListId: string;
};
const AppsList = ({ apps, title, appsListId }: AppsListProps) => {
return (
<Box marginBlockEnd={16}>
{title && (
<Box is='h2' id={appsListId} fontScale='h3' color='default' marginBlockEnd={20}>
{title}
</Box>
)}
<CardGroup vertical stretch aria-labelledby={appsListId} role='list'>
{apps.map((app) => (
<AppRow key={app.id} {...app} />
))}
</CardGroup>
</Box>
);
};
export default AppsList;

View File

@@ -0,0 +1 @@
export { default } from './AppsList';

View File

@@ -0,0 +1,75 @@
import { Box } from '@rocket.chat/fuselage';
import { useBreakpoints } from '@rocket.chat/fuselage-hooks';
import { useTranslation } from 'react-i18next';
import FilterByText from '../../../components/FilterByText';
import CategoryDropDown from '../components/CategoryFilter/CategoryDropDown';
import TagList from '../components/CategoryFilter/TagList';
import RadioDropDown from '../components/RadioDropDown/RadioDropDown';
import type { CategoryDropDownListProps, CategoryOnSelected, selectedCategoriesList } from '../definitions/CategoryDropdownDefinitions';
import type { RadioDropDownGroup, RadioDropDownOnSelected } from '../definitions/RadioDropDownDefinitions';
export type AppsFiltersProps = {
text: string;
setText: (text: string) => void;
freePaidFilterStructure: RadioDropDownGroup;
freePaidFilterOnSelected: RadioDropDownOnSelected;
categories: CategoryDropDownListProps['categories'];
selectedCategories: selectedCategoriesList;
onSelected: CategoryOnSelected;
sortFilterStructure: RadioDropDownGroup;
sortFilterOnSelected: RadioDropDownOnSelected;
categoryTagList: selectedCategoriesList;
statusFilterStructure: RadioDropDownGroup;
statusFilterOnSelected: RadioDropDownOnSelected;
context: string;
};
const AppsFilters = ({
text,
setText,
freePaidFilterStructure,
freePaidFilterOnSelected,
categories,
selectedCategories,
onSelected,
sortFilterStructure,
sortFilterOnSelected,
categoryTagList,
statusFilterStructure,
statusFilterOnSelected,
context,
}: AppsFiltersProps) => {
const { t } = useTranslation();
const isPrivateAppsPage = context === 'private';
const breakpoints = useBreakpoints();
const appsSearchPlaceholders: { [key: string]: string } = {
explore: t('Search_Apps'),
enterprise: t('Search_Premium_Apps'),
installed: t('Search_Installed_Apps'),
requested: t('Search_Requested_Apps'),
private: t('Search_Private_apps'),
};
const fixFiltersSize = breakpoints.includes('lg') ? { maxWidth: 'x200', minWidth: 'x200' } : null;
return (
<Box paddingInline={24}>
<FilterByText value={text} onChange={(event) => setText(event.target.value)} placeholder={appsSearchPlaceholders[context]}>
{!isPrivateAppsPage && (
<RadioDropDown group={freePaidFilterStructure} onSelected={freePaidFilterOnSelected} flexGrow={1} {...fixFiltersSize} />
)}
<RadioDropDown group={statusFilterStructure} onSelected={statusFilterOnSelected} flexGrow={1} {...fixFiltersSize} />
{!isPrivateAppsPage && (
<CategoryDropDown categories={categories} selectedCategories={selectedCategories} onSelected={onSelected} flexGrow={1} />
)}
<RadioDropDown group={sortFilterStructure} onSelected={sortFilterOnSelected} flexGrow={1} {...fixFiltersSize} />
</FilterByText>
<TagList categories={categoryTagList} onClick={onSelected} />
</Box>
);
};
export default AppsFilters;

View File

@@ -0,0 +1,13 @@
import { Page } from '@rocket.chat/ui-client';
import AppsPageContent from './AppsPageContent';
const AppsPage = () => {
return (
<Page background='tint'>
<AppsPageContent />
</Page>
);
};
export default AppsPage;

View File

@@ -0,0 +1,23 @@
import { Box, States, StatesIcon, StatesTitle, StatesSubtitle, StatesActions, StatesAction } from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
const AppsPageContentError = ({ onButtonClick }: { onButtonClick: () => void }) => {
const { t } = useTranslation();
return (
<Box marginBlockStart={20}>
<States>
<StatesIcon variation='danger' name='circle-exclamation' />
<StatesTitle>{t('Connection_error')}</StatesTitle>
<StatesSubtitle>{t('Marketplace_error')}</StatesSubtitle>
<StatesActions>
<StatesAction icon='reload' onClick={onButtonClick}>
{t('Reload_page')}
</StatesAction>
</StatesActions>
</States>
</Box>
);
};
export default AppsPageContentError;

View File

@@ -0,0 +1,261 @@
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import { usePagination } from '@rocket.chat/ui-client';
import { useRouteParameter, useRouter } from '@rocket.chat/ui-contexts';
import { useEffect, useMemo, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import AppsFilters from './AppsFilters';
import AppsPageConnectionError from './AppsPageConnectionError';
import AppsPageContentBody from './AppsPageContentBody';
import AppsPageContentSkeleton from './AppsPageContentSkeleton';
import NoAppRequestsEmptyState from './NoAppRequestsEmptyState';
import NoInstalledAppMatchesEmptyState from './NoInstalledAppMatchesEmptyState';
import NoInstalledAppsEmptyState from './NoInstalledAppsEmptyState';
import NoMarketplaceOrInstalledAppMatchesEmptyState from './NoMarketplaceOrInstalledAppMatchesEmptyState';
import PrivateEmptyState from './PrivateEmptyState';
import UnsupportedEmptyState from './UnsupportedEmptyState';
import MarketplaceHeader from '../components/MarketplaceHeader';
import type { RadioDropDownGroup } from '../definitions/RadioDropDownDefinitions';
import { useAppsReload } from '../hooks/useAppsReload';
import { useCategories } from '../hooks/useCategories';
import { useFilteredApps } from '../hooks/useFilteredApps';
import { useRadioToggle } from '../hooks/useRadioToggle';
type AppsContext = 'explore' | 'installed' | 'premium' | 'private' | 'requested';
const AppsPageContent = () => {
const { t } = useTranslation();
const reload = useAppsReload();
const [text, setText] = useState('');
const debouncedText = useDebouncedValue(text, 500);
const { current, itemsPerPage, setItemsPerPage: onSetItemsPerPage, setCurrent: onSetCurrent, ...paginationProps } = usePagination();
const router = useRouter();
const context = (useRouteParameter('context') as AppsContext | undefined) ?? 'explore';
const isMarketplace = context === 'explore';
const isPremium = context === 'premium';
const isRequested = context === 'requested';
const [freePaidFilterStructure, setFreePaidFilterStructure] = useState({
label: t('Filter_By_Price'),
items: [
{ id: 'all', label: t('All_Prices'), checked: true },
{ id: 'free', label: t('Free_Apps'), checked: false },
{ id: 'paid', label: t('Paid_Apps'), checked: false },
{ id: 'premium', label: t('Premium'), checked: false },
],
});
const freePaidFilterOnSelected = useRadioToggle(setFreePaidFilterStructure);
const [statusFilterStructure, setStatusFilterStructure] = useState({
label: t('Filter_By_Status'),
items: [
{ id: 'all', label: t('All_statuses'), checked: true },
{ id: 'enabled', label: t('Enabled'), checked: false },
{ id: 'disabled', label: t('Disabled'), checked: false },
],
});
const statusFilterOnSelected = useRadioToggle(setStatusFilterStructure);
const baseFilterStructureItems = [
{ id: 'az', label: 'A-Z', checked: false },
{ id: 'za', label: 'Z-A', checked: false },
{ id: 'mru', label: t('Most_recent_updated'), checked: true },
{ id: 'lru', label: t('Least_recent_updated'), checked: false },
];
const requestedFilterItems = [
{ id: 'urf', label: t('Unread_Requested_First'), checked: false },
{ id: 'url', label: t('Unread_Requested_Last'), checked: false },
];
const createFilterStructureItems = () => {
return isRequested ? [...requestedFilterItems, ...baseFilterStructureItems] : baseFilterStructureItems;
};
const [sortFilterStructure, setSortFilterStructure] = useState<RadioDropDownGroup>(() => {
return {
label: t('Sort_By'),
items: createFilterStructureItems(),
};
});
useEffect(() => {
setSortFilterStructure({
label: t('Sort_By'),
items: createFilterStructureItems(),
});
}, [isRequested]);
const sortFilterOnSelected = useRadioToggle(setSortFilterStructure);
const findSort = () => {
const possibleSort = sortFilterStructure.items.find(({ checked }) => checked);
return possibleSort ? possibleSort.id : 'mru';
};
const findPurchaseType = () => {
const possiblePurchaseType = freePaidFilterStructure.items.find(({ checked }) => checked);
return possiblePurchaseType ? possiblePurchaseType.id : 'all';
};
const findStatus = () => {
const possibleStatus = statusFilterStructure.items.find(({ checked }) => checked);
return possibleStatus ? possibleStatus.id : 'all';
};
const [categories, selectedCategories, categoryTagList, onSelected] = useCategories();
const { isPending, isError, error, data } = useFilteredApps({
text: debouncedText,
current,
itemsPerPage,
categories: useMemo(() => selectedCategories.map(({ label }) => label), [selectedCategories]),
purchaseType: useMemo(findPurchaseType, [freePaidFilterStructure]),
sortingMethod: useMemo(findSort, [sortFilterStructure]),
status: useMemo(findStatus, [statusFilterStructure]),
context,
});
const isFiltered =
Boolean(text.length) ||
freePaidFilterStructure.items.find((item) => item.checked)?.id !== 'all' ||
statusFilterStructure.items.find((item) => item.checked)?.id !== 'all' ||
sortFilterStructure.items.find((item) => item.checked)?.id !== 'mru' ||
selectedCategories.length > 0;
const handleReturn = () => {
router.navigate({
name: 'marketplace',
params: {
context: 'explore',
page: 'list',
},
});
};
const toggleInitialSortOption = useCallback((isRequested: boolean) => {
setSortFilterStructure((prevState) => {
prevState.items.forEach((currentItem) => {
if (isRequested && currentItem.id === 'urf') {
currentItem.checked = true;
return;
}
if (!isRequested && currentItem.id === 'mru') {
currentItem.checked = true;
return;
}
currentItem.checked = false;
});
return { ...prevState };
});
}, []);
useEffect(() => {
toggleInitialSortOption(isRequested);
}, [isMarketplace, isRequested, sortFilterOnSelected, t, toggleInitialSortOption]);
if (isPending) {
return (
<>
<MarketplaceHeader unsupportedVersion={false} title={t(`Apps_context_${context}`)} />
<AppsFilters
text={text}
setText={setText}
freePaidFilterStructure={freePaidFilterStructure}
freePaidFilterOnSelected={freePaidFilterOnSelected}
categories={categories}
selectedCategories={selectedCategories}
onSelected={onSelected}
sortFilterStructure={sortFilterStructure}
sortFilterOnSelected={sortFilterOnSelected}
categoryTagList={categoryTagList}
statusFilterStructure={statusFilterStructure}
statusFilterOnSelected={statusFilterOnSelected}
context={context}
/>
<AppsPageContentSkeleton />
</>
);
}
if (isError) {
// TODO: Introduce error codes, so we can avoid using error message strings for this kind of logic
// whenever we change the error message, this code ends up breaking.
const unsupportedVersion = error.message === 'Marketplace_Unsupported_Version';
return (
<>
<MarketplaceHeader unsupportedVersion={unsupportedVersion} title={t(`Apps_context_${context}`)} />
<AppsFilters
text={text}
setText={setText}
freePaidFilterStructure={freePaidFilterStructure}
freePaidFilterOnSelected={freePaidFilterOnSelected}
categories={categories}
selectedCategories={selectedCategories}
onSelected={onSelected}
sortFilterStructure={sortFilterStructure}
sortFilterOnSelected={sortFilterOnSelected}
categoryTagList={categoryTagList}
statusFilterStructure={statusFilterStructure}
statusFilterOnSelected={statusFilterOnSelected}
context={context}
/>
{unsupportedVersion ? <UnsupportedEmptyState /> : <AppsPageConnectionError onButtonClick={reload} />}
</>
);
}
return (
<>
<MarketplaceHeader unsupportedVersion={false} title={t(`Apps_context_${context}`)} />
<AppsFilters
text={text}
setText={setText}
freePaidFilterStructure={freePaidFilterStructure}
freePaidFilterOnSelected={freePaidFilterOnSelected}
categories={categories}
selectedCategories={selectedCategories}
onSelected={onSelected}
sortFilterStructure={sortFilterStructure}
sortFilterOnSelected={sortFilterOnSelected}
categoryTagList={categoryTagList}
statusFilterStructure={statusFilterStructure}
statusFilterOnSelected={statusFilterOnSelected}
context={context}
/>
{(context === 'requested' && data.count === 0 && <NoAppRequestsEmptyState />) ||
((isMarketplace || isPremium) && data.count === 0 && (
<NoMarketplaceOrInstalledAppMatchesEmptyState shouldShowSearchText={!!data.shouldShowSearchText} text={text} />
)) ||
(context === 'installed' && data.totalAppsLength !== 0 && data.count === 0 && (
<NoInstalledAppMatchesEmptyState shouldShowSearchText={!!data.shouldShowSearchText} text={text} onButtonClick={handleReturn} />
)) ||
(context === 'private' && !isMarketplace && data.totalAppsLength === 0 && <PrivateEmptyState />) ||
(context !== 'private' && !isMarketplace && data.totalAppsLength === 0 && (
<NoInstalledAppsEmptyState onButtonClick={handleReturn} />
)) || (
<AppsPageContentBody
isMarketplace={isMarketplace}
isFiltered={isFiltered}
appsResult={data}
itemsPerPage={itemsPerPage}
current={current}
onSetItemsPerPage={onSetItemsPerPage}
onSetCurrent={onSetCurrent}
paginationProps={paginationProps}
/>
)}
</>
);
};
export default AppsPageContent;

View File

@@ -0,0 +1,70 @@
import type { App } from '@rocket.chat/core-typings';
import { Box, Pagination } from '@rocket.chat/fuselage';
import type { PaginatedResult } from '@rocket.chat/rest-typings';
import type { Dispatch, SetStateAction } from 'react';
import { useId, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import AppsList from '../AppsList';
import FeaturedAppsSections from './FeaturedAppsSections';
export type AppsPageContentBodyProps = {
isMarketplace: boolean;
isFiltered: boolean;
appsResult?: PaginatedResult<{
items: App[];
shouldShowSearchText: boolean;
allApps: App[];
totalAppsLength: number;
}>;
itemsPerPage: 25 | 50 | 100;
current: number;
onSetItemsPerPage: Dispatch<SetStateAction<25 | 50 | 100>>;
onSetCurrent: Dispatch<SetStateAction<number>>;
paginationProps: {
itemsPerPageLabel: () => string;
showingResultsLabel: (context: { count: number; current: number; itemsPerPage: 25 | 50 | 100 }) => string;
};
};
const AppsPageContentBody = ({
isMarketplace,
isFiltered,
appsResult,
itemsPerPage,
current,
onSetItemsPerPage,
onSetCurrent,
paginationProps,
}: AppsPageContentBodyProps) => {
const { t } = useTranslation();
const scrollableRef = useRef<HTMLDivElement>(null);
const appsListId = useId();
return (
<>
<Box display='flex' flexDirection='column' overflow='hidden' height='100%' paddingInline={24}>
<Box overflowY='scroll' height='100%' ref={scrollableRef}>
{isMarketplace && !isFiltered && <FeaturedAppsSections appsListId={appsListId} appsResult={appsResult?.allApps || []} />}
<AppsList appsListId={appsListId} apps={appsResult?.items || []} title={isMarketplace ? t('All_Apps') : undefined} />
</Box>
</Box>
{Boolean(appsResult?.count) && (
<Pagination
divider
current={current}
itemsPerPage={itemsPerPage}
count={appsResult?.total || 0}
onSetItemsPerPage={onSetItemsPerPage}
onSetCurrent={(value) => {
onSetCurrent(value);
scrollableRef.current?.scrollTo(0, 0);
}}
{...paginationProps}
/>
)}
</>
);
};
export default AppsPageContentBody;

View File

@@ -0,0 +1,21 @@
import { Skeleton, Box } from '@rocket.chat/fuselage';
const AppsPageContentSkeleton = () => {
const loadingRows = Array.from({ length: 3 }, (_, i) => <Skeleton key={i} height='x56' marginBlockEnd={8} width='100%' variant='rect' />);
return (
<Box paddingInline={24}>
<Box marginBlockEnd={36}>
<Skeleton height='x28' width='x150' marginBlockEnd={20} variant='rect' />
{loadingRows}
</Box>
<Box marginBlockEnd={36}>
<Skeleton height='x28' width='x150' marginBlockEnd={20} variant='rect' />
{loadingRows}
</Box>
<Skeleton height='x28' width='x150' marginBlockEnd={20} variant='rect' />
{loadingRows}
</Box>
);
};
export default AppsPageContentSkeleton;

View File

@@ -0,0 +1,35 @@
import type { App } from '@rocket.chat/core-typings';
import { useTranslation } from 'react-i18next';
import AppsList from '../AppsList';
import normalizeFeaturedApps from '../helpers/normalizeFeaturedApps';
import { useFeaturedApps } from '../hooks/useFeaturedApps';
type FeaturedSectionsProps = {
appsResult: App[];
appsListId: string;
};
const FeaturedAppsSections = ({ appsResult, appsListId }: FeaturedSectionsProps) => {
const { t, i18n } = useTranslation();
const featuredApps = useFeaturedApps();
if (featuredApps.isSuccess) {
return (
<>
{featuredApps.data.sections.map((section, index) => (
<AppsList
appsListId={`${appsListId + index}`}
key={section.slug}
apps={normalizeFeaturedApps(section.apps, appsResult)}
title={i18n.exists(section.i18nLabel) ? t(section.i18nLabel) : section.i18nLabel}
/>
))}
</>
);
}
return null;
};
export default FeaturedAppsSections;

View File

@@ -0,0 +1,18 @@
import { Box, States, StatesIcon, StatesSubtitle, StatesTitle } from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
const NoAppRequestsEmptyState = () => {
const { t } = useTranslation();
return (
<Box marginBlockStart='24px'>
<States>
<StatesIcon name='cube' />
<StatesTitle>{t('No_requested_apps')}</StatesTitle>
<StatesSubtitle>{t('Requested_apps_will_appear_here')}</StatesSubtitle>
</States>
</Box>
);
};
export default NoAppRequestsEmptyState;

View File

@@ -0,0 +1,46 @@
import {
Box,
States,
StatesIcon,
StatesTitle,
StatesSubtitle,
StatesSuggestion,
StatesSuggestionText,
StatesActions,
StatesAction,
} from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
export type NoInstalledAppMatchesEmptyStateProps = {
shouldShowSearchText: boolean;
text: string;
onButtonClick: () => void;
};
const NoInstalledAppMatchesEmptyState = ({ shouldShowSearchText, text, onButtonClick }: NoInstalledAppMatchesEmptyStateProps) => {
const { t } = useTranslation();
return (
<Box marginBlockStart={20}>
<States>
<StatesIcon name='magnifier' />
<StatesTitle>{t('No_installed_app_matches')}</StatesTitle>
{shouldShowSearchText && (
<StatesSubtitle>
<span>
{t('No_app_matches_for')} <strong>"{text}"</strong>
</span>
</StatesSubtitle>
)}
<StatesSuggestion>
<StatesSuggestionText>{t('Try_searching_in_the_marketplace_instead')}</StatesSuggestionText>
</StatesSuggestion>
<StatesActions>
<StatesAction onClick={onButtonClick}>{t('Search_on_marketplace')}</StatesAction>
</StatesActions>
</States>
</Box>
);
};
export default NoInstalledAppMatchesEmptyState;

View File

@@ -0,0 +1,21 @@
import { Box, States, StatesIcon, StatesTitle, StatesSubtitle, StatesActions, StatesAction } from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
const NoInstalledAppsEmptyState = ({ onButtonClick }: { onButtonClick: () => void }) => {
const { t } = useTranslation();
return (
<Box marginBlockStart={20}>
<States>
<StatesIcon name='magnifier' />
<StatesTitle>{t('No_apps_installed')}</StatesTitle>
<StatesSubtitle>{t('Explore_the_marketplace_to_find_awesome_apps')}</StatesSubtitle>
<StatesActions>
<StatesAction onClick={onButtonClick}>{t('Explore_marketplace')}</StatesAction>
</StatesActions>
</States>
</Box>
);
};
export default NoInstalledAppsEmptyState;

View File

@@ -0,0 +1,46 @@
import {
Box,
States,
StatesIcon,
StatesSubtitle,
StatesSuggestion,
StatesSuggestionList,
StatesSuggestionListItem,
StatesSuggestionText,
StatesTitle,
} from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
export type NoMarketplaceOrInstalledAppMatchesEmptyStateProps = { shouldShowSearchText: boolean; text: string };
const NoMarketplaceOrInstalledAppMatchesEmptyState = ({
shouldShowSearchText,
text,
}: NoMarketplaceOrInstalledAppMatchesEmptyStateProps) => {
const { t } = useTranslation();
return (
<Box marginBlockStart={20}>
<States>
<StatesIcon name='magnifier' />
<StatesTitle>{t('No_app_matches')}</StatesTitle>
{shouldShowSearchText && (
<StatesSubtitle>
{t('No_marketplace_matches_for')}: <strong>"{text}"</strong>
</StatesSubtitle>
)}
<StatesSuggestion>
<StatesSuggestionText>{t('You_can_try_to')}:</StatesSuggestionText>
<StatesSuggestionList>
<StatesSuggestionListItem>{t('Search_by_category')}</StatesSuggestionListItem>
<StatesSuggestionListItem>{t('Search_for_a_more_general_term')}</StatesSuggestionListItem>
<StatesSuggestionListItem>{t('Search_for_a_more_specific_term')}</StatesSuggestionListItem>
<StatesSuggestionListItem>{t('Check_if_the_spelling_is_correct')}</StatesSuggestionListItem>
</StatesSuggestionList>
</StatesSuggestion>
</States>
</Box>
);
};
export default NoMarketplaceOrInstalledAppMatchesEmptyState;

View File

@@ -0,0 +1,63 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen } from '@testing-library/react';
import PrivateEmptyState from './PrivateEmptyState';
jest.mock('@rocket.chat/ui-client', () => ({
...jest.requireActual('@rocket.chat/ui-client'),
useLicense: jest.fn(),
}));
describe('with private apps enabled', () => {
beforeEach(async () => {
const { useLicense } = await import('@rocket.chat/ui-client');
(useLicense as jest.Mock).mockReturnValue({
data: {
limits: {
privateApps: {
max: 5,
},
},
},
isPending: false,
});
});
const appRoot = mockAppRoot().withTranslations('en', 'core', {
Private_apps_upgrade_empty_state_title: 'Upgrade to unlock private apps',
No_private_apps_installed: 'No private apps installed',
});
it('should offer to upgrade to unlock private apps', () => {
render(<PrivateEmptyState />, { wrapper: appRoot.build() });
expect(screen.getByRole('heading', { name: 'No private apps installed' })).toBeInTheDocument();
});
});
describe('without private apps enabled', () => {
beforeEach(async () => {
const { useLicense } = await import('@rocket.chat/ui-client');
(useLicense as jest.Mock).mockReturnValue({
data: {
limits: {
privateApps: {
max: 0,
},
},
},
isPending: false,
});
});
const appRoot = mockAppRoot().withTranslations('en', 'core', {
Private_apps_upgrade_empty_state_title: 'Upgrade to unlock private apps',
No_private_apps_installed: 'No private apps installed',
});
it('should offer to upgrade to unlock private apps', () => {
render(<PrivateEmptyState />, { wrapper: appRoot.build() });
expect(screen.getByRole('heading', { name: 'Upgrade to unlock private apps' })).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,16 @@
import type { StoryObj, Meta } from '@storybook/react';
import PrivateEmptyState from './PrivateEmptyState';
export default {
component: PrivateEmptyState,
parameters: {
layout: 'fullscreen',
controls: { hideNoControlsWarning: true },
},
} satisfies Meta<typeof PrivateEmptyState>;
export const Default: StoryObj<typeof PrivateEmptyState> = {
render: () => <PrivateEmptyState />,
name: 'PageEmptyPrivateApps',
};

View File

@@ -0,0 +1,13 @@
import { Box } from '@rocket.chat/fuselage';
import PrivateEmptyStateDefault from './PrivateEmptyStateDefault';
import PrivateEmptyStateUpgrade from './PrivateEmptyStateUpgrade';
import { usePrivateAppsEnabled } from '../hooks/usePrivateAppsEnabled';
const PrivateEmptyState = () => {
const privateAppsEnabled = usePrivateAppsEnabled();
return <Box marginBlockStart='24px'>{privateAppsEnabled ? <PrivateEmptyStateDefault /> : <PrivateEmptyStateUpgrade />}</Box>;
};
export default PrivateEmptyState;

View File

@@ -0,0 +1,16 @@
import { States, StatesIcon, StatesTitle, StatesSubtitle } from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
const PrivateEmptyStateDefault = () => {
const { t } = useTranslation();
return (
<States>
<StatesIcon name='lock' />
<StatesTitle>{t('No_private_apps_installed')}</StatesTitle>
<StatesSubtitle>{t('Private_apps_upgrade_empty_state_description')}</StatesSubtitle>
</States>
);
};
export default PrivateEmptyStateDefault;

View File

@@ -0,0 +1,36 @@
import { States, StatesIcon, StatesTitle, StatesSubtitle, StatesActions, Button } from '@rocket.chat/fuselage';
import { usePermission } from '@rocket.chat/ui-contexts';
import { useTranslation } from 'react-i18next';
import { useExternalLink } from '../../../hooks/useExternalLink';
import UpgradeButton from '../../admin/subscription/components/UpgradeButton';
import { PRICING_LINK } from '../../admin/subscription/utils/links';
const PrivateEmptyStateUpgrade = () => {
const { t } = useTranslation();
const isAdmin = usePermission('manage-apps');
const handleOpenLink = useExternalLink();
return (
<States>
<StatesIcon name='lightning' />
<StatesTitle>{t('Private_apps_upgrade_empty_state_title')}</StatesTitle>
<StatesSubtitle>{t('Private_apps_upgrade_empty_state_description')}</StatesSubtitle>
<StatesActions>
{isAdmin && (
<UpgradeButton primary target='private-apps-header' action='upgrade'>
{t('Upgrade')}
</UpgradeButton>
)}
{!isAdmin && (
<Button onClick={() => handleOpenLink(PRICING_LINK)} role='link'>
{t('Learn_more')}
</Button>
)}
</StatesActions>
</States>
);
};
export default PrivateEmptyStateUpgrade;

View File

@@ -0,0 +1,16 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen } from '@testing-library/react';
import UnsupportedEmptyState from './UnsupportedEmptyState';
describe('with private apps enabled', () => {
const appRoot = mockAppRoot().withTranslations('en', 'core', {
Marketplace_unavailable: 'Marketplace unavailable',
});
it('should inform that the marketplace is unavailable due unsupported version', () => {
render(<UnsupportedEmptyState />, { wrapper: appRoot.build() });
expect(screen.getByRole('heading', { name: 'Marketplace unavailable' })).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,16 @@
import type { StoryObj, Meta } from '@storybook/react';
import UnsupportedEmptyState from './UnsupportedEmptyState';
export default {
component: UnsupportedEmptyState,
parameters: {
layout: 'fullscreen',
controls: { hideNoControlsWarning: true },
},
} as Meta<typeof UnsupportedEmptyState>;
export const Default: StoryObj<typeof UnsupportedEmptyState> = {
render: () => <UnsupportedEmptyState />,
name: 'UnsupportedEmptyState',
};

View File

@@ -0,0 +1,32 @@
import { Box, States, StatesIcon, StatesTitle, StatesSubtitle, StatesActions, Button } from '@rocket.chat/fuselage';
import { usePermission } from '@rocket.chat/ui-contexts';
import { useTranslation } from 'react-i18next';
import { links } from '../../../lib/links';
import UpdateRocketChatButton from '../components/UpdateRocketChatButton';
const UnsupportedEmptyState = () => {
const isAdmin = usePermission('manage-apps');
const { t } = useTranslation();
const title = isAdmin ? t('Update_to_access_marketplace') : t('Marketplace_unavailable');
const description = isAdmin ? t('Update_to_access_marketplace_description') : t('Marketplace_unavailable_description');
return (
<Box marginBlockStart={64}>
<States>
<StatesIcon name='warning' />
<StatesTitle>{title}</StatesTitle>
<StatesSubtitle>{description}</StatesSubtitle>
<StatesActions>
<Button secondary is='a' href={links.go.supportPrerequisites} external>
{t('Learn_more')}
</Button>
{isAdmin && <UpdateRocketChatButton />}
</StatesActions>
</States>
</Box>
);
};
export default UnsupportedEmptyState;

Some files were not shown because too many files have changed in this diff Show More