feat(ui): path analysis - path highight
This commit is contained in:
parent
ee05d89f13
commit
36d7eabbd9
9 changed files with 175 additions and 123 deletions
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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)');
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ export default class FilterItem {
|
|||
|
||||
this.completed = json.completed;
|
||||
this.dropped = json.dropped;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue