* applied eslint * add locales and lint the project * removed error boundary * updated locales * fix min files * fix locales
272 lines
8 KiB
TypeScript
272 lines
8 KiB
TypeScript
import { TYPES } from 'Types/session/event';
|
|
import cn from 'classnames';
|
|
import copy from 'copy-to-clipboard';
|
|
import {
|
|
Angry,
|
|
MessageCircleQuestion,
|
|
MousePointerClick,
|
|
Navigation,
|
|
Pointer,
|
|
TextCursorInput,
|
|
} from 'lucide-react';
|
|
import React, { useRef, useState } from 'react';
|
|
|
|
import { prorata, numberWithCommas } from 'App/utils';
|
|
import withOverlay from 'Components/hocs/withOverlay';
|
|
import { Icon, TextEllipsis, Tooltip } from 'UI';
|
|
|
|
import LoadInfo from './LoadInfo';
|
|
import cls from './event.module.css';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
type Props = {
|
|
event: any;
|
|
selected?: boolean;
|
|
isCurrent?: boolean;
|
|
onClick?: () => void;
|
|
showSelection?: boolean;
|
|
toggleLoadInfo?: () => void;
|
|
isRed?: boolean;
|
|
presentInSearch?: boolean;
|
|
whiteBg?: boolean;
|
|
};
|
|
|
|
const isFrustrationEvent = (evt: any): boolean => {
|
|
if (
|
|
evt.type === 'mouse_thrashing' ||
|
|
evt.type === TYPES.CLICKRAGE ||
|
|
evt.type === TYPES.TAPRAGE
|
|
) {
|
|
return true;
|
|
}
|
|
if (evt.type === TYPES.CLICK || evt.type === TYPES.INPUT) {
|
|
return evt.hesitation > 1000;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const Event: React.FC<Props> = ({
|
|
event,
|
|
selected = false,
|
|
isCurrent = false,
|
|
onClick,
|
|
showSelection = false,
|
|
toggleLoadInfo,
|
|
isRed = false,
|
|
presentInSearch = false,
|
|
whiteBg,
|
|
}) => {
|
|
const { t } = useTranslation();
|
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
const isLocation = event.type === TYPES.LOCATION;
|
|
|
|
const onContextMenu = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
e.preventDefault();
|
|
setMenuOpen(true);
|
|
};
|
|
|
|
const onMouseLeave = () => setMenuOpen(false);
|
|
|
|
const copyHandler = (e: React.MouseEvent<HTMLButtonElement>) => {
|
|
e.stopPropagation();
|
|
const path = event.getIn(['target', 'path']) || event.url || '';
|
|
copy(path);
|
|
setMenuOpen(false);
|
|
};
|
|
|
|
const renderBody = () => {
|
|
let title = event.type;
|
|
let body;
|
|
let icon = null;
|
|
let iconName = null;
|
|
const isFrustration = isFrustrationEvent(event);
|
|
const tooltip = { disabled: true, text: '' };
|
|
|
|
switch (event.type) {
|
|
case TYPES.LOCATION:
|
|
title = t('Visited');
|
|
body = event.url;
|
|
icon = <Navigation size={16} strokeWidth={1} />;
|
|
break;
|
|
case TYPES.SWIPE:
|
|
title = t('Swipe');
|
|
body = event.direction;
|
|
iconName = `chevron-${event.direction}`;
|
|
break;
|
|
case TYPES.TOUCH:
|
|
title = t('Tapped');
|
|
body = event.label;
|
|
iconName = 'event/click';
|
|
break;
|
|
case TYPES.CLICK:
|
|
title = t('Clicked');
|
|
body = event.label;
|
|
icon = isFrustration ? (
|
|
<MessageCircleQuestion size={16} strokeWidth={1} />
|
|
) : (
|
|
<Pointer size={16} strokeWidth={1} />
|
|
);
|
|
isFrustration
|
|
? Object.assign(tooltip, {
|
|
disabled: false,
|
|
text: `${t('User hesitated')} ${Math.round(
|
|
event.hesitation / 1000,
|
|
)}${t('s to perform this event')}`,
|
|
})
|
|
: null;
|
|
break;
|
|
case TYPES.INPUT:
|
|
title = 'Input';
|
|
body = event.value;
|
|
icon = isFrustration ? (
|
|
<MessageCircleQuestion size={16} strokeWidth={1} />
|
|
) : (
|
|
<TextCursorInput size={16} strokeWidth={1} />
|
|
);
|
|
isFrustration
|
|
? Object.assign(tooltip, {
|
|
disabled: false,
|
|
text: `${t('User hesitated')} ${Math.round(
|
|
event.hesitation / 1000,
|
|
)}${t('s to enter a value in this input field.')}`,
|
|
})
|
|
: null;
|
|
break;
|
|
case TYPES.CLICKRAGE:
|
|
case TYPES.TAPRAGE:
|
|
title = event.count ? `${event.count} ${t('Clicks')}` : t('Click Rage');
|
|
body = event.label;
|
|
icon = <Angry size={16} strokeWidth={1} />;
|
|
break;
|
|
case TYPES.IOS_VIEW:
|
|
title = t('View');
|
|
body = event.name;
|
|
iconName = 'event/ios_view';
|
|
break;
|
|
case 'mouse_thrashing':
|
|
title = t('Mouse Thrashing');
|
|
icon = <MousePointerClick size={16} strokeWidth={1} />;
|
|
break;
|
|
}
|
|
|
|
return (
|
|
<Tooltip
|
|
title={tooltip.text}
|
|
disabled={tooltip.disabled}
|
|
placement="left"
|
|
anchorClassName="w-full"
|
|
containerClassName="w-full"
|
|
>
|
|
<div className={cn(cls.main, 'flex flex-col w-full')}>
|
|
<div
|
|
className={cn('flex items-start w-full', { 'px-4': isLocation })}
|
|
>
|
|
<div style={{ minWidth: '16px' }}>
|
|
{event.type && iconName ? (
|
|
<Icon name={iconName} size="16" color="gray-dark" />
|
|
) : (
|
|
icon
|
|
)}
|
|
</div>
|
|
<div className="ml-3 w-full">
|
|
<div className="flex w-full items-start">
|
|
<div
|
|
className="flex flex-col justify-center items-start w-full"
|
|
style={{ minWidth: '0' }}
|
|
>
|
|
<span className={cn(cls.title, 'font-medium')}>{title}</span>
|
|
{body && !isLocation && (
|
|
<TextEllipsis
|
|
maxWidth="80%"
|
|
className="w-full text-sm color-gray-medium"
|
|
text={body}
|
|
/>
|
|
)}
|
|
</div>
|
|
{isLocation && event.speedIndex != null && (
|
|
<div className="color-gray-medium flex font-medium items-center leading-none justify-end">
|
|
<div className="font-size-10 pr-2">${t('Speed Index')}</div>
|
|
<div>{numberWithCommas(event.speedIndex || 0)}</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{event.target && event.target.label && (
|
|
<div className={cls.badge}>{event.target.label}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{isLocation && (
|
|
<div className="pt-1 px-4">
|
|
<TextEllipsis
|
|
className="text-sm ms-8 font-normal color-gray-medium"
|
|
text={body}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Tooltip>
|
|
);
|
|
};
|
|
|
|
const isFrustration = isFrustrationEvent(event);
|
|
|
|
const mobileTypes = [TYPES.TOUCH, TYPES.SWIPE, TYPES.TAPRAGE];
|
|
return (
|
|
<div
|
|
ref={wrapperRef}
|
|
onMouseLeave={onMouseLeave}
|
|
data-openreplay-label="Event"
|
|
data-type={event.type}
|
|
className={cn(cls.event, {
|
|
[cls.menuClosed]: !menuOpen,
|
|
[cls.highlighted]: showSelection ? selected : isCurrent,
|
|
[cls.selected]: selected,
|
|
[cls.showSelection]: showSelection,
|
|
[cls.red]: isRed,
|
|
[cls.clickType]:
|
|
event.type === TYPES.CLICK || event.type === TYPES.SWIPE,
|
|
[cls.inputType]: event.type === TYPES.INPUT,
|
|
[cls.frustration]: isFrustration,
|
|
[cls.highlight]: presentInSearch,
|
|
[cls.lastInGroup]: whiteBg,
|
|
'pl-4 pr-6 py-2': event.type !== TYPES.LOCATION,
|
|
'border-0 border-l-0 ml-0': mobileTypes.includes(event.type),
|
|
})}
|
|
onClick={onClick}
|
|
onContextMenu={onContextMenu}
|
|
>
|
|
{menuOpen && (
|
|
<button onClick={copyHandler} className={cls.contextMenu}>
|
|
{event.target ? t('Copy CSS') : t('Copy URL')}
|
|
</button>
|
|
)}
|
|
<div className={cn(cls.topBlock, cls.firstLine, 'w-full')}>
|
|
{renderBody()}
|
|
</div>
|
|
{isLocation &&
|
|
(event.fcpTime ||
|
|
event.visuallyComplete ||
|
|
event.timeToInteractive ||
|
|
event.webvitals) ? (
|
|
<LoadInfo
|
|
onClick={toggleLoadInfo}
|
|
event={event}
|
|
webvitals={event.webvitals}
|
|
prorata={prorata({
|
|
parts: 100,
|
|
elements: {
|
|
a: event.fcpTime,
|
|
b: event.visuallyComplete,
|
|
c: event.timeToInteractive,
|
|
},
|
|
startDivisorFn: (elements) => elements / 1.2,
|
|
divisorFn: (elements, parts) => elements / (2 * parts + 1),
|
|
})}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default withOverlay()(Event);
|