Product analytics refinements (#3011)

* Various UX, UI and Functional Improvements in  Dashboards & Cards

- Depth filter of Sankey chart data in frontend
- Dashboard & Cards empty state view updates
- Disabled save image feature on cards

* Fixed empty views and headers

* Various improvements across dashboards and cards.

* Dashboard and Sankey refinements.

* More improvements in Sankey and Dashboard

* Autocomplete with checklist -- improvements
This commit is contained in:
Sudheer Salavadi 2025-02-06 03:43:10 -05:00 committed by GitHub
parent afb08cfe6d
commit 3dc933daf3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 229 additions and 93 deletions

View file

@ -3,8 +3,9 @@ import { echarts, defaultOptions } from './init';
import { SankeyChart } from 'echarts/charts';
import { sankeyTooltip, getEventPriority, getNodeName } from './sankeyUtils';
import { NoContent } from 'App/components/ui';
import {InfoCircleOutlined} from '@ant-design/icons';
import { InfoCircleOutlined } from '@ant-design/icons';
import { X } from 'lucide-react';
echarts.use([SankeyChart]);
interface SankeyNode {
@ -15,9 +16,9 @@ interface SankeyNode {
}
interface SankeyLink {
source: number;
target: number;
value: number;
source: number | string;
target: number | string;
value: number;
sessionsCount: number;
eventType?: string;
}
@ -34,6 +35,42 @@ interface Props {
isUngrouped?: boolean;
}
function buildSubgraph(
startNodeId: string | number,
nodes: SankeyNode[],
links: SankeyLink[]
) {
const visited = new Set<string | number>();
const queue = [startNodeId];
visited.add(startNodeId);
const adjacency: Record<string | number, Array<string | number>> = {};
links.forEach((link) => {
if (!adjacency[link.source]) {
adjacency[link.source] = [];
}
adjacency[link.source].push(link.target);
});
while (queue.length > 0) {
const current = queue.shift()!;
const neighbors = adjacency[current] || [];
neighbors.forEach((nbr) => {
if (!visited.has(nbr)) {
visited.add(nbr);
queue.push(nbr);
}
});
}
const subNodes = nodes.filter((n) => visited.has(n.id));
const subLinks = links.filter(
(l) => visited.has(l.source) && visited.has(l.target)
);
return { subNodes, subLinks };
}
const EChartsSankey: React.FC<Props> = (props) => {
const { data, height = 240, onChartClick, isUngrouped } = props;
const chartRef = React.useRef<HTMLDivElement>(null);
@ -44,31 +81,53 @@ const EChartsSankey: React.FC<Props> = (props) => {
style={{ minHeight: height }}
title={
<div className="flex items-center relative">
<InfoCircleOutlined className='hidden md:inline-block mr-1' />
<InfoCircleOutlined className="hidden md:inline-block mr-1" />
Set a start or end point to visualize the journey. If set, try adjusting filters.
</div>
}
show={true}
/>
);
}
const [finalNodeCount, setFinalNodeCount] = React.useState(data.nodes.length);
React.useEffect(() => {
if (!chartRef.current) return;
const startNodes = data.nodes.filter((n) => n.depth === 0);
let finalNodes = data.nodes;
let finalLinks = data.links;
if (startNodes.length > 1) {
const chosenStartNode = startNodes[0];
const { subNodes, subLinks } = buildSubgraph(
chosenStartNode.id!,
data.nodes,
data.links
);
finalNodes = subNodes;
finalLinks = subLinks;
}
const chart = echarts.init(chartRef.current);
const maxDepth = 4;
const filteredNodes = data.nodes.filter((n) => (n.depth ?? 0) <= maxDepth);
const filteredLinks = data.links.filter((l) => {
const sourceNode = data.nodes.find((n) => n.id === l.source);
const targetNode = data.nodes.find((n) => n.id === l.target);
return (sourceNode?.depth ?? 0) <= maxDepth && (targetNode?.depth ?? 0) <= maxDepth;
const filteredNodes = finalNodes.filter((n) => (n.depth ?? 0) <= maxDepth);
const filteredLinks = finalLinks.filter((l) => {
const sourceNode = finalNodes.find((n) => n.id === l.source);
const targetNode = finalNodes.find((n) => n.id === l.target);
return (
(sourceNode?.depth ?? 0) <= maxDepth && (targetNode?.depth ?? 0) <= maxDepth
);
});
const nodeValues = new Array(filteredNodes.length).fill(0);
setFinalNodeCount(filteredNodes.length);
const echartNodes = filteredNodes
@ -81,8 +140,9 @@ const EChartsSankey: React.FC<Props> = (props) => {
computedName === 'Others'
? 'rgba(34,44,154,.9)'
: n.eventType === 'DROP'
? '#B5B7C8'
: '#394eff';
? '#B5B7C8'
: '#394eff';
return {
name: computedName,
depth: n.depth,
@ -93,16 +153,14 @@ const EChartsSankey: React.FC<Props> = (props) => {
};
})
.sort((a, b) => {
if (a.depth === b.depth) {
return getEventPriority(a.type || '') - getEventPriority(b.type || '');
} else {
return (a.depth as number) - (b.depth as number);
}
});
const distinctSteps = new Set(data.nodes.map(node => node.depth)).size;
console.log('Number of steps returned by the backend:', distinctSteps);
const echartLinks = filteredLinks.map((l) => ({
source: echartNodes.findIndex((n) => n.id === l.source),
target: echartNodes.findIndex((n) => n.id === l.target),
@ -111,17 +169,14 @@ const EChartsSankey: React.FC<Props> = (props) => {
lineStyle: { opacity: 0.1 },
}));
nodeValues.forEach((v, i) => {
const outgoingValues = echartLinks
.filter((l) => l.source === i)
.reduce((p, c) => p + c.value, 0);
const incomingValues = echartLinks
.filter((l) => l.target === i)
.reduce((p, c) => p + c.value, 0);
nodeValues[i] = Math.max(outgoingValues, incomingValues);
});
if (echartNodes.length === 0) return;
const startNodeValue = echartLinks
.filter((link) => link.source === 0)
.reduce((sum, link) => sum + link.value, 0);
const option = {
...defaultOptions,
tooltip: {
@ -147,31 +202,49 @@ const EChartsSankey: React.FC<Props> = (props) => {
label: {
show: true,
position: 'top',
textShadowColor: "transparent",
textBorderColor: "transparent",
textShadowColor: 'transparent',
textBorderColor: 'transparent',
align: 'left',
overflow: "truncate",
overflow: 'truncate',
maxWidth: 30,
distance: 3,
offset: [-20, 0],
formatter: function(params: any) {
const totalSessions = nodeValues.reduce((sum: number, v: number) => sum + v, 0);
const percentage = totalSessions ? ((params.value / totalSessions) * 100).toFixed(1) + '%' : '0%';
return `{header|${params.name}}\n{body|${percentage} ${params.value} Sessions}`;
formatter: function (params: any) {
const nodeVal = params.value;
const percentage = startNodeValue
? ((nodeVal / startNodeValue) * 100).toFixed(1) + '%'
: '0%';
return (
`{header|${params.name}}\n` +
`{body|}{percentage|${percentage}} {sessions|${nodeVal}}`
);
},
rich: {
header: {
fontWeight: 'bold',
fontWeight: '600',
fontSize: 12,
color: '#333'
color: '#333',
overflow: 'truncate',
paddingBottom:'.5rem',
},
body: {
fontSize: 12,
color: '#666'
}
}
color: '#000',
},
percentage: {
fontSize: 12,
color: '#454545',
},
sessions: {
fontSize: 12,
fontFamily: "mono, 'monospace', sans-serif",
color: '#999999',
},
},
},
tooltip: {
formatter: sankeyTooltip(echartNodes, nodeValues),
formatter: sankeyTooltip(echartNodes, []),
},
nodeAlign: 'left',
nodeWidth: 40,
@ -189,8 +262,10 @@ const EChartsSankey: React.FC<Props> = (props) => {
],
};
chart.setOption(option);
function getUpstreamNodes(nodeIdx: number, visited = new Set<number>()) {
if (visited.has(nodeIdx)) return;
visited.add(nodeIdx);
@ -231,7 +306,7 @@ const EChartsSankey: React.FC<Props> = (props) => {
const baseOpacity = connectedChain.has(idx) ? 1 : 0.35;
const extraStyle =
idx === hoveredIndex
? { borderColor: '#000000', borderWidth: 1, borderType: 'dotted' }
? { borderColor: '#000', borderWidth: 1, borderType: 'dotted' }
: {};
return {
...node,
@ -267,7 +342,6 @@ const EChartsSankey: React.FC<Props> = (props) => {
chart.on('mouseout', function (params: any) {
if (params.dataType === 'node') {
// Restore original styles on mouseout.
chart.setOption({
series: [
{
@ -279,21 +353,21 @@ const EChartsSankey: React.FC<Props> = (props) => {
}
});
chart.on('click', function (params: any) {
if (!onChartClick) return;
if (params.dataType === 'node') {
const nodeIndex = params.dataIndex;
// Use filteredNodes here.
const node = filteredNodes[nodeIndex];
onChartClick([{ node }]);
} else if (params.dataType === 'edge') {
const linkIndex = params.dataIndex;
// Use filteredLinks here.
const link = filteredLinks[linkIndex];
onChartClick([{ link }]);
}
});
const ro = new ResizeObserver(() => chart.resize());
ro.observe(chartRef.current);
@ -303,11 +377,29 @@ const EChartsSankey: React.FC<Props> = (props) => {
};
}, [data, height, onChartClick]);
const containerStyle: React.CSSProperties = isUngrouped
? {width: '100%', minHeight: 500, height: '100%', overflowY: 'auto' }
: { width: '100%', height };
let containerStyle: React.CSSProperties;
if (isUngrouped) {
const dynamicMinHeight = finalNodeCount * 15;
containerStyle = {
width: '100%',
minHeight: dynamicMinHeight,
height: '100%',
overflowY: 'auto',
};
} else {
containerStyle = {
width: '100%',
height,
};
}
return <div ref={chartRef} style={containerStyle} />;
return <div ref={chartRef} style={containerStyle} className='min-w-[600px] overflow-scroll' />;
};
export default EChartsSankey;

View file

@ -65,12 +65,12 @@ export const tabItems: Record<string, TabItem[]> = {
type: USER_PATH,
description: 'Understand the paths users take through your product.',
},
// { TODO: 1.23+
// icon: <Icon name={'dashboards/cohort-chart'} color={'inherit'} size={16} />,
// title: 'Retention',
// type: RETENTION,
// description: 'Analyze user retention over specific time periods.',
// },
{
icon: <Icon name={'dashboards/heatmap-2'} color={'inherit'} size={16} />,
title: 'Heatmaps',
@ -143,12 +143,12 @@ export const tabItems: Record<string, TabItem[]> = {
type: FilterKey.USER_DEVICE,
description: 'Explore the devices used by your users.',
},
// { TODO: 1.23+ maybe
// icon: <ArrowDown10 width={16} />,
// title: 'Speed Index by Country',
// type: TABLE,
// description: 'Measure performance across different regions.',
// },
],
};
@ -223,7 +223,7 @@ function CategoryTab({
];
}
// TODO This code here makes 0 sense
if (selectedCard.cardType === FUNNEL) {
cardData.series = [];
cardData.series.push(new FilterSeries());
@ -317,7 +317,7 @@ const AddCardSection = observer(
return (
<div
className={
'pt-4 pb-6 px-6 rounded-xl bg-white border border-gray-lighter flex flex-col gap-2'
'pt-4 pb-6 px-6 rounded-xl bg-white border border-gray-lighter flex flex-col gap-2 shadow-sm'
}
>
<div className={'flex justify-between p-2'}>

View file

@ -140,12 +140,11 @@ const ListView: React.FC<Props> = ({
);
const onItemClick = (metric: Widget) => {
if (disableSelection) return;
if (toggleSelection) {
toggleSelection(metric.metricId);
} else {
if (disableSelection) {
const path = withSiteId(`/metrics/${metric.metricId}`, siteId);
history.push(path);
} else {
toggleSelection?.(metric.metricId);
}
};

View file

@ -72,7 +72,7 @@ function MetricsList({
metricStore.updateKey('showMine', !showOwn);
};
// Define dimensions for the empty state illustration
const isFiltered =
metricsSearch !== '' || (metricStore.filter.type && metricStore.filter.type !== 'all');

View file

@ -16,6 +16,7 @@ function LongLoader({ onClick }: { onClick: () => void }) {
'0%': '#394EFF',
'100%': '#394EFF'
}}
status="active"
showInfo={false}
/>
</div>

View file

@ -196,14 +196,14 @@ const PathAnalysisFilter = observer(({ metric, writeOption }: any) => {
{ value: 'custom', label: 'Custom Events' },
];
return (
<Card styles={{ body: { padding: '20px 20px' } }} className="rounded-lg">
<div className='flex flex-col lg:flex-row lg:items-center gap-6 flex-wrap'>
<Form.Item className='mb-0 flex-1'>
<div className="rounded-lg bg-white border">
<div className='flex flex-col justify-start gap-2 flex-wrap'>
<Form.Item className='mb-0 hover:bg-bg-blue/30 px-4 pb-1 pt-2'>
<div className="flex flex-wrap gap-2 items-center justify-start">
<span className="font-medium">Journeys with: </span>
<div className="flex gap-2 items-start">
<span className="font-medium">Journeys With </span>
<div className="flex gap-2 items-center">
<Select
className="w-36 rounded-lg"
className="w-36 rounded-lg !h-[26px]"
name="startType"
options={[
{ value: 'start', label: 'Start Point' },
@ -215,11 +215,11 @@ const PathAnalysisFilter = observer(({ metric, writeOption }: any) => {
size="small"
/>
<span className="text-neutral-400 mt-.5">showing</span>
<span className="">showing</span>
<Select
mode="multiple"
className="rounded-lg w-max min-w-44 max-w-58"
className="rounded-lg h-[26px] w-max min-w-44 max-w-58"
// style={{ width: 'auto', minWidth: '9rem', maxWidth: '12rem' }}
allowClear
name="metricValue"
@ -234,7 +234,7 @@ const PathAnalysisFilter = observer(({ metric, writeOption }: any) => {
</div>
</div>
</Form.Item>
<Form.Item className='mb-0 flex-1'>
<Form.Item className='mb-0 hover:bg-bg-blue/30 px-4 pb-2 pt-1'>
<div className="flex flex-wrap items-center justify-start">
<span className="font-medium mr-2">{
metric.startType === 'start'
@ -258,7 +258,7 @@ const PathAnalysisFilter = observer(({ metric, writeOption }: any) => {
</div>
</Form.Item>
</div>
</Card>
</div>
);
});

View file

@ -1,6 +1,6 @@
import React, { useRef, useState, useEffect } from 'react';
import { Button, Checkbox, Input, Tooltip } from 'antd';
import { RedoOutlined } from '@ant-design/icons';
import { RedoOutlined, CloseCircleFilled } from '@ant-design/icons';
import cn from 'classnames';
import { Loader } from 'UI';
import OutsideClickDetectingDiv from '../../OutsideClickDetectingDiv';
@ -83,7 +83,9 @@ export function AutocompleteModal({
const applyQuery = () => {
const vals = commaQuery ? query.split(',').map((i) => i.trim()) : [query];
onApply(vals);
// onApply(vals);
const merged = Array.from(new Set([...selectedValues, ...vals]));
onApply(merged);
};
const clearSelection = () => {
@ -134,6 +136,7 @@ export function AutocompleteModal({
placeholder={placeholder}
className="rounded-lg"
autoFocus
allowClear
/>
<Loader loading={isLoading}>
<>
@ -159,7 +162,7 @@ export function AutocompleteModal({
<div className={'border-y border-y-gray-light py-2'}>
<div
className={
'whitespace-normal rounded cursor-pointer text-blue hover:bg-active-blue px-2 py-1'
'whitespace-normal rounded cursor-pointer text-teal hover:bg-active-blue px-2 py-1'
}
onClick={applyQuery}
>
@ -174,10 +177,6 @@ export function AutocompleteModal({
<Button type="primary" onClick={applyValues} className="btn-apply-event-value">
Apply
</Button>
<Button onClick={onClose} className="btn-cancel-event-value">
Cancel
</Button>
</div>
<Tooltip title='Clear all selection'>
@ -191,7 +190,7 @@ export function AutocompleteModal({
);
}
// Props interface
interface Props {
value: string[];
params?: any;
@ -202,15 +201,34 @@ interface Props {
mapValues?: (value: string) => string;
}
// AutoCompleteContainer component
export function AutoCompleteContainer(props: Props) {
const filterValueContainer = useRef<HTMLDivElement>(null);
const [showValueModal, setShowValueModal] = useState(false);
const [hovered, setHovered] = useState(false);
const isEmpty = props.value.length === 0 || !props.value[0];
const onClose = () => setShowValueModal(false);
const onApply = (values: string[]) => {
props.onApplyValues(values);
setTimeout(() => {
props.onApplyValues(values);
setShowValueModal(false);
}, 100);
console.log("closed on apply");
};
const onClearClick = (event: React.MouseEvent) => {
event.stopPropagation();
props.onApplyValues([]);
setShowValueModal(false);
console.log("closed clear click");
};
const handleContainerClick = (event: React.MouseEvent) => {
if (event.target === event.currentTarget ||
event.currentTarget.contains(event.target as Node)) {
setShowValueModal(true);
}
};
return (
@ -220,10 +238,13 @@ export function AutoCompleteContainer(props: Props) {
}
style={{ height: 26 }}
ref={filterValueContainer}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onClick={handleContainerClick}
>
<div
onClick={() => setTimeout(() => setShowValueModal(true), 0)}
className={'flex items-center gap-2 cursor-pointer'}
className={'flex items-center gap-2 cursor-pointer pr-4'}
>
{!isEmpty ? (
<>
@ -240,7 +261,7 @@ export function AutoCompleteContainer(props: Props) {
/>
{props.value.length > 2 && (
<TruncatedText
text={`+ ${props.value.length - 1} More`}
text={`+ ${props.value.length - 2} More`}
maxWidth="8rem"
/>
)}
@ -252,6 +273,14 @@ export function AutoCompleteContainer(props: Props) {
{props.placeholder ? props.placeholder : 'Select value(s)'}
</div>
)}
{!isEmpty && hovered && (
<div
className="absolute right-2 cursor-pointer flex items-center justify-center"
onClick={onClearClick}
>
<CloseCircleFilled className='text-neutral-200' />
</div>
)}
</div>
{showValueModal ? (
<props.modalRenderer

View file

@ -109,7 +109,7 @@ function FilterValue(props: Props) {
placeholder={filter.placeholder}
options={filter.options}
onApplyValues={onApplyValues}
// onChange={(item, index) => onChange(null, { value: item.value }, index)}
{...props}
/>
)
@ -179,7 +179,7 @@ function FilterValue(props: Props) {
id={`ignore-outside`}
className={cn('grid gap-3 w-fit flex-wrap my-1.5', {
'grid-cols-2': filter.hasSource,
//'lg:grid-cols-3': !filter.hasSource,
})}
>
{renderValueFiled(filter.value)}

View file

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

View file

@ -123,10 +123,25 @@
box-shadow: 1px 1px 1px 1px rgba(0, 0, 0 0.3);
}
.rounded-sm .ant-select-selector{
border-radius: .25rem !important;
}
.rounded-lg .ant-select-selector{
border-radius: .5rem !important;
}
.rounded-xl .ant-select-selector{
border-radius: .75rem !important;
}
.active-bg {
background-color: $active-blue;
}
.border-b-light {
border-bottom: solid thin $gray-light;
}