From 484d36dc26877b0b0ffa9242e99ef63cdff4cd77 Mon Sep 17 00:00:00 2001 From: Martin Schoeler Date: Tue, 15 Jul 2025 08:21:41 -0300 Subject: [PATCH] feat: [FE] New App Logs Filters (#36288) Co-authored-by: Tasso Evangelista <2263066+tassoevan@users.noreply.github.com> --- .changeset/curvy-dancers-try.md | 5 + .../AppDetailsPage/AppDetailsPage.tsx | 128 +++--- .../AppDetailsPage/tabs/AppLogs/AppLogs.tsx | 54 ++- .../tabs/AppLogs/AppLogsItem.stories.tsx | 2 +- .../Components/CollapsiblePanel.stories.tsx | 2 +- .../AppLogs/Filters/AppLogsFilter.stories.tsx | 36 ++ .../tabs/AppLogs/Filters/AppLogsFilter.tsx | 80 ++++ .../Filters/AppLogsFilterCompact.spec.tsx | 44 ++ .../AppLogsFilterContextualBar.spec.tsx | 114 +++++ .../AppLogsFilterContextualBar.stories.tsx | 36 ++ .../Filters/AppLogsFilterContextualBar.tsx | 73 ++++ .../Filters/AppLogsFilterExpanded.spec.tsx | 70 ++++ .../tabs/AppLogs/Filters/DateTimeFilter.tsx | 44 ++ .../AppLogs/Filters/DateTimeModal.spec.tsx | 50 +++ .../AppLogs/Filters/DateTimeModal.stories.tsx | 32 ++ .../tabs/AppLogs/Filters/DateTimeModal.tsx | 62 +++ .../AppLogs/Filters/InstanceFilterSelect.tsx | 33 ++ .../AppLogs/Filters/SeverityFilterSelect.tsx | 16 + .../tabs/AppLogs/Filters/TimeFilterSelect.tsx | 157 +++++++ .../AppLogsFilterCompact.spec.tsx.snap | 64 +++ .../AppLogsFilterContextualBar.spec.tsx.snap | 395 ++++++++++++++++++ .../AppLogsFilterExpanded.spec.tsx.snap | 223 ++++++++++ .../__snapshots__/DateTimeModal.spec.tsx.snap | 202 +++++++++ .../tabs/AppLogs/useAppLogsFilterForm.ts | 17 + .../AppDetailsPage/useCompactMode.ts | 6 + .../client/views/marketplace/hooks/useLogs.ts | 17 +- .../client/views/marketplace/routes.tsx | 6 +- packages/i18n/src/locales/en.i18n.json | 7 + 28 files changed, 1911 insertions(+), 64 deletions(-) create mode 100644 .changeset/curvy-dancers-try.md create mode 100644 apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilter.stories.tsx create mode 100644 apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilter.tsx create mode 100644 apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilterCompact.spec.tsx create mode 100644 apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilterContextualBar.spec.tsx create mode 100644 apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilterContextualBar.stories.tsx create mode 100644 apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilterContextualBar.tsx create mode 100644 apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilterExpanded.spec.tsx create mode 100644 apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/DateTimeFilter.tsx create mode 100644 apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/DateTimeModal.spec.tsx create mode 100644 apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/DateTimeModal.stories.tsx create mode 100644 apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/DateTimeModal.tsx create mode 100644 apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/InstanceFilterSelect.tsx create mode 100644 apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/SeverityFilterSelect.tsx create mode 100644 apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/TimeFilterSelect.tsx create mode 100644 apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/__snapshots__/AppLogsFilterCompact.spec.tsx.snap create mode 100644 apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/__snapshots__/AppLogsFilterContextualBar.spec.tsx.snap create mode 100644 apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/__snapshots__/AppLogsFilterExpanded.spec.tsx.snap create mode 100644 apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/__snapshots__/DateTimeModal.spec.tsx.snap create mode 100644 apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/useAppLogsFilterForm.ts create mode 100644 apps/meteor/client/views/marketplace/AppDetailsPage/useCompactMode.ts diff --git a/.changeset/curvy-dancers-try.md b/.changeset/curvy-dancers-try.md new file mode 100644 index 00000000000..b7cf3c2edc9 --- /dev/null +++ b/.changeset/curvy-dancers-try.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": minor +--- + +Adds a new filter to the Logs tab of the App Details page. diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/AppDetailsPage.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/AppDetailsPage.tsx index 2b384e27082..e5d139332f3 100644 --- a/apps/meteor/client/views/marketplace/AppDetailsPage/AppDetailsPage.tsx +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/AppDetailsPage.tsx @@ -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({ values: reducedSettings }); + const settingsFormMethods = useForm({ 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 ( - - - - - {!appData && } - {appData && ( - <> - - - {Boolean(!tab || tab === 'details') && } - {tab === 'requests' && } - {tab === 'security' && isSecurityVisible && ( - + + + + + {!appData && } + {appData && ( + <> + + - )} - {tab === 'releases' && } - {Boolean(tab === 'settings' && settings && Object.values(settings).length) && ( - - - - )} - {tab === 'logs' && } - - )} - - - - - - {installed && isAdminUser && ( - - )} - - + {Boolean(!tab || tab === 'details') && } + {tab === 'requests' && } + {tab === 'security' && isSecurityVisible && ( + + )} + {tab === 'releases' && } + {Boolean(tab === 'settings' && settings && Object.values(settings).length) && ( + + + + )} + {(tab === 'logs' || tab === 'logs-filter') && ( + + + + )} + + )} + + + + + + {installed && isAdminUser && ( + + )} + + + + {compactMode && contextualBar === 'filter-logs' && ( + + + + )} ); }; diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/AppLogs.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/AppLogs.tsx index 1f336f043a0..baee5a9cc3d 100644 --- a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/AppLogs.tsx +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/AppLogs.tsx @@ -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 && } - {isError && ( - - {t('App_not_found')} - - )} - {isSuccess && ( + + + + {isFetching && } + {isError && } + {isSuccess && data?.logs?.length === 0 ? ( + + ) : ( - + {data?.logs?.map((log, index) => )} diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/AppLogsItem.stories.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/AppLogsItem.stories.tsx index f459fdc8812..fd10ec442d5 100644 --- a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/AppLogsItem.stories.tsx +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/AppLogsItem.stories.tsx @@ -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) => {fn()}], args: { diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Components/CollapsiblePanel.stories.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Components/CollapsiblePanel.stories.tsx index 00ffa82b088..b59fc6c823f 100644 --- a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Components/CollapsiblePanel.stories.tsx +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Components/CollapsiblePanel.stories.tsx @@ -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: { diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilter.stories.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilter.stories.tsx new file mode 100644 index 00000000000..4bfe63bb6b3 --- /dev/null +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilter.stories.tsx @@ -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 ( + + {fn()} + + ); + }, + ], + parameters: { + layout: 'fullscreen', + }, +} satisfies Meta; + +export const Default = () => ; diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilter.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilter.tsx new file mode 100644 index 00000000000..2f61a71071a --- /dev/null +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilter.tsx @@ -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 ( + + + + ( + } + id='eventFilter' + {...field} + /> + )} + /> + + {!compactMode && ( + + + + + )} + {!compactMode && ( + + + } + /> + + )} + {!compactMode && ( + + + } /> + + )} + {compactMode && ( + + )} + + ); +}; diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilterCompact.spec.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilterCompact.spec.tsx new file mode 100644 index 00000000000..6bf622b6b19 --- /dev/null +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilterCompact.spec.tsx @@ -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(, { + wrapper: mockAppRoot().build(), + }); + expect(view.baseElement).toMatchSnapshot(); +}); + +test.each(testCases)('AppLogsItem should have no a11y violations', async (_storyname, Story) => { + const { container } = render(, { wrapper: mockAppRoot().build() }); + + const results = await axe(container); + + expect(results).toHaveNoViolations(); +}); + +it('Should show filter button in contextual bar', async () => { + render(, { + wrapper: mockAppRoot().build(), + }); + + expect(screen.getByRole('button', { name: 'Filters' })).toBeVisible(); +}); + +it('Should not show instance, time, and severity filters', async () => { + render(, { + 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(); +}); diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilterContextualBar.spec.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilterContextualBar.spec.tsx new file mode 100644 index 00000000000..5d1c1f9f3f9 --- /dev/null +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilterContextualBar.spec.tsx @@ -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(, { + wrapper: mockAppRoot().build(), + }); + expect(view.baseElement).toMatchSnapshot(); +}); + +test.each(testCases)('AppLogsItem should have no a11y violations', async (_storyname, Story) => { + const { container } = render(, { 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(, { + 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(, { + 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(, { 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(, { 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]); + }); + }); +}); diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilterContextualBar.stories.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilterContextualBar.stories.tsx new file mode 100644 index 00000000000..23fa65fdbaf --- /dev/null +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilterContextualBar.stories.tsx @@ -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 ( + + {fn()} + + ); + }, + ], + parameters: { + layout: 'fullscreen', + }, +} satisfies Meta; + +export const Default = () => ; diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilterContextualBar.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilterContextualBar.tsx new file mode 100644 index 00000000000..f9802e3545b --- /dev/null +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilterContextualBar.tsx @@ -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 ( + + + + {t('Filters')} + + + + + + + + + + + + + + + + + + } + /> + + + + } + /> + + + + ); +}; diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilterExpanded.spec.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilterExpanded.spec.tsx new file mode 100644 index 00000000000..842a4e9f00c --- /dev/null +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilterExpanded.spec.tsx @@ -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(, { + wrapper: mockAppRoot().build(), + }); + expect(view.baseElement).toMatchSnapshot(); +}); + +test.each(testCases)('AppLogsItem should have no a11y violations', async (_storyname, Story) => { + const { container } = render(, { 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(, { + 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(, { + 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(); +}); diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/DateTimeFilter.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/DateTimeFilter.tsx new file mode 100644 index 00000000000..8836dd70dea --- /dev/null +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/DateTimeFilter.tsx @@ -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 ( + + + ( + + )} + /> + + + + } + /> + + + ); +}; + +export default DateTimeFilter; diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/DateTimeModal.spec.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/DateTimeModal.spec.tsx new file mode 100644 index 00000000000..e925f0f3191 --- /dev/null +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/DateTimeModal.spec.tsx @@ -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(, { + wrapper: mockAppRoot().build(), + }); + expect(view.baseElement).toMatchSnapshot(); +}); + +test.each(testCases)('AppLogsItem should have no a11y violations', async (_storyname, Story) => { + const { container } = render(, { 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(, { 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(); +}); diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/DateTimeModal.stories.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/DateTimeModal.stories.tsx new file mode 100644 index 00000000000..ecd50ffffc7 --- /dev/null +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/DateTimeModal.stories.tsx @@ -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 ( + + {fn()} + + ); + }, + ], + parameters: { + layout: 'fullscreen', + }, +} satisfies Meta; + +export const Default: StoryFn> = () => ( + +); diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/DateTimeModal.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/DateTimeModal.tsx new file mode 100644 index 00000000000..d135d1de363 --- /dev/null +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/DateTimeModal.tsx @@ -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({ defaultValues }); + + const handleSave = (): void => { + onSave({ + startDate: getValues('startDate'), + startTime: getValues('startTime'), + endDate: getValues('endDate'), + endTime: getValues('endTime'), + }); + }; + + return ( + + + {t('Custom_time_range')} + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/InstanceFilterSelect.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/InstanceFilterSelect.tsx new file mode 100644 index 00000000000..ea36d903458 --- /dev/null +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/InstanceFilterSelect.tsx @@ -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, '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 ; + } + + return ; +}; diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/TimeFilterSelect.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/TimeFilterSelect.tsx new file mode 100644 index 00000000000..839ed5200e3 --- /dev/null +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/TimeFilterSelect.tsx @@ -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, 'onChange' | 'options'>; + +export const TimeFilterSelect = ({ compactView = false, ...props }: TimeFilterSelectProps) => { + const { setValue, control, getValues } = useAppLogsFilterFormContext(); + const { t } = useTranslation(); + + const setModal = useSetModal(); + + const [customTimeRangeLabel, setCustomTimeRangeOptionLabel] = useState(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(); + 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 ( + + + + + + + + + + + +`; diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/__snapshots__/AppLogsFilterContextualBar.spec.tsx.snap b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/__snapshots__/AppLogsFilterContextualBar.spec.tsx.snap new file mode 100644 index 00000000000..30795a832ac --- /dev/null +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/__snapshots__/AppLogsFilterContextualBar.spec.tsx.snap @@ -0,0 +1,395 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`renders AppLogsItem without crashing 1`] = ` + +
+
+