snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
This commit is contained in:
@@ -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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './AppDetailsPage';
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './AppDetails';
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './AppInstances';
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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>> = {};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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('');
|
||||
});
|
||||
@@ -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')} />;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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')} />
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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} />;
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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>> = {};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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} />;
|
||||
};
|
||||
@@ -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} />;
|
||||
};
|
||||
@@ -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} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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 -> 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>
|
||||
`;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './AppLogs';
|
||||
@@ -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>();
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './AppReleases';
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './AppSettings';
|
||||
@@ -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' }));
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './AppStatus';
|
||||
@@ -0,0 +1,6 @@
|
||||
import { useBreakpoints } from '@rocket.chat/fuselage-hooks';
|
||||
|
||||
export const useCompactMode = () => {
|
||||
const breakpoint = useBreakpoints();
|
||||
return !breakpoint.includes('lg');
|
||||
};
|
||||
Reference in New Issue
Block a user