[NEW] Fallback Error component for Engagement Dashboard widgets (#26441)
parent
22f209d584
commit
3039f2e82e
@ -0,0 +1,27 @@ |
||||
import { Box } from '@rocket.chat/fuselage'; |
||||
import React, { ReactElement, ReactNode } from 'react'; |
||||
|
||||
import Card from '../../../../../client/components/Card'; |
||||
import EngagementDashboardCardErrorBoundary from './EngagementDashboardCardErrorBoundary'; |
||||
|
||||
type EngagementDashboardCardProps = { |
||||
children?: ReactNode; |
||||
title?: string; |
||||
}; |
||||
|
||||
const EngagementDashboardCard = ({ children, title = undefined }: EngagementDashboardCardProps): ReactElement => ( |
||||
<Box mb='x16'> |
||||
<Card variant='light'> |
||||
{title && <Card.Title>{title}</Card.Title>} |
||||
<Card.Body> |
||||
<Card.Col> |
||||
<EngagementDashboardCardErrorBoundary> |
||||
<Box>{children}</Box> |
||||
</EngagementDashboardCardErrorBoundary> |
||||
</Card.Col> |
||||
</Card.Body> |
||||
</Card> |
||||
</Box> |
||||
); |
||||
|
||||
export default EngagementDashboardCard; |
||||
@ -0,0 +1,45 @@ |
||||
import { States, StatesAction, StatesActions, StatesIcon, StatesSubtitle, StatesTitle } from '@rocket.chat/fuselage'; |
||||
import { useTranslation } from '@rocket.chat/ui-contexts'; |
||||
import { QueryErrorResetBoundary } from '@tanstack/react-query'; |
||||
import React, { ReactElement, ReactNode, useState } from 'react'; |
||||
import { ErrorBoundary } from 'react-error-boundary'; |
||||
|
||||
export type EngagementDashboardCardErrorBoundaryProps = { |
||||
children?: ReactNode; |
||||
}; |
||||
|
||||
const EngagementDashboardCardErrorBoundary = ({ children }: EngagementDashboardCardErrorBoundaryProps): ReactElement => { |
||||
const t = useTranslation(); |
||||
|
||||
const [error, setError] = useState<Error>(); |
||||
const isError = (error: unknown): error is Error => error instanceof Error; |
||||
|
||||
const errorHandler = (error: Error, info: { componentStack: string }): void => { |
||||
setError(error); |
||||
console.error('Uncaught Error:', error, info); |
||||
}; |
||||
|
||||
return ( |
||||
<QueryErrorResetBoundary> |
||||
{({ reset }): ReactElement => ( |
||||
<ErrorBoundary |
||||
children={children} |
||||
onError={errorHandler} |
||||
onReset={reset} |
||||
fallbackRender={({ resetErrorBoundary }): ReactElement => ( |
||||
<States> |
||||
<StatesIcon name='circle-exclamation' /> |
||||
<StatesTitle>{t('Something_Went_Wrong')}</StatesTitle> |
||||
<StatesSubtitle>{isError(error) && error?.message}</StatesSubtitle> |
||||
<StatesActions data-qa='EngagementDashboardCardErrorBoundary'> |
||||
<StatesAction onClick={(): void => resetErrorBoundary()}>{t('Retry')}</StatesAction> |
||||
</StatesActions> |
||||
</States> |
||||
)} |
||||
/> |
||||
)} |
||||
</QueryErrorResetBoundary> |
||||
); |
||||
}; |
||||
|
||||
export default EngagementDashboardCardErrorBoundary; |
||||
@ -0,0 +1,14 @@ |
||||
import { Box, Flex, InputBox } from '@rocket.chat/fuselage'; |
||||
import React, { ReactElement, ReactNode } from 'react'; |
||||
|
||||
type EngagementDashboardCardFilterProps = { |
||||
children?: ReactNode; |
||||
}; |
||||
|
||||
const EngagementDashboardCardFilter = ({ children = <InputBox.Skeleton /> }: EngagementDashboardCardFilterProps): ReactElement => ( |
||||
<Box display='flex' justifyContent='flex-end' alignItems='center' wrap='no-wrap'> |
||||
{children && <Flex.Item grow={0}>{children}</Flex.Item>} |
||||
</Box> |
||||
); |
||||
|
||||
export default EngagementDashboardCardFilter; |
||||
@ -1,26 +0,0 @@ |
||||
import { Box, Flex, InputBox, Margins } from '@rocket.chat/fuselage'; |
||||
import React, { ReactElement, ReactNode } from 'react'; |
||||
|
||||
type SectionProps = { |
||||
children?: ReactNode; |
||||
title?: ReactNode; |
||||
filter?: ReactNode; |
||||
}; |
||||
|
||||
const Section = ({ children, title = undefined, filter = <InputBox.Skeleton /> }: SectionProps): ReactElement => ( |
||||
<Box> |
||||
<Margins block='x24'> |
||||
<Box display='flex' justifyContent='flex-end' alignItems='center' wrap='no-wrap'> |
||||
{title && ( |
||||
<Box flexGrow={1} fontScale='p2' color='default'> |
||||
{title} |
||||
</Box> |
||||
)} |
||||
{filter && <Flex.Item grow={0}>{filter}</Flex.Item>} |
||||
</Box> |
||||
{children} |
||||
</Margins> |
||||
</Box> |
||||
); |
||||
|
||||
export default Section; |
||||
@ -0,0 +1,134 @@ |
||||
import { Box, Icon, Margins, Pagination, Skeleton, Table, Tile } from '@rocket.chat/fuselage'; |
||||
import { useTranslation } from '@rocket.chat/ui-contexts'; |
||||
import moment from 'moment'; |
||||
import React, { ReactElement, useMemo, useState } from 'react'; |
||||
|
||||
import Growth from '../../../../../../client/components/dataView/Growth'; |
||||
import EngagementDashboardCardFilter from '../EngagementDashboardCardFilter'; |
||||
import DownloadDataButton from '../dataView/DownloadDataButton'; |
||||
import PeriodSelector from '../dataView/PeriodSelector'; |
||||
import { usePeriodSelectorState } from '../dataView/usePeriodSelectorState'; |
||||
import { useChannelsList } from './useChannelsList'; |
||||
|
||||
const ChannelsOverview = (): ReactElement => { |
||||
const [period, periodSelectorProps] = usePeriodSelectorState('last 7 days', 'last 30 days', 'last 90 days'); |
||||
|
||||
const t = useTranslation(); |
||||
|
||||
const [current, setCurrent] = useState(0); |
||||
const [itemsPerPage, setItemsPerPage] = useState<25 | 50 | 100>(25); |
||||
|
||||
const { data } = useChannelsList({ |
||||
period, |
||||
offset: current, |
||||
count: itemsPerPage, |
||||
}); |
||||
|
||||
const channels = useMemo(() => { |
||||
if (!data) { |
||||
return; |
||||
} |
||||
|
||||
return data?.channels?.map(({ room: { t, name, usernames, ts, _updatedAt }, messages, diffFromLastWeek }) => ({ |
||||
t, |
||||
name: name || usernames?.join(' × '), |
||||
createdAt: ts, |
||||
updatedAt: _updatedAt, |
||||
messagesCount: messages, |
||||
messagesVariation: diffFromLastWeek, |
||||
})); |
||||
}, [data]); |
||||
|
||||
return ( |
||||
<> |
||||
<EngagementDashboardCardFilter> |
||||
<PeriodSelector {...periodSelectorProps} /> |
||||
<DownloadDataButton |
||||
attachmentName={`Channels_start_${data?.start}_end_${data?.end}`} |
||||
headers={['Room type', 'Name', 'Messages', 'Last Update Date', 'Creation Date']} |
||||
dataAvailable={!!data} |
||||
dataExtractor={(): unknown[][] | undefined => |
||||
data?.channels?.map(({ room: { t, name, usernames, ts, _updatedAt }, messages }) => [ |
||||
t, |
||||
name || usernames?.join(' × '), |
||||
messages, |
||||
_updatedAt, |
||||
ts, |
||||
]) |
||||
} |
||||
/> |
||||
</EngagementDashboardCardFilter> |
||||
<Box> |
||||
{channels && !channels.length && ( |
||||
<Tile fontScale='p1' color='info' style={{ textAlign: 'center' }}> |
||||
{t('No_data_found')} |
||||
</Tile> |
||||
)} |
||||
{(!channels || channels.length) && ( |
||||
<Table> |
||||
<Table.Head> |
||||
<Table.Row> |
||||
<Table.Cell>{'#'}</Table.Cell> |
||||
<Table.Cell>{t('Channel')}</Table.Cell> |
||||
<Table.Cell>{t('Created')}</Table.Cell> |
||||
<Table.Cell>{t('Last_active')}</Table.Cell> |
||||
<Table.Cell>{t('Messages_sent')}</Table.Cell> |
||||
</Table.Row> |
||||
</Table.Head> |
||||
<Table.Body> |
||||
{channels?.map(({ t, name, createdAt, updatedAt, messagesCount, messagesVariation }, i) => ( |
||||
<Table.Row key={i}> |
||||
<Table.Cell>{i + 1}.</Table.Cell> |
||||
<Table.Cell> |
||||
<Margins inlineEnd='x4'> |
||||
{(t === 'd' && <Icon name='at' />) || (t === 'p' && <Icon name='lock' />) || (t === 'c' && <Icon name='hashtag' />)} |
||||
</Margins> |
||||
{name} |
||||
</Table.Cell> |
||||
<Table.Cell>{moment(createdAt).format('L')}</Table.Cell> |
||||
<Table.Cell>{moment(updatedAt).format('L')}</Table.Cell> |
||||
<Table.Cell> |
||||
{messagesCount} <Growth>{messagesVariation}</Growth> |
||||
</Table.Cell> |
||||
</Table.Row> |
||||
))} |
||||
{!channels && |
||||
Array.from({ length: 5 }, (_, i) => ( |
||||
<Table.Row key={i}> |
||||
<Table.Cell> |
||||
<Skeleton width='100%' /> |
||||
</Table.Cell> |
||||
<Table.Cell> |
||||
<Skeleton width='100%' /> |
||||
</Table.Cell> |
||||
<Table.Cell> |
||||
<Skeleton width='100%' /> |
||||
</Table.Cell> |
||||
<Table.Cell> |
||||
<Skeleton width='100%' /> |
||||
</Table.Cell> |
||||
<Table.Cell> |
||||
<Skeleton width='100%' /> |
||||
</Table.Cell> |
||||
</Table.Row> |
||||
))} |
||||
</Table.Body> |
||||
</Table> |
||||
)} |
||||
<Pagination |
||||
current={current} |
||||
itemsPerPage={itemsPerPage} |
||||
itemsPerPageLabel={(): string => t('Items_per_page:')} |
||||
showingResultsLabel={({ count, current, itemsPerPage }): string => |
||||
t('Showing_results_of', current + 1, Math.min(current + itemsPerPage, count), count) |
||||
} |
||||
count={data?.total || 0} |
||||
onSetItemsPerPage={setItemsPerPage} |
||||
onSetCurrent={setCurrent} |
||||
/> |
||||
</Box> |
||||
</> |
||||
); |
||||
}; |
||||
|
||||
export default ChannelsOverview; |
||||
@ -1,137 +1,12 @@ |
||||
import { Box, Icon, Margins, Pagination, Skeleton, Table, Tile } from '@rocket.chat/fuselage'; |
||||
import { useTranslation } from '@rocket.chat/ui-contexts'; |
||||
import moment from 'moment'; |
||||
import React, { ReactElement, useMemo, useState } from 'react'; |
||||
import React, { ReactElement } from 'react'; |
||||
|
||||
import Growth from '../../../../../../client/components/dataView/Growth'; |
||||
import Section from '../Section'; |
||||
import DownloadDataButton from '../dataView/DownloadDataButton'; |
||||
import PeriodSelector from '../dataView/PeriodSelector'; |
||||
import { usePeriodSelectorState } from '../dataView/usePeriodSelectorState'; |
||||
import { useChannelsList } from './useChannelsList'; |
||||
import EngagementDashboardCard from '../EngagementDashboardCard'; |
||||
import ChannelsOverview from './ChannelsOverview'; |
||||
|
||||
const ChannelsTab = (): ReactElement => { |
||||
const [period, periodSelectorProps] = usePeriodSelectorState('last 7 days', 'last 30 days', 'last 90 days'); |
||||
|
||||
const t = useTranslation(); |
||||
|
||||
const [current, setCurrent] = useState(0); |
||||
const [itemsPerPage, setItemsPerPage] = useState<25 | 50 | 100>(25); |
||||
|
||||
const { data } = useChannelsList({ |
||||
period, |
||||
offset: current, |
||||
count: itemsPerPage, |
||||
}); |
||||
|
||||
const channels = useMemo(() => { |
||||
if (!data) { |
||||
return; |
||||
} |
||||
|
||||
return data?.channels?.map(({ room: { t, name, usernames, ts, _updatedAt }, messages, diffFromLastWeek }) => ({ |
||||
t, |
||||
name: name || usernames?.join(' × '), |
||||
createdAt: ts, |
||||
updatedAt: _updatedAt, |
||||
messagesCount: messages, |
||||
messagesVariation: diffFromLastWeek, |
||||
})); |
||||
}, [data]); |
||||
|
||||
return ( |
||||
<Section |
||||
filter={ |
||||
<> |
||||
<PeriodSelector {...periodSelectorProps} /> |
||||
<DownloadDataButton |
||||
attachmentName={`Channels_start_${data?.start}_end_${data?.end}`} |
||||
headers={['Room type', 'Name', 'Messages', 'Last Update Date', 'Creation Date']} |
||||
dataAvailable={!!data} |
||||
dataExtractor={(): unknown[][] | undefined => |
||||
data?.channels?.map(({ room: { t, name, usernames, ts, _updatedAt }, messages }) => [ |
||||
t, |
||||
name || usernames?.join(' × '), |
||||
messages, |
||||
_updatedAt, |
||||
ts, |
||||
]) |
||||
} |
||||
/> |
||||
</> |
||||
} |
||||
> |
||||
<Box> |
||||
{channels && !channels.length && ( |
||||
<Tile fontScale='p1' color='info' style={{ textAlign: 'center' }}> |
||||
{t('No_data_found')} |
||||
</Tile> |
||||
)} |
||||
{(!channels || channels.length) && ( |
||||
<Table> |
||||
<Table.Head> |
||||
<Table.Row> |
||||
<Table.Cell>{'#'}</Table.Cell> |
||||
<Table.Cell>{t('Channel')}</Table.Cell> |
||||
<Table.Cell>{t('Created')}</Table.Cell> |
||||
<Table.Cell>{t('Last_active')}</Table.Cell> |
||||
<Table.Cell>{t('Messages_sent')}</Table.Cell> |
||||
</Table.Row> |
||||
</Table.Head> |
||||
<Table.Body> |
||||
{channels?.map(({ t, name, createdAt, updatedAt, messagesCount, messagesVariation }, i) => ( |
||||
<Table.Row key={i}> |
||||
<Table.Cell>{i + 1}.</Table.Cell> |
||||
<Table.Cell> |
||||
<Margins inlineEnd='x4'> |
||||
{(t === 'd' && <Icon name='at' />) || (t === 'p' && <Icon name='lock' />) || (t === 'c' && <Icon name='hashtag' />)} |
||||
</Margins> |
||||
{name} |
||||
</Table.Cell> |
||||
<Table.Cell>{moment(createdAt).format('L')}</Table.Cell> |
||||
<Table.Cell>{moment(updatedAt).format('L')}</Table.Cell> |
||||
<Table.Cell> |
||||
{messagesCount} <Growth>{messagesVariation}</Growth> |
||||
</Table.Cell> |
||||
</Table.Row> |
||||
))} |
||||
{!channels && |
||||
Array.from({ length: 5 }, (_, i) => ( |
||||
<Table.Row key={i}> |
||||
<Table.Cell> |
||||
<Skeleton width='100%' /> |
||||
</Table.Cell> |
||||
<Table.Cell> |
||||
<Skeleton width='100%' /> |
||||
</Table.Cell> |
||||
<Table.Cell> |
||||
<Skeleton width='100%' /> |
||||
</Table.Cell> |
||||
<Table.Cell> |
||||
<Skeleton width='100%' /> |
||||
</Table.Cell> |
||||
<Table.Cell> |
||||
<Skeleton width='100%' /> |
||||
</Table.Cell> |
||||
</Table.Row> |
||||
))} |
||||
</Table.Body> |
||||
</Table> |
||||
)} |
||||
<Pagination |
||||
current={current} |
||||
itemsPerPage={itemsPerPage} |
||||
itemsPerPageLabel={(): string => t('Items_per_page:')} |
||||
showingResultsLabel={({ count, current, itemsPerPage }): string => |
||||
t('Showing_results_of', current + 1, Math.min(current + itemsPerPage, count), count) |
||||
} |
||||
count={data?.total || 0} |
||||
onSetItemsPerPage={setItemsPerPage} |
||||
onSetCurrent={setCurrent} |
||||
/> |
||||
</Box> |
||||
</Section> |
||||
); |
||||
}; |
||||
const ChannelsTab = (): ReactElement => ( |
||||
<EngagementDashboardCard> |
||||
<ChannelsOverview /> |
||||
</EngagementDashboardCard> |
||||
); |
||||
|
||||
export default ChannelsTab; |
||||
|
||||
@ -1,15 +1,23 @@ |
||||
import { Divider } from '@rocket.chat/fuselage'; |
||||
import { useTranslation } from '@rocket.chat/ui-contexts'; |
||||
import React, { ReactElement } from 'react'; |
||||
|
||||
import EngagementDashboardCard from '../EngagementDashboardCard'; |
||||
import MessagesPerChannelSection from './MessagesPerChannelSection'; |
||||
import MessagesSentSection from './MessagesSentSection'; |
||||
|
||||
const MessagesTab = (): ReactElement => ( |
||||
<> |
||||
<MessagesSentSection /> |
||||
<Divider /> |
||||
<MessagesPerChannelSection /> |
||||
</> |
||||
); |
||||
const MessagesTab = (): ReactElement => { |
||||
const t = useTranslation(); |
||||
|
||||
return ( |
||||
<> |
||||
<EngagementDashboardCard title={t('Messages_sent')}> |
||||
<MessagesSentSection /> |
||||
</EngagementDashboardCard> |
||||
<EngagementDashboardCard title={t('Where_are_the_messages_being_sent?')}> |
||||
<MessagesPerChannelSection /> |
||||
</EngagementDashboardCard> |
||||
</> |
||||
); |
||||
}; |
||||
|
||||
export default MessagesTab; |
||||
|
||||
@ -1,25 +0,0 @@ |
||||
import { Component, ReactNode, ErrorInfo } from 'react'; |
||||
|
||||
export class ErrorBoundary extends Component< |
||||
{ fallback?: ReactNode; onError?: (error: Error, errorInfo: ErrorInfo) => void }, |
||||
{ hasError: boolean } |
||||
> { |
||||
state = { hasError: false }; |
||||
|
||||
static getDerivedStateFromError(): { hasError: boolean } { |
||||
return { hasError: true }; |
||||
} |
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo): void { |
||||
this.props.onError?.(error, errorInfo); |
||||
console.error('Uncaught Error:', error, errorInfo); |
||||
} |
||||
|
||||
render(): ReactNode { |
||||
if (this.state.hasError) { |
||||
return this.props.fallback || null; |
||||
} |
||||
|
||||
return this.props.children; |
||||
} |
||||
} |
||||
@ -1,4 +1,3 @@ |
||||
export * from './ExternalLink'; |
||||
export * from './ErrorBoundary'; |
||||
export * from './DotLeader'; |
||||
export * from './TooltipComponent'; |
||||
|
||||
Loading…
Reference in new issue