feat(ui): path analysis - path highight

This commit is contained in:
Shekar Siri 2023-10-20 11:22:41 +02:00
parent ee05d89f13
commit 36d7eabbd9
9 changed files with 175 additions and 123 deletions

View file

@ -21,30 +21,37 @@ function CardIssues() {
const isMounted = useIsMounted();
const { showModal } = useModal();
const fetchIssues = (filter: any) => {
const fetchIssues = async (filter: any) => {
if (!isMounted()) return;
setLoading(true);
const mapSeries = (item: any) => {
const filters = item.filter.filters
.map((f: any) => f.toJson());
return {
...item,
filter: {
...item.filter,
filters
}
};
};
const newFilter = {
...filter,
series: filter.series.map((item: any) => {
return {
...item,
filter: {
...item.filter,
filters: item.filter.filters.filter((filter: any, index: any) => {
const stage = widget.data.funnel.stages[index];
return stage && stage.isActive;
}).map((f: any) => f.toJson())
}
};
})
series: filter.series.map(mapSeries)
};
widget.fetchIssues(newFilter).then((res: any) => {
try {
const res = await widget.fetchIssues(newFilter);
setData(res);
}).finally(() => {
} catch (error) {
console.error('Error fetching issues:', error);
} finally {
setLoading(false);
});
}
};
const handleClick = (issue: any) => {
@ -77,7 +84,7 @@ function CardIssues() {
</div>
<Loader loading={loading}>
<NoContent show={data.issues.length == 0} title="No data!">
<NoContent show={data.issues.length == 0} title='No data!'>
{data.issues.map((item: any, index: any) => (
<div onClick={() => handleClick(item)} key={index}>
<CardIssueItem issue={item} />

View file

@ -37,7 +37,7 @@ function MetricTypeDropdown(props: Props) {
}
setTimeout(() => onChange(type.value), 0);
}
setTimeout(() => onChange(USER_PATH), 0);
// setTimeout(() => onChange(USER_PATH), 0);
}, []);
const onChange = (type: string) => {

View file

@ -1,5 +1,5 @@
import React from 'react';
import { Layer } from 'recharts';
import { Layer, Rectangle } from 'recharts';
function CustomLink(props: any) {
const [fill, setFill] = React.useState('url(#linkGradient)');
@ -11,12 +11,17 @@ function CustomLink(props: any) {
const onClick = () => {
if (props.onClick) {
props.onClick(props);
props.onClick(props.payload);
}
};
return (
<Layer key={`CustomLink${index}`} onClick={onClick}>
<Layer
key={`CustomLink${index}`}
onClick={onClick}
onMouseEnter={props.onMouseEnter}
onMouseLeave={props.onMouseLeave}
>
<path
d={`
M${sourceX},${sourceY + linkWidth / 2}
@ -30,7 +35,8 @@ function CustomLink(props: any) {
Z
`}
fill={isActive ? 'rgba(57, 78, 255, 0.5)' : fill}
strokeWidth='0'
strokeWidth='1'
strokeOpacity={props.strokeOpacity}
onMouseEnter={() => {
setFill('rgba(57, 78, 255, 0.5)');
}}

View file

@ -9,7 +9,6 @@ interface Props {
function NodeButton(props: Props) {
const { payload } = props;
const [show, setShow] = React.useState(false);
console.log('payload', payload, props)
const toggleMenu = (e: React.MouseEvent) => {
setShow(!show);

View file

@ -1,10 +1,11 @@
import React, { useEffect } from 'react';
import React, { useState } from 'react';
import { Sankey, ResponsiveContainer } from 'recharts';
import CustomLink from './CustomLink';
import CustomNode from './CustomNode';
import { NoContent } from 'UI';
interface Node {
id: number; // Assuming you missed this from your interface
name: string;
eventType: string;
avgTimeFromPrevious: number | null;
@ -24,60 +25,69 @@ interface Data {
interface Props {
data: Data;
nodePadding?: number;
nodeWidth?: number;
onChartClick?: (data: any) => void;
height?: number;
}
const SankeyChart: React.FC<Props> = ({
data,
height = 240
}: Props) => {
const [highlightedLinks, setHighlightedLinks] = useState<number[]>([]);
function SankeyChart(props: Props) {
const { data, nodeWidth = 10, height = 240 } = props;
const [activeLink, setActiveLink] = React.useState<any>(null);
const handleLinkMouseEnter = (linkData: any) => {
const { payload } = linkData;
const fullPathArray: Node[] = [];
useEffect(() => {
if (!activeLink) return;
const { source, target } = activeLink.payload;
const filters = [];
if (source) {
filters.push({
operator: 'is',
type: source.eventType,
value: [source.name],
isEvent: true
});
console.log('linkData', linkData.index);
// Add the source node of the current link
fullPathArray.push(payload.source);
fullPathArray.push(payload.target);
if (payload.source.sourceLinks.length > 0) {
let prevLink = data.links[payload.source.sourceLinks[0]];
// fullPathArray.unshift(prevLink);
fullPathArray.unshift(data.nodes[prevLink.source]);
if (prevLink.source) {
let prevLinkPrev = data.links[prevLink.source];
// fullPathArray.unshift(prevLinkPrev);
fullPathArray.unshift(data.nodes[prevLinkPrev.source]);
}
}
if (target) {
filters.push({
operator: 'is',
type: target.eventType,
value: [target.name],
isEvent: true
});
}
setHighlightedLinks(fullPathArray);
console.log('fullPathArray', fullPathArray.map(node => node));
};
props.onChartClick?.(filters);
}, [activeLink]);
return (
<NoContent
style={{ paddingTop: '80px' }}
show={!(data && data.nodes && data.nodes.length && data.links)}
title={'No data for the selected time period.'}>
show={!data.nodes.length || !data.links.length}
title={'No data for the selected time period.'}
>
<ResponsiveContainer height={height} width='100%'>
<Sankey
data={data}
iterations={128}
node={<CustomNode />}
nodeWidth={nodeWidth}
sort={false}
margin={{
left: 0,
right: 200,
top: 0,
bottom: 10
sort={true}
onClick={(data) => {
}}
link={<CustomLink onClick={(props: any) => setActiveLink(props)} activeLink={activeLink} />}
link={({ source, target, ...linkProps }, index) => (
<CustomLink
{...linkProps}
strokeOpacity={highlightedLinks.includes(index) ? 1 : 0.2}
onMouseEnter={() => handleLinkMouseEnter(linkProps)}
onMouseLeave={() => setHighlightedLinks([])}
/>
)}
margin={{ right: 200 }}
>
<defs>
<linearGradient id={'linkGradient'}>
@ -89,6 +99,6 @@ function SankeyChart(props: Props) {
</ResponsiveContainer>
</NoContent>
);
}
};
export default SankeyChart;

View file

@ -28,7 +28,7 @@ export const options = [
{ key: 'not equal', label: 'not equal', value: 'not equal' },
{ key: 'onSelector', label: 'on selector', value: 'onSelector' },
{ key: 'onText', label: 'on text', value: 'onText' },
{ key: 'onComponent', label: 'on component', value: 'onComponent' },
{ key: 'onComponent', label: 'on component', value: 'onComponent' }
];
const filterKeys = ['is', 'isNot'];
@ -38,47 +38,47 @@ const stringFilterKeysPerformance = ['is', 'inAnyPage', 'isNot', 'contains', 'st
const targetFilterKeys = ['on', 'notOn', 'onAny', 'contains', 'startsWith', 'endsWith', 'notContains'];
const signUpStatusFilterKeys = ['isSignedUp', 'notSignedUp'];
const rangeFilterKeys = ['before', 'after', 'on', 'inRange', 'notInRange', 'withInLast', 'notWithInLast'];
const pageUrlFilter = ['contains', 'startsWith', 'endsWith']
const pageUrlFilter = ['contains', 'startsWith', 'endsWith'];
const getOperatorsByKeys = (keys) => {
return options.filter(option => keys.includes(option.key));
};
export const baseOperators = options.filter(({key}) => filterKeys.includes(key));
export const stringOperatorsLimited = options.filter(({key}) => stringFilterKeysLimited.includes(key));
export const stringOperators = options.filter(({key}) => stringFilterKeys.includes(key));
export const stringOperatorsPerformance = options.filter(({key}) => stringFilterKeysPerformance.includes(key));
export const targetOperators = options.filter(({key}) => targetFilterKeys.includes(key));
export const baseOperators = options.filter(({ key }) => filterKeys.includes(key));
export const stringOperatorsLimited = options.filter(({ key }) => stringFilterKeysLimited.includes(key));
export const stringOperators = options.filter(({ key }) => stringFilterKeys.includes(key));
export const stringOperatorsPerformance = options.filter(({ key }) => stringFilterKeysPerformance.includes(key));
export const targetOperators = options.filter(({ key }) => targetFilterKeys.includes(key));
export const booleanOperators = [
{ key: 'true', label: 'true', value: 'true' },
{ key: 'false', label: 'false', value: 'false' },
]
export const pageUrlOperators = options.filter(({key}) => pageUrlFilter.includes(key))
{ key: 'false', label: 'false', value: 'false' }
];
export const pageUrlOperators = options.filter(({ key }) => pageUrlFilter.includes(key));
export const customOperators = [
{ key: '=', label: '=', value: '=' },
{ key: '<', label: '<', value: '<' },
{ key: '>', label: '>', value: '>' },
{ key: '<=', label: '<=', value: '<=' },
{ key: '>=', label: '>=', value: '>=' },
]
{ key: '>=', label: '>=', value: '>=' }
];
export const metricTypes = [
{ label: 'Timeseries', value: 'timeseries' },
{ label: 'Table', value: 'table' },
{ label: 'Funnel', value: 'funnel' },
{ label: 'Funnel', value: 'funnel' }
// { label: 'Errors', value: 'errors' },
// { label: 'Sessions', value: 'sessions' },
];
export const tableColumnName = {
[FilterKey.USERID]: 'Users',
[FilterKey.ISSUE]: 'Issues',
[FilterKey.USER_BROWSER]: 'Browser',
[FilterKey.USER_DEVICE]: 'Devices',
[FilterKey.USER_COUNTRY]: 'Countries',
[FilterKey.LOCATION]: 'URLs',
}
[FilterKey.USERID]: 'Users',
[FilterKey.ISSUE]: 'Issues',
[FilterKey.USER_BROWSER]: 'Browser',
[FilterKey.USER_DEVICE]: 'Devices',
[FilterKey.USER_COUNTRY]: 'Countries',
[FilterKey.LOCATION]: 'URLs'
};
export const metricOf = [
{ label: 'Session Count', value: 'sessionCount', type: 'timeseries' },
@ -89,9 +89,9 @@ export const metricOf = [
{ label: 'Browser', value: FilterKey.USER_BROWSER, type: 'table' },
{ label: 'Devices', value: FilterKey.USER_DEVICE, type: 'table' },
{ label: 'Countries', value: FilterKey.USER_COUNTRY, type: 'table' },
{ label: 'URLs', value: FilterKey.LOCATION, type: 'table' },
{ label: 'URLs', value: FilterKey.LOCATION, type: 'table' }
]
];
export const methodOptions = [
{ label: 'GET', value: 'GET' },
@ -102,8 +102,8 @@ export const methodOptions = [
{ label: 'HEAD', value: 'HEAD' },
{ label: 'OPTIONS', value: 'OPTIONS' },
{ label: 'TRACE', value: 'TRACE' },
{ label: 'CONNECT', value: 'CONNECT' },
]
{ label: 'CONNECT', value: 'CONNECT' }
];
export const issueOptions = [
{ label: 'Click Rage', value: IssueType.CLICK_RAGE },
@ -119,19 +119,26 @@ export const issueOptions = [
{ label: 'Custom', value: IssueType.CUSTOM },
{ label: 'Error', value: IssueType.JS_EXCEPTION },
{ label: 'Mouse Thrashing', value: IssueType.MOUSE_THRASHING }
]
];
export const issueCategories = [
{ label: 'Resources', value: IssueCategory.RESOURCES },
{ label: 'Network Request', value: IssueCategory.NETWORK },
{ label: 'Click Rage', value: IssueCategory.RAGE },
{ label: 'JS Errors', value: IssueCategory.ERRORS },
]
{ label: 'JS Errors', value: IssueCategory.ERRORS }
];
export const issueCategoriesMap = issueCategories.reduce((acc, {value, label}) => {
export const pathAnalysisEvents = [
{ value: FilterKey.LOCATION, label: 'Pages' },
{ value: FilterKey.CLICK, label: 'Clicks' },
{ value: FilterKey.INPUT, label: 'Input' },
{ value: FilterKey.CUSTOM, label: 'Custom' }
];
export const issueCategoriesMap = issueCategories.reduce((acc, { value, label }) => {
acc[value] = label;
return acc;
}, {})
}, {});
export default {
options,
@ -148,5 +155,5 @@ export default {
issueOptions,
issueCategories,
methodOptions,
pageUrlOperators,
}
pageUrlOperators
};

View file

@ -91,6 +91,7 @@ export default class FilterItem {
this.completed = json.completed;
this.dropped = json.dropped;
return this;
}

View file

@ -3,7 +3,7 @@ import FilterSeries from './filterSeries';
import { DateTime } from 'luxon';
import Session from 'App/mstore/types/session';
import Funnelissue from 'App/mstore/types/funnelIssue';
import { issueOptions, issueCategories, issueCategoriesMap } from 'App/constants/filterOptions';
import { issueOptions, issueCategories, issueCategoriesMap, pathAnalysisEvents } from 'App/constants/filterOptions';
import { FilterKey } from 'Types/filter/filterType';
import Period, { LAST_24_HOURS } from 'Types/app/period';
import Funnel from '../types/funnel';
@ -69,7 +69,7 @@ export default class Widget {
name: string = 'Untitled Card';
metricType: string = 'timeseries';
metricOf: string = 'sessionCount';
metricValue: string = '';
metricValue: any = '';
viewType: string = 'lineChart';
metricFormat: string = 'sessionCount';
series: FilterSeries[] = [];
@ -85,7 +85,6 @@ export default class Widget {
thumbnail?: string;
params: any = { density: 70 };
startType: string = 'start';
// startPoint: FilterItem = filtersMap[FilterKey.LOCATION];
startPoint: FilterItem = new FilterItem(filtersMap[FilterKey.LOCATION]);
excludes: FilterItem[] = [];
hideExcess?: boolean = false;
@ -162,6 +161,25 @@ export default class Widget {
this.series[0].filter.eventsOrderSupport = ['then'];
}
if (this.metricType === USER_PATH) {
this.hideExcess = json.hideExcess;
this.startType = json.startType;
if (json.startPoint) {
if (Array.isArray(json.startPoint) && json.startPoint.length > 0) {
this.startPoint = new FilterItem().fromJson(json.startPoint[0]);
}
if (json.startPoint == typeof Object) {
this.startPoint = json.startPoint;
}
}
// TODO change this to excludes after the api change
if (json.exclude) {
this.series[0].filter.excludes = json.exclude.map((i: any) => new FilterItem().fromJson(i));
}
}
if (period) {
this.period = period;
}
@ -238,14 +256,17 @@ export default class Widget {
return this.metricId !== undefined;
}
setData(data: any, period: any) {
const _data: any = { ...data };
if (this.metricType === USER_PATH) {
_data['links'] = data.links.map((s: any) => ({
...s,
...s
// value: Math.round(s.value),
}));
Object.assign(this.data, _data);
return _data;
}
if (this.metricOf === FilterKey.ERRORS) {
@ -311,34 +332,33 @@ export default class Widget {
}
fetchIssues(card: any): Promise<any> {
return new Promise((resolve) => {
metricService.fetchIssues(card)
.then((response: any) => {
if (card.metricType === USER_PATH) {
resolve({
total: response.count,
issues: response.values.map((issue: any) => new Issue().fromJSON(issue))
});
} else {
const significantIssues = response.issues.significant
? response.issues.significant.map((issue: any) => new Funnelissue().fromJSON(issue))
: [];
const insignificantIssues = response.issues.insignificant
? response.issues.insignificant.map((issue: any) => new Funnelissue().fromJSON(issue))
: [];
resolve({
issues: significantIssues.length > 0 ? significantIssues : insignificantIssues
});
}
}).finally(() => {
resolve({
issues: []
});
});
});
async fetchIssues(card: any): Promise<any> {
try {
const response = await metricService.fetchIssues(card);
if (card.metricType === USER_PATH) {
return {
total: response.count,
issues: response.values.map((issue: any) => new Issue().fromJSON(issue))
};
} else {
const mapIssue = (issue: any) => new Funnelissue().fromJSON(issue);
const significantIssues = response.issues.significant?.map(mapIssue) || [];
const insignificantIssues = response.issues.insignificant?.map(mapIssue) || [];
return {
issues: significantIssues.length > 0 ? significantIssues : insignificantIssues
};
}
} catch (error) {
console.error('Error fetching issues:', error);
return {
issues: []
};
}
}
fetchIssue(funnelId: any, issueId: any, params: any): Promise<any> {
return new Promise((resolve, reject) => {
metricService
@ -361,6 +381,8 @@ export default class Widget {
return issueOptions.filter((i: any) => metricValue.includes(i.value));
} else if (metricType === INSIGHTS) {
return issueCategories.filter((i: any) => metricValue.includes(i.value));
} else if (metricType === USER_PATH) {
return pathAnalysisEvents.filter((i: any) => metricValue.includes(i.value));
}
}

View file

@ -82,7 +82,7 @@ export default class MetricService {
}
const path = isWidget ? `/cards/${metric.metricId}/chart` : `/cards/try`;
if (metric.metricType === USER_PATH) {
data.density = 4;
data.density = 5;
data.metricOf = 'sessionCount';
}
try {