feat: [FE] New App Logs Filters (#36288)

Co-authored-by: Tasso Evangelista <2263066+tassoevan@users.noreply.github.com>
This commit is contained in:
Martin Schoeler 2025-07-15 08:21:41 -03:00 committed by GitHub
parent a00729f114
commit 484d36dc26
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 1911 additions and 64 deletions

View File

@ -0,0 +1,5 @@
---
"@rocket.chat/meteor": minor
---
Adds a new filter to the Logs tab of the App Details page.

View File

@ -14,10 +14,13 @@ import { handleAPIError } from '../helpers/handleAPIError';
import { useAppInfo } from '../hooks/useAppInfo';
import AppDetails from './tabs/AppDetails';
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';
import { Page, PageFooter, PageHeader, PageScrollableContentWithShadow } from '../../../components/Page';
@ -35,7 +38,9 @@ const AppDetailsPage = ({ id }: AppDetailsPageProps): ReactElement => {
const tab = useRouteParameter('tab');
const context = useRouteParameter('context');
const contextualBar = useRouteParameter('contextualBar');
const appData = useAppInfo(id, context || '');
const compactMode = useCompactMode();
const handleReturn = useEffectEvent((): void => {
if (!context) {
@ -48,6 +53,20 @@ const AppDetailsPage = ({ id }: AppDetailsPageProps): ReactElement => {
});
});
const handleReturnToLogs = useEffectEvent((): 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);
@ -58,12 +77,14 @@ const AppDetailsPage = ({ id }: AppDetailsPageProps): ReactElement => {
);
}, [settings]);
const methods = useForm<AppDetailsPageFormData>({ values: reducedSettings });
const settingsFormMethods = useForm<AppDetailsPageFormData>({ values: reducedSettings });
const {
handleSubmit,
reset,
formState: { isDirty, isSubmitting },
} = methods;
} = settingsFormMethods;
const logsFilterFormMethods = useAppLogsFilterForm();
const saveAppSettings = useCallback(
async (data: AppDetailsPageFormData) => {
@ -81,56 +102,67 @@ const AppDetailsPage = ({ id }: AppDetailsPageProps): ReactElement => {
handleAPIError(e);
}
},
[dispatchToastMessage, id, name, settings, reset],
[id, settings, reset, dispatchToastMessage, t, name],
);
return (
<Page flexDirection='column' h='full'>
<PageHeader title={t('App_Info')} onClickBack={handleReturn} />
<PageScrollableContentWithShadow pi={24} pbs={24} pbe={0} h='full'>
<Box w='full' alignSelf='center' h='full' display='flex' flexDirection='column'>
{!appData && <AppDetailsPageLoading />}
{appData && (
<>
<AppDetailsPageHeader app={appData} />
<AppDetailsPageTabs
context={context || ''}
installed={installed}
isSecurityVisible={isSecurityVisible}
settings={settings}
tab={tab}
/>
{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}
<Page flexDirection='row'>
<Page flexDirection='column' h='full'>
<PageHeader title={t('App_Info')} onClickBack={handleReturn} />
<PageScrollableContentWithShadow pi={24} pbs={24} pbe={0} h='full'>
<Box w='full' alignSelf='center' h='full' display='flex' flexDirection='column'>
{!appData && <AppDetailsPageLoading />}
{appData && (
<>
<AppDetailsPageHeader app={appData} />
<AppDetailsPageTabs
context={context || ''}
installed={installed}
isSecurityVisible={isSecurityVisible}
settings={settings}
tab={tab}
/>
)}
{tab === 'releases' && <AppReleases id={id} />}
{Boolean(tab === 'settings' && settings && Object.values(settings).length) && (
<FormProvider {...methods}>
<AppSettings settings={settings || {}} />
</FormProvider>
)}
{tab === 'logs' && <AppLogs 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>
{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>
)}
</>
)}
</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 onClose={handleReturnToLogs} />
</FormProvider>
)}
</Page>
);
};

View File

@ -1,30 +1,64 @@
import { Box, Pagination } from '@rocket.chat/fuselage';
import type { ReactElement } from 'react';
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import { useMemo, type ReactElement } 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 { CustomScrollbars } from '../../../../../components/CustomScrollbars';
import GenericError from '../../../../../components/GenericError';
import GenericNoResults from '../../../../../components/GenericNoResults';
import { usePagination } from '../../../../../components/GenericTable/hooks/usePagination';
import AccordionLoading from '../../../components/AccordionLoading';
import { useLogs } from '../../../hooks/useLogs';
const AppLogs = ({ id }: { id: string }): ReactElement => {
const { t } = useTranslation();
const { watch } = useAppLogsFilterFormContext();
const { startTime, endTime, startDate, endDate, event, severity, instance } = watch();
const { current, itemsPerPage, setItemsPerPage: onSetItemsPerPage, setCurrent: onSetCurrent, ...paginationProps } = usePagination();
const { data, isSuccess, isError, isLoading } = useLogs({ appId: id, current, itemsPerPage });
const debouncedEvent = useDebouncedValue(event, 500);
const { data, isSuccess, isError, isFetching, error } = useLogs({
appId: id,
current,
itemsPerPage,
...(instance !== 'all' && { instanceId: instance }),
...(severity !== 'all' && { logLevel: severity }),
method: debouncedEvent,
...(startTime && startDate && { startDate: new Date(`${startDate}T${startTime}`).toISOString() }),
...(endTime && endDate && { endDate: new Date(`${endDate}T${endTime}`).toISOString() }),
});
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 (
<>
{isLoading && <AccordionLoading />}
{isError && (
<Box maxWidth='x600' alignSelf='center'>
{t('App_not_found')}
</Box>
)}
{isSuccess && (
<Box pb={16}>
<AppLogsFilter />
</Box>
{isFetching && <AccordionLoading />}
{isError && <GenericError title={parsedError} />}
{isSuccess && data?.logs?.length === 0 ? (
<GenericNoResults />
) : (
<CustomScrollbars>
<CollapsiblePanel width='100%' alignSelf='center'>
<CollapsiblePanel aria-busy={isFetching || event !== debouncedEvent} width='100%' alignSelf='center'>
{data?.logs?.map((log, index) => <AppLogsItem regionId={log._id} key={`${index}-${log._createdAt}`} {...log} />)}
</CollapsiblePanel>
</CustomScrollbars>

View File

@ -5,7 +5,7 @@ import AppLogsItem from './AppLogsItem';
import { CollapsiblePanel } from './Components/CollapsiblePanel';
export default {
title: 'Components/AppLogsItem',
title: 'Marketplace/AppDetailsPage/AppLogs/AppLogsItem',
component: AppLogsItem,
decorators: [(fn) => <CollapsiblePanel style={{ padding: 24 }}>{fn()}</CollapsiblePanel>],
args: {

View File

@ -5,7 +5,7 @@ import { CollapsiblePanel } from './CollapsiblePanel';
import { CollapsibleRegion } from './CollapsibleRegion';
export default {
title: 'Components/CollapsiblePanel',
title: 'Marketplace/AppDetailsPage/AppLogs/Components/CollapsiblePanel',
component: CollapsiblePanel,
args: {

View File

@ -0,0 +1,36 @@
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 { AppLogsFilter } from './AppLogsFilter';
import { useAppLogsFilterForm } from '../useAppLogsFilterForm';
export default {
title: 'Marketplace/AppDetailsPage/AppLogs/Filters/AppLogsFilter',
component: AppLogsFilter,
args: {},
decorators: [
mockAppRoot()
// @ts-expect-error The endpoint is to be merged in https://github.com/RocketChat/Rocket.Chat/pull/36245
.withEndpoint('GET', '/apps/logs/instanceIds', () => ({
success: true,
instanceIds: ['instance-1', 'instance-2', 'instance-3'],
}))
.buildStoryDecorator(),
(fn) => {
const methods = useAppLogsFilterForm();
return (
<FormProvider {...methods}>
<Box p={16}>{fn()}</Box>
</FormProvider>
);
},
],
parameters: {
layout: 'fullscreen',
},
} satisfies Meta<typeof AppLogsFilter>;
export const Default = () => <AppLogsFilter />;

View File

@ -0,0 +1,80 @@
import { Box, Button, Icon, Label, Palette, TextInput } from '@rocket.chat/fuselage';
import { useRouter } from '@rocket.chat/ui-contexts';
import { Controller } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { InstanceFilterSelect } from './InstanceFilterSelect';
import { SeverityFilterSelect } from './SeverityFilterSelect';
import { TimeFilterSelect } from './TimeFilterSelect';
import { useCompactMode } from '../../../useCompactMode';
import { useAppLogsFilterFormContext } from '../useAppLogsFilterForm';
export const AppLogsFilter = () => {
const { t } = useTranslation();
const { control } = useAppLogsFilterFormContext();
const router = useRouter();
const openContextualBar = () => {
router.navigate(
{
name: 'marketplace',
params: { ...router.getRouteParameters(), contextualBar: 'filter-logs' },
},
{ replace: true },
);
};
const compactMode = useCompactMode();
return (
<Box display='flex' flexDirection='row' width='full' flexWrap='wrap' alignContent='flex-end'>
<Box display='flex' flexDirection='column' mie={10} flexGrow={1}>
<Label htmlFor='eventFilter'>{t('Event')}</Label>
<Controller
control={control}
name='event'
render={({ field }) => (
<TextInput
addon={<Icon color={Palette.text['font-secondary-info']} name='magnifier' size={20} />}
id='eventFilter'
{...field}
/>
)}
/>
</Box>
{!compactMode && (
<Box display='flex' flexDirection='column' mie={10} flexGrow={1}>
<Label id='timeFilterLabel' htmlFor='timeFilter'>
{t('Time')}
</Label>
<TimeFilterSelect id='timeFilter' aria-labelledby='timeFilterLabel' />
</Box>
)}
{!compactMode && (
<Box display='flex' flexDirection='column' mie={10} flexGrow={1}>
<Label id='instanceFilterLabel' htmlFor='instanceFilter'>
{t('Instance')}
</Label>
<Controller
control={control}
name='instance'
render={({ field }) => <InstanceFilterSelect aria-labelledby='instanceFilterLabel' id='instanceFilter' {...field} />}
/>
</Box>
)}
{!compactMode && (
<Box display='flex' flexDirection='column' mie={10} flexGrow={1}>
<Label>{t('Severity')}</Label>
<Controller control={control} name='severity' render={({ field }) => <SeverityFilterSelect id='severityFilter' {...field} />} />
</Box>
)}
{compactMode && (
<Button alignSelf='flex-end' icon='customize' secondary mie={10} onClick={() => openContextualBar()}>
{t('Filters')}
</Button>
)}
</Box>
);
};

View File

@ -0,0 +1,44 @@
/* eslint-disable @typescript-eslint/naming-convention */
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() });
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('Should show filter button in contextual bar', async () => {
render(<Default />, {
wrapper: mockAppRoot().build(),
});
expect(screen.getByRole('button', { name: 'Filters' })).toBeVisible();
});
it('Should not show instance, time, and severity filters', async () => {
render(<Default />, {
wrapper: mockAppRoot().build(),
});
expect(screen.queryByRole('button', { name: 'Time' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Severity' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Instance' })).not.toBeInTheDocument();
});

View File

@ -0,0 +1,114 @@
/* eslint-disable @typescript-eslint/naming-convention */
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]);
});
});
});

View File

@ -0,0 +1,36 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { action } from '@storybook/addon-actions';
import type { Meta } from '@storybook/react';
import { FormProvider, useForm } from 'react-hook-form';
import { AppLogsFilterContextualBar } from './AppLogsFilterContextualBar';
import { Contextualbar } from '../../../../../../components/Contextualbar';
export default {
title: 'Marketplace/AppDetailsPage/AppLogs/Filters/AppLogsFilterContextualBar',
component: AppLogsFilterContextualBar,
args: {},
decorators: [
mockAppRoot()
// @ts-expect-error The endpoint is to be merged in https://github.com/RocketChat/Rocket.Chat/pull/36245
.withEndpoint('GET', '/apps/logs/instanceIds', () => ({
success: true,
instanceIds: ['instance-1', 'instance-2', 'instance-3'],
}))
.buildStoryDecorator(),
(fn) => {
const methods = useForm({});
return (
<FormProvider {...methods}>
<Contextualbar height='100vh'>{fn()}</Contextualbar>
</FormProvider>
);
},
],
parameters: {
layout: 'fullscreen',
},
} satisfies Meta<typeof AppLogsFilterContextualBar>;
export const Default = () => <AppLogsFilterContextualBar onClose={action('onClose')} />;

View File

@ -0,0 +1,73 @@
import { Box, Label } from '@rocket.chat/fuselage';
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 {
ContextualbarHeader,
ContextualbarIcon,
ContextualbarTitle,
ContextualbarClose,
ContextualbarScrollableContent,
ContextualbarDialog,
} from '../../../../../../components/Contextualbar';
import { useAppLogsFilterFormContext } from '../useAppLogsFilterForm';
type AppLogsFilterContextualBarProps = {
onClose: () => void;
};
export const AppLogsFilterContextualBar = ({ onClose = () => undefined }: AppLogsFilterContextualBarProps) => {
const { t } = useTranslation();
const { control } = 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' mie={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' mie={10} flexGrow={1}>
<Label htmlFor='timeFilter'>{t('Logs_from')}</Label>
<DateTimeFilter control={control} type='start' />
</Box>
<Box display='flex' flexDirection='column' mie={10} flexGrow={1}>
<Label htmlFor='timeFilter'>{t('Until')}</Label>
<DateTimeFilter control={control} type='end' />
</Box>
<Box display='flex' flexDirection='column' mie={10} flexGrow={1}>
<Label id='instanceFilterLabel' htmlFor='instanceFilter'>
{t('Instance')}
</Label>
<Controller
control={control}
name='instance'
render={({ field }) => <InstanceFilterSelect aria-labelledby='instanceFilterLabel' id='instanceFilter' {...field} />}
/>
</Box>
<Box display='flex' flexDirection='column' mie={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>
</ContextualbarDialog>
);
};

View File

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

View File

@ -0,0 +1,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';
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' ? 'Start Date' : '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' ? 'Start Time' : 'End Time'} type='time' {...field} />}
/>
</Margins>
</Box>
);
};
export default DateTimeFilter;

View File

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

View File

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

View File

@ -0,0 +1,62 @@
import { Box, Button, Label, Modal } 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;
};
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')}>
<Modal.Header>
<Modal.Title>{t('Custom_time_range')}</Modal.Title>
<Modal.Close onClick={onClose} />
</Modal.Header>
<Modal.Content>
<Box display='flex' flexDirection='column' mie={10} mbe={24} flexGrow={1}>
<Label htmlFor='timeFilter'>{t('Logs_from')}</Label>
<DateTimeFilter control={control} type='start' />
</Box>
<Box display='flex' flexDirection='column' mie={10} flexGrow={1}>
<Label htmlFor='timeFilter'>{t('Until')}</Label>
<DateTimeFilter control={control} type='end' />
</Box>
</Modal.Content>
<Modal.Footer>
<Modal.FooterControllers>
<Button onClick={onClose}>{t('Cancel')}</Button>
<Button primary disabled={!watch('startDate') || !watch('endDate')} onClick={handleSave}>
{t('Apply')}
</Button>
</Modal.FooterControllers>
</Modal.Footer>
</Modal>
);
};

View File

@ -0,0 +1,33 @@
import type { SelectOption } from '@rocket.chat/fuselage';
import { InputBoxSkeleton, Select } from '@rocket.chat/fuselage';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import type { ComponentProps } from 'react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
type InstanceFilterSelectProps = Omit<ComponentProps<typeof Select>, 'options'>;
export const InstanceFilterSelect = ({ ...props }: InstanceFilterSelectProps) => {
const { t } = useTranslation();
// @ts-expect-error The endpoint is to be merged in https://github.com/RocketChat/Rocket.Chat/pull/36245
const getOptions = useEndpoint('GET', '/apps/logs/instanceIds');
const { data, isPending } = useQuery({
queryKey: ['app-logs-filter-instances'],
// @ts-expect-error The endpoint is to be merged in https://github.com/RocketChat/Rocket.Chat/pull/36245
queryFn: async () => getOptions(),
});
const options: SelectOption[] = useMemo(() => {
// @ts-expect-error The endpoint is to be merged in https://github.com/RocketChat/Rocket.Chat/pull/36245
const mappedData: [string, string][] = data?.instanceIds?.map((id: string) => [id, id]) || [];
return [['all', t('All')], ...mappedData];
}, [data, t]);
if (isPending) {
return <InputBoxSkeleton aria-labelledby={props['aria-labelledby']} aria-busy />;
}
return <Select options={options} {...props} />;
};

View File

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

View File

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

View File

@ -0,0 +1,64 @@
// 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-1w4asan"
>
<div
class="rcx-box rcx-box--full rcx-css-1rfw4fq"
>
<label
class="rcx-box rcx-box--full rcx-label"
for="eventFilter"
>
Event
</label>
<label
class="rcx-box rcx-box--full rcx-label rcx-box rcx-box--full rcx-box--animated rcx-input-box__wrapper"
>
<input
class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--undecorated rcx-input-box--type-text rcx-input-box"
id="eventFilter"
name="event"
size="1"
type="text"
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-magnifier rcx-icon rcx-css-1bepdyv"
>
</i>
</span>
</label>
</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>
</div>
</div>
</div>
</body>
`;

View File

@ -0,0 +1,395 @@
// 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-ucmcg1"
>
<span
data-focus-scope-start="true"
hidden=""
/>
<div
aria-labelledby="contextualbarTitle"
class="rcx-box rcx-box--full rcx-vertical-bar rcx-css-1n0hsd8"
role="dialog"
tabindex="-1"
>
<div
class="rcx-box rcx-box--full rcx-css-ftwpdg"
>
<div
class="rcx-box rcx-box--full rcx-css-1sl6k6j"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-customize rcx-icon rcx-css-x7bl3q rcx-css-g86psg"
>
</i>
<div
class="rcx-box rcx-box--full rcx-css-x7bl3q rcx-css-1to6ka7"
id="contextualbarTitle"
>
Filters
</div>
<button
aria-label="Close"
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-x7bl3q rcx-css-1yzvz7u"
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-pln26h 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-iag4sp"
>
<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>
<button
aria-expanded="false"
aria-haspopup="listbox"
aria-labelledby="react-aria-:r8: timeFilterLabel"
class="rcx-box rcx-box--full rcx-select rcx-css-1vw6rc6"
id="timeFilter"
type="button"
>
<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>
<span
class="rcx-box rcx-box--full rcx-css-uyvtjh"
id="react-aria-:r8:"
/>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-chevron-down rcx-icon rcx-css-6vi44e"
>
</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>
<button
aria-expanded="false"
aria-haspopup="listbox"
aria-label="Severity"
aria-labelledby="react-aria-:rg: react-aria-:rb: severityFilterLabel"
class="rcx-box rcx-box--full rcx-select rcx-css-1vw6rc6"
id="severityFilter"
type="button"
>
<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>
<span
class="rcx-box rcx-box--full rcx-css-uyvtjh"
id="react-aria-:rg:"
/>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-chevron-down rcx-icon rcx-css-6vi44e"
>
</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>
<span
data-focus-scope-end="true"
hidden=""
/>
</div>
</div>
</body>
`;

View File

@ -0,0 +1,223 @@
// 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-1w4asan"
>
<div
class="rcx-box rcx-box--full rcx-css-1rfw4fq"
>
<label
class="rcx-box rcx-box--full rcx-label"
for="eventFilter"
>
Event
</label>
<label
class="rcx-box rcx-box--full rcx-label rcx-box rcx-box--full rcx-box--animated rcx-input-box__wrapper"
>
<input
class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--undecorated rcx-input-box--type-text rcx-input-box"
id="eventFilter"
name="event"
size="1"
type="text"
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-magnifier rcx-icon rcx-css-1bepdyv"
>
</i>
</span>
</label>
</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>
<button
aria-expanded="false"
aria-haspopup="listbox"
aria-labelledby="react-aria-:r7: timeFilterLabel"
class="rcx-box rcx-box--full rcx-select rcx-css-1vw6rc6"
id="timeFilter"
type="button"
>
<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>
<span
class="rcx-box rcx-box--full rcx-css-4w7o7u"
id="react-aria-:r7:"
>
All_time
</span>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-chevron-down rcx-icon rcx-css-6vi44e"
>
</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>
<button
aria-expanded="false"
aria-haspopup="listbox"
aria-label="Severity"
aria-labelledby="react-aria-:rf: react-aria-:ra:"
class="rcx-box rcx-box--full rcx-select rcx-css-1vw6rc6"
id="severityFilter"
type="button"
>
<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>
<span
class="rcx-box rcx-box--full rcx-css-4w7o7u"
id="react-aria-:rf:"
>
All
</span>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-chevron-down rcx-icon rcx-css-6vi44e"
>
</i>
</button>
</div>
</div>
</div>
</div>
</body>
`;

View File

@ -0,0 +1,202 @@
// 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-1e2ego0"
>
<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"
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-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"
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>
</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 rcx-button-group__item"
type="button"
>
<span
class="rcx-button--content"
>
Cancel
</span>
</button>
<button
class="rcx-box rcx-box--full rcx-button--primary rcx-button rcx-button-group__item"
disabled=""
type="button"
>
<span
class="rcx-button--content"
>
Apply
</span>
</button>
</div>
</div>
</div>
</dialog>
</div>
</div>
</body>
`;

View File

@ -0,0 +1,17 @@
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' } });
export const useAppLogsFilterFormContext = () => useFormContext<AppLogsFilterFormData>();

View File

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

View File

@ -8,17 +8,32 @@ export const useLogs = ({
appId,
current,
itemsPerPage,
logLevel,
method,
startDate,
endDate,
instanceId,
}: {
appId: string;
current: number;
itemsPerPage: number;
logLevel?: '0' | '1' | '2';
method?: string;
startDate?: string;
endDate?: string;
instanceId?: string;
}): UseQueryResult<OperationResult<'GET', '/apps/:id/logs'>> => {
const query = useMemo(
() => ({
count: itemsPerPage,
offset: current,
...(logLevel && { logLevel }),
...(method && { method }),
...(startDate && { startDate }),
...(endDate && { endDate }),
...(instanceId && { instanceId }),
}),
[itemsPerPage, current],
[itemsPerPage, current, logLevel, method, startDate, endDate, instanceId],
);
const logs = useEndpoint('GET', '/apps/:id/logs', { id: appId });

View File

@ -9,8 +9,8 @@ declare module '@rocket.chat/ui-contexts' {
pathname: '/marketplace';
};
'marketplace': {
pattern: '/marketplace/:context?/:page?/:id?/:version?/:tab?';
pathname: `/marketplace${`/${string}` | ''}${`/${string}` | ''}${`/${string}` | ''}${`/${string}` | ''}${`/${string}` | ''}`;
pattern: '/marketplace/:context?/:page?/:id?/:version?/:tab?/:contextualBar?';
pathname: `/marketplace${`/${string}` | ''}${`/${string}` | ''}${`/${string}` | ''}${`/${string}` | ''}${`/${string}` | ''}${`/${string}` | ''}`;
};
}
}
@ -21,7 +21,7 @@ export const registerMarketplaceRoute = createRouteGroup(
lazy(() => import('./MarketplaceRouter')),
);
registerMarketplaceRoute('/:context?/:page?/:id?/:version?/:tab?', {
registerMarketplaceRoute('/:context?/:page?/:id?/:version?/:tab?/:contextualBar?', {
name: 'marketplace',
component: lazy(() => import('./AppsRoute')),
});

View File

@ -428,6 +428,7 @@
"All_roles": "All roles",
"All_rooms": "All rooms",
"All_status": "All status",
"All_time": "All time",
"All_users": "All users",
"All_users_in_the_channel_can_write_new_messages": "All users in the channel can write new messages",
"All_visible": "All visible",
@ -1517,6 +1518,7 @@
"Custom_Sound_Info": "Custom Sound Info",
"Custom_Sound_Saved_Successfully": "Custom sound saved successfully",
"Custom_Status": "Custom Status",
"Custom_time_range": "Custom time range",
"Custom_Translations": "Custom Translations",
"Custom_Translations_Description": "Should be a valid JSON where keys are languages containing a dictionary of key and translations. Example: `{\"en\": {\"Channels\": \"Rooms\"},\"pt\": {\"Channels\": \"Salas\"}}`",
"Custom_User_Status": "Custom User Status",
@ -2860,6 +2862,10 @@
"Language_Version": "English Version",
"Language_setting_warning": "<strong>Server language setting does not affect user's client</strong><br/>Each user has their own preference for language, that will be kept if this setting is changed.",
"Larger_amounts_of_active_connections": "For larger amounts of active connections you can consider our <1>multiple instance solutions</1>.",
"Last_5_minutes": "Last 5 minutes",
"Last_15_minutes": "Last 15 minutes",
"Last_30_minutes": "Last 30 minutes",
"Last_1_hour": "Last 1 hour",
"Last_15_days": "Last 15 Days",
"Last_30_days": "Last 30 Days",
"Last_6_months": "Last 6 months",
@ -3117,6 +3123,7 @@
"Logout_Device": "Log out device",
"Logout_Others": "Logout From Other Logged In Locations",
"Logs": "Logs",
"Logs_from": "Logs from",
"Logs_Description": "Configure how server logs are received.",
"Long_press_to_do_x": "Long press to do {{action}}",
"Longest_chat_duration": "Longest Chat Duration",