Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/nice-items-marry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@rocket.chat/meteor": minor
"@rocket.chat/i18n": minor
---

Implements new component for Apps Logs View
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
import { Accordion, Box, Pagination } from '@rocket.chat/fuselage';
import { Box, Pagination } from '@rocket.chat/fuselage';
import type { ReactElement } from 'react';
import { useTranslation } from 'react-i18next';

import AppLogsItem from './AppLogsItem';
import { CollapsiblePanel } from './Components/CollapsiblePanel';
import { CustomScrollbars } from '../../../../../components/CustomScrollbars';
import { usePagination } from '../../../../../components/GenericTable/hooks/usePagination';
import { useFormatDateAndTime } from '../../../../../hooks/useFormatDateAndTime';
import AccordionLoading from '../../../components/AccordionLoading';
import { useLogs } from '../../../hooks/useLogs';

const AppLogs = ({ id }: { id: string }): ReactElement => {
const { t } = useTranslation();
const formatDateAndTime = useFormatDateAndTime();
const { current, itemsPerPage, setItemsPerPage: onSetItemsPerPage, setCurrent: onSetCurrent, ...paginationProps } = usePagination();
const { data, isSuccess, isError, isLoading } = useLogs({ appId: id, current, itemsPerPage });

Expand All @@ -25,16 +24,9 @@ const AppLogs = ({ id }: { id: string }): ReactElement => {
)}
{isSuccess && (
<CustomScrollbars>
<Accordion width='100%' alignSelf='center'>
{data?.logs?.map((log) => (
<AppLogsItem
key={log._createdAt}
title={`${formatDateAndTime(log._createdAt)}: "${log.method}" (${log.totalTime}ms)`}
instanceId={log.instanceId}
entries={log.entries}
/>
))}
</Accordion>
<CollapsiblePanel width='100%' alignSelf='center'>
{data?.logs?.map((log, index) => <AppLogsItem regionId={log._id} key={`${index}-${log._createdAt}`} {...log} />)}
</CollapsiblePanel>
</CustomScrollbars>
)}
<Pagination
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/* eslint-disable @typescript-eslint/naming-convention */
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { composeStories } from '@storybook/react';
import { render } from '@testing-library/react';
import { axe } from 'jest-axe';

import * as stories from './AppLogsItem.stories';

const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);

test.each(testCases)(`renders AppLogsItem without crashing`, async (_storyname, Story) => {
const view = render(<Story />, { wrapper: mockAppRoot().build() });
expect(view.baseElement).toMatchSnapshot();
});

test.each(testCases)('AppLogsItem should have no a11y violations', async (_storyname, Story) => {
const { container } = render(<Story />, { wrapper: mockAppRoot().build() });

const results = await axe(container);
expect(results).toHaveNoViolations();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { Meta, StoryFn } from '@storybook/react';
import type { ComponentProps } from 'react';

import AppLogsItem from './AppLogsItem';
import { CollapsiblePanel } from './Components/CollapsiblePanel';

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

export const Simple: StoryFn<ComponentProps<typeof AppLogsItem>> = (args) => <AppLogsItem {...args} />;
Original file line number Diff line number Diff line change
@@ -1,29 +1,61 @@
import type { ILogEntry } from '@rocket.chat/core-typings';
import { Box, AccordionItem } from '@rocket.chat/fuselage';
import type { ILogItem } from '@rocket.chat/core-typings';
import { Box, Divider } from '@rocket.chat/fuselage';
import { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';

import AppLogsItemEntry from './AppLogsItemEntry';
import { AppsLogItemField } from './AppLogsItemField';
import { CollapseButton } from './Components/CollapseButton';
import { CollapsibleRegion } from './Components/CollapsibleRegion';
import { useFormatDateAndTime } from '../../../../../hooks/useFormatDateAndTime';

type AppLogsItemProps = {
entries: ILogEntry[];
instanceId: string;
title: string;
};
export type AppLogsItemProps = {
regionId: string;
} & ILogItem;

const AppLogsItem = ({ entries, instanceId, title, ...props }: AppLogsItemProps) => {
const AppLogsItem = ({ regionId, ...props }: AppLogsItemProps) => {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const title = (
<>
{props.entries.map(({ severity, timestamp, caller, args }, index) => {
const parsedArgs = args.map((arg) => (typeof arg === 'string' ? arg : JSON.stringify(arg))).join(' ');
return (
<Box
lineHeight={20}
mbe={4}
fontFamily='mono'
key={`${index}-${severity}-${timestamp}-${caller}`}
>{`${timestamp} ${severity} ${caller} ${parsedArgs}`}</Box>
);
})}
</>
);

const anchorRef = useRef<HTMLDivElement>(null);

const formatDateAndTime = useFormatDateAndTime();

return (
<AccordionItem title={title} {...props}>
{instanceId && (
<Box color='default'>
{t('Instance')}: {instanceId}
<>
<CollapseButton regionId={regionId} expanded={expanded} onClick={() => setExpanded(!expanded)}>
<Box ref={anchorRef}>{title}</Box>
</CollapseButton>

<CollapsibleRegion expanded={expanded} id={regionId} pbs={expanded ? 16 : '0px'} mis={36}>
{props.instanceId && <AppsLogItemField mbs={0} field={props.instanceId} label='Instance' />}
{props.totalTime !== undefined && <AppsLogItemField field={`${props.totalTime}ms`} label={t('Total_time')} />}
{props.startTime && <AppsLogItemField field={formatDateAndTime(Date.parse(props.startTime))} label={t('Time')} />}
{props.method && <AppsLogItemField field={props.method} label={t('Event')} />}
<Box mbs={16} display='flex' color='default' flexDirection='column'>
<Box fontWeight={700}>{t('Full_log')}</Box>
</Box>
)}
{entries.map(({ severity, timestamp, caller, args }, i) => (
<AppLogsItemEntry key={i} severity={severity} timestamp={timestamp} caller={caller} args={args} />
))}
</AccordionItem>
<AppLogsItemEntry fullLog={props} />
</CollapsibleRegion>
<Box is='dt'>
<Divider mb={0} />
</Box>
</>
);
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,29 +1,21 @@
import type { ILogItem } from '@rocket.chat/core-typings';
import { Box } from '@rocket.chat/fuselage';
import DOMPurify from 'dompurify';
import { useTranslation } from 'react-i18next';

import { useHighlightedCode } from '../../../../../hooks/useHighlightedCode';

type AppLogsItemEntryProps = {
severity: string;
timestamp: string;
caller: string;
args: unknown;
fullLog: ILogItem;
};

const AppLogsItemEntry = ({ severity, timestamp, caller, args }: AppLogsItemEntryProps) => {
const { t } = useTranslation();

const AppLogsItemEntry = ({ fullLog }: AppLogsItemEntryProps) => {
return (
<Box color='default'>
<Box>
{severity}: {timestamp} {t('Caller')}: {caller}
</Box>
<Box withRichContent width='full'>
<pre>
<code
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(useHighlightedCode('json', JSON.stringify(args, null, 2))),
__html: DOMPurify.sanitize(useHighlightedCode('json', JSON.stringify(fullLog, null, 2))),
}}
/>
</pre>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Box } from '@rocket.chat/fuselage';
import type { ComponentProps, ReactNode } from 'react';

type AppsLogItemFieldProps = {
field: ReactNode | string;
label: string;
} & ComponentProps<typeof Box>;

export const AppsLogItemField = ({ field, label, ...props }: AppsLogItemFieldProps) => {
return (
<Box mb={16} display='flex' color='default' flexDirection='column' {...props}>
<Box fontWeight={700}>{label}</Box>
{field}
</Box>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { css } from '@rocket.chat/css-in-js';
import { Box, Chevron, Palette } from '@rocket.chat/fuselage';
import type { CSSProperties, ReactNode } from 'react';

type CollapseButtonProps = {
children: ReactNode;
regionId: string;
expanded?: boolean;
onClick: () => void;
};

export const CollapseButton = ({ regionId, children, expanded, onClick }: CollapseButtonProps) => {
const clickable = css`
background: ${Palette.surface['surface-light']};

&:hover {
background: ${Palette.surface['surface-tint']};
}
`;
const style: CSSProperties = { whiteSpace: 'nowrap', textOverflow: 'ellipsis', overflow: 'hidden' };
return (
<Box is='dt' style={style}>
<Box
is='button'
role='button'
onClick={onClick}
className={clickable}
aria-expanded={expanded}
aria-controls={regionId}
display='flex'
flexDirection='row'
width='full'
focusable
color={Palette.text['font-default']}
>
<Chevron size={32} down={!expanded} up={expanded} style={{ alignSelf: 'flex-start' }} />
<Box pb='x4' pi='x4' fontWeight='700'>
{children}
</Box>
</Box>
</Box>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { StoryFn } from '@storybook/react';

import { CollapseButton } from './CollapseButton';
import { CollapsiblePanel } from './CollapsiblePanel';
import { CollapsibleRegion } from './CollapsibleRegion';

export default {
title: 'Components/CollapsiblePanel',
component: CollapsiblePanel,

args: {
expanded: true,
},

parameters: {
layout: 'centered',
},
};

const Template: StoryFn = (args) => {
return (
<CollapsiblePanel>
<CollapseButton
onClick={() => {
args.expanded = !args.expanded;
}}
expanded={args.expanded}
regionId='collapse-item'
>
Click Me
</CollapseButton>
<CollapsibleRegion expanded={args.expanded} id='collapse-item'>
<p>This is the content of the panel that can be activated.</p>
<button>Click Me</button>
<p>More content can go here.</p>
</CollapsibleRegion>
</CollapsiblePanel>
);
};

export const Default = Template.bind({});
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Box } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';

type CollapsiblePanelProps = ComponentProps<typeof Box>;

export const CollapsiblePanel = (props: CollapsiblePanelProps) => {
return (
<Box {...props} is='dl'>
{props.children}
</Box>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { css } from '@rocket.chat/css-in-js';
import { Box } from '@rocket.chat/fuselage';
import type { ComponentProps, ReactNode } from 'react';

type CollapsibleRegionProps = {
children: ReactNode;
expanded?: boolean;
} & ComponentProps<typeof Box>;

export const CollapsibleRegion = ({ children, expanded, ...props }: CollapsibleRegionProps) => {
return (
<Box
{...props}
maxHeight={expanded ? 'fit-content' : 0}
className={[
css`
transition: all 0.18s ease;
`,
]}
overflowY='hidden'
is='dd'
>
<Box role='region'>{children}</Box>
</Box>
);
};
Loading
Loading