feat: New Collapse component for Apps logs (#36142)

Co-authored-by: Tasso Evangelista <2263066+tassoevan@users.noreply.github.com>
pull/36256/head^2
Martin Schoeler 12 months ago committed by GitHub
parent 93acfbe014
commit 0ba4d8bc18
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 6
      .changeset/nice-items-marry.md
  2. 18
      apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/AppLogs.tsx
  3. 21
      apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/AppLogsItem.spec.tsx
  4. 43
      apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/AppLogsItem.stories.tsx
  5. 66
      apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/AppLogsItem.tsx
  6. 16
      apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/AppLogsItemEntry.tsx
  7. 16
      apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/AppLogsItemField.tsx
  8. 43
      apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Components/CollapseButton.tsx
  9. 41
      apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Components/CollapsiblePanel.stories.tsx
  10. 12
      apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Components/CollapsiblePanel.tsx
  11. 26
      apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Components/CollapsibleRegion.tsx
  12. 131
      apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/__snapshots__/AppLogsItem.spec.tsx.snap
  13. 2
      packages/apps-engine/src/definition/accessors/ILogEntry.ts
  14. 1
      packages/core-typings/src/ILogs.ts
  15. 2
      packages/i18n/src/locales/en.i18n.json
  16. 2
      packages/i18n/src/locales/pt-BR.i18n.json

@ -0,0 +1,6 @@
---
"@rocket.chat/meteor": minor
"@rocket.chat/i18n": minor
---
Implements new component for Apps Logs View

@ -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 });
@ -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

@ -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();
});

@ -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} />;

@ -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>
</>
);
};

@ -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>

@ -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>
);
};

@ -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>
);
};

@ -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({});

@ -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>
);
};

@ -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>
);
};

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

@ -19,4 +19,6 @@ export interface ILogEntry {
timestamp: Date;
/** The items which were logged. */
args: Array<any>;
/** The method which was logged. */
method?: string;
}

@ -3,6 +3,7 @@ export interface ILogEntry {
caller: string;
severity: string;
timestamp: string;
method?: string;
}
export interface ILogItem {

@ -2285,6 +2285,7 @@
"From_Email": "From Email",
"From_email_warning": "<b>Warning</b>: The field <b>From</b> is subject to your mail server settings.",
"Full_Name": "Full Name",
"Full_log": "Full log",
"Full_Screen": "Full Screen",
"Fully_integrated_voip_receive_internal_external_calls_without_switching_between_apps_external_systems": "Fully-integrated Rocket.Chat VoIP allows your team to make and receive internal and external calls without switching between apps or external systems.",
"Gaming": "Gaming",
@ -5111,6 +5112,7 @@
"Top_5_agents_with_the_most_conversations": "Top 5 agents with the most conversations",
"Topic": "Topic",
"Total": "Total",
"Total_time": "Total time",
"Total_Discussions": "Discussions",
"Total_Threads": "Threads",
"Total_abandoned_chats": "Total Abandoned Chats",

@ -2282,6 +2282,7 @@
"From_Email": "E-mail de",
"From_email_warning": "<b>Aviso</b>: O campo <b>De</b> está sujeito às configurações do seu servidor de e-mails.",
"Full_Name": "Nome completo",
"Full_log": "Log completo",
"Full_Screen": "Tela cheia",
"Fully_integrated_voip_receive_internal_external_calls_without_switching_between_apps_external_systems": "O Rocket.Chat VoIP totalmente integrado permite que sua equipe faça e receba chamadas internas e externas sem alternar entre aplicativos ou sistemas externos.",
"Gaming": "Jogos",
@ -5097,6 +5098,7 @@
"Top_5_agents_with_the_most_conversations": "Top 5 agentes com mais conversas",
"Topic": "Tópico",
"Total": "Total",
"Total_time": "Tempo total",
"Total_Discussions": "Discussões",
"Total_Threads": "Tópicos",
"Total_abandoned_chats": "Total de conversas abandonadas",

Loading…
Cancel
Save