Product analytics refinements (#3006)

* 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.
This commit is contained in:
Sudheer Salavadi 2025-02-05 04:43:16 -05:00 committed by GitHub
parent cb8d87e367
commit f88ff53e15
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 97 additions and 44 deletions

View file

@ -4,6 +4,7 @@ import { SankeyChart } from 'echarts/charts';
import { sankeyTooltip, getEventPriority, getNodeName } from './sankeyUtils';
import { NoContent } from 'App/components/ui';
import {InfoCircleOutlined} from '@ant-design/icons';
import { X } from 'lucide-react';
echarts.use([SankeyChart]);
interface SankeyNode {
@ -30,10 +31,11 @@ interface Props {
data: Data;
height?: number;
onChartClick?: (filters: any[]) => void;
isUngrouped?: boolean;
}
const EChartsSankey: React.FC<Props> = (props) => {
const { data, height = 240, onChartClick } = props;
const { data, height = 240, onChartClick, isUngrouped } = props;
const chartRef = React.useRef<HTMLDivElement>(null);
if (data.nodes.length === 0 || data.links.length === 0) {
@ -64,7 +66,6 @@ const EChartsSankey: React.FC<Props> = (props) => {
const targetNode = data.nodes.find((n) => n.id === l.target);
return (sourceNode?.depth ?? 0) <= maxDepth && (targetNode?.depth ?? 0) <= maxDepth;
});
const nodeValues = new Array(filteredNodes.length).fill(0);
@ -74,11 +75,11 @@ const EChartsSankey: React.FC<Props> = (props) => {
.map((n) => {
let computedName = getNodeName(n.eventType || 'Minor Paths', n.name);
if (computedName === 'Other') {
computedName = 'Minor Paths';
computedName = 'Others';
}
const itemColor =
computedName === 'Minor Paths'
? '#222F99'
computedName === 'Others'
? 'rgba(34,44,154,.9)'
: n.eventType === 'DROP'
? '#B5B7C8'
: '#394eff';
@ -98,9 +99,9 @@ const EChartsSankey: React.FC<Props> = (props) => {
return (a.depth as number) - (b.depth as number);
}
});
console.log('EChart Nodes:', echartNodes);
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),
@ -144,22 +145,45 @@ const EChartsSankey: React.FC<Props> = (props) => {
},
},
label: {
formatter: '{b} - {c}',
show: true,
position: 'top',
textShadowColor: "transparent",
textBorderColor: "transparent",
align: 'left',
overflow: "truncate",
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}`;
},
rich: {
header: {
fontWeight: 'bold',
fontSize: 12,
color: '#333'
},
body: {
fontSize: 12,
color: '#666'
}
}
},
tooltip: {
formatter: sankeyTooltip(echartNodes, nodeValues),
},
nodeAlign: 'jusitfy',
nodeAlign: 'left',
nodeWidth: 40,
nodeGap: 8,
nodeGap: 40,
lineStyle: {
color: 'source',
curveness: 0.2,
curveness: 0.6,
opacity: 0.1,
},
itemStyle: {
color: '#394eff',
borderRadius: 2,
borderRadius: 7,
},
},
],
@ -279,7 +303,11 @@ const EChartsSankey: React.FC<Props> = (props) => {
};
}, [data, height, onChartClick]);
return <div ref={chartRef} style={{ width: '100%', height }} />;
const containerStyle: React.CSSProperties = isUngrouped
? {width: '100%', minHeight: 500, height: '100%', overflowY: 'auto' }
: { width: '100%', height };
return <div ref={chartRef} style={containerStyle} />;
};
export default EChartsSankey;

View file

@ -513,14 +513,16 @@ function WidgetChart(props: Props) {
}
if (metricType === USER_PATH && data && data.links) {
// const usedData = _metric.hideExcess ? filterMinorPaths(data) : data;
const isUngrouped = props.isPreview ? (!(_metric.hideExcess ?? true)) : false;
const height = props.isPreview ? 550 : 500;
return (
<SankeyChart
height={props.isPreview ? 500 : 240}
height={height}
data={data}
onChartClick={(filters: any) => {
dashboardStore.drillDownFilter.merge({ filters, page: 1 });
}}
isUngrouped={isUngrouped}
/>
);
}

View file

@ -193,17 +193,17 @@ const PathAnalysisFilter = observer(({ metric, writeOption }: any) => {
{ value: 'location', label: 'Pages' },
{ value: 'click', label: 'Clicks' },
{ value: 'input', label: 'Input' },
{ value: 'custom', label: 'Custom' },
{ value: 'custom', label: 'Custom Events' },
];
return (
<Card styles={{ body: { padding: '20px 20px' } }} className="rounded-lg">
<Form.Item>
<div className='flex flex-col lg:flex-row lg:items-center gap-6 flex-wrap'>
<Form.Item className='mb-0 flex-1'>
<div className="flex flex-wrap gap-2 items-center justify-start">
<span className="font-medium">User journeys with: </span>
<div className="flex sm:flex-wrap lg:flex-nowrap gap-2 items-start">
<span className="font-medium">Journeys with: </span>
<div className="flex gap-2 items-start">
<Select
className="w-36 rounded-xl"
className="w-36 rounded-lg"
name="startType"
options={[
{ value: 'start', label: 'Start Point' },
@ -219,7 +219,8 @@ const PathAnalysisFilter = observer(({ metric, writeOption }: any) => {
<Select
mode="multiple"
className="min-w-36 rounded-xl"
className="rounded-lg w-max min-w-44 max-w-58"
// style={{ width: 'auto', minWidth: '9rem', maxWidth: '12rem' }}
allowClear
name="metricValue"
options={metricValueOptions}
@ -227,19 +228,19 @@ const PathAnalysisFilter = observer(({ metric, writeOption }: any) => {
onChange={(value) => writeOption({ name: 'metricValue', value })}
placeholder="Select Metrics"
size="small"
maxTagCount={'responsive'}
showSearch={false}
/>
</div>
</div>
</Form.Item>
<div className="flex items-center">
<Form.Item
label={
metric.startType === 'start'
? 'Specify Start Point'
: 'Specify End Point'
}
className="m0-0 font-medium p-0 h-fit"
>
<Form.Item className='mb-0 flex-1'>
<div className="flex flex-wrap items-center justify-start">
<span className="font-medium mr-2">{
metric.startType === 'start'
? 'Start Point'
: 'End Point'
}</span>
<span className="font-normal">
<FilterItem
hideDelete
@ -254,6 +255,7 @@ const PathAnalysisFilter = observer(({ metric, writeOption }: any) => {
onRemoveFilter={() => {}}
/>
</span>
</div>
</Form.Item>
</div>
</Card>

View file

@ -45,7 +45,7 @@ function WidgetOptions() {
onClick={(e) => {
e.preventDefault();
metric.update({ hideExcess: !metric.hideExcess });
metric.updateKey('hasChanged', true);
// metric.updateKey('hasChanged', true);
}}
>
<Space>

View file

@ -171,7 +171,7 @@ function WidgetView(props: Props) {
{
value: 'flex-row',
icon: (
<Tooltip title="Horizontal Layout">
<Tooltip title="Filters on Left">
<LayoutPanelLeft size={16} />
</Tooltip>
)
@ -179,7 +179,7 @@ function WidgetView(props: Props) {
{
value: 'flex-col',
icon: (
<Tooltip title="Vertical Layout">
<Tooltip title="Filters on Top">
<LayoutPanelTop size={16} />
</Tooltip>
)
@ -187,7 +187,7 @@ function WidgetView(props: Props) {
{
value: 'flex-row-reverse',
icon: (
<Tooltip title="Reversed Horizontal Layout">
<Tooltip title="Filters on Right">
<div className={'rotate-180'}>
<LayoutPanelLeft size={16} />
</div>

View file

@ -3,7 +3,7 @@ import cn from 'classnames';
import WidgetName from 'Components/Dashboard/components/WidgetName';
import { useStore } from 'App/mstore';
import { observer } from 'mobx-react-lite';
import { USER_PATH } from 'App/constants/card';
import { Button, Space, Tooltip } from 'antd';
import CardViewMenu from 'Components/Dashboard/components/WidgetView/CardViewMenu';
import { Link2 } from 'lucide-react'
@ -14,16 +14,20 @@ interface Props {
onSave: () => void;
undoChanges: () => void;
layoutControl?: React.ReactNode;
isPreview?: boolean;
}
const defaultText = 'Copy link to clipboard'
function WidgetViewHeader({ onClick, onSave, layoutControl }: Props) {
function WidgetViewHeader({ onClick, onSave, layoutControl, isPreview }: Props) {
const [tooltipText, setTooltipText] = React.useState(defaultText);
const { metricStore } = useStore();
const widget = metricStore.instance;
const handleSave = () => {
if (!isPreview && widget.metricType === USER_PATH) {
widget.hideExcess = true; // Force grouped view
}
onSave();
};
@ -36,7 +40,7 @@ function WidgetViewHeader({ onClick, onSave, layoutControl }: Props) {
return (
<div
className={cn(
'flex justify-between items-center bg-white rounded-lg shadow-sm px-4 ps-2 py-2 border border-gray-lighter input-card-title'
'flex justify-between items-center bg-white rounded-lg shadow-sm px-4 ps-2 py-2 border border-gray-lighter input-card-title flex-wrap'
)}
onClick={onClick}
>

View file

@ -1,5 +1,6 @@
import React, { useRef, useState, useEffect } from 'react';
import { Button, Checkbox, Input, Tooltip } from 'antd';
import { RedoOutlined } from '@ant-design/icons';
import cn from 'classnames';
import { Loader } from 'UI';
import OutsideClickDetectingDiv from '../../OutsideClickDetectingDiv';
@ -85,6 +86,10 @@ export function AutocompleteModal({
onApply(vals);
};
const clearSelection = () => {
setSelectedValues([]);
};
const sortedOptions = React.useMemo(() => {
if (values[0] && values[0].length) {
const sorted = options.sort((a, b) => {
@ -128,6 +133,7 @@ export function AutocompleteModal({
onChange={(e) => handleInputChange(e.target.value)}
placeholder={placeholder}
className="rounded-lg"
autoFocus
/>
<Loader loading={isLoading}>
<>
@ -163,13 +169,23 @@ export function AutocompleteModal({
) : null}
</>
</Loader>
<div className={'flex gap-2 items-center pt-2'}>
<Button type={'primary'} onClick={applyValues} className="btn-apply-event-value">
Apply
</Button>
<Button onClick={onClose} className="btn-cancel-event-value">
Cancel
<div className="flex justify-between items-center pt-2">
<div className="flex gap-2 items-center">
<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'>
<Button onClick={clearSelection} type='text' className="btn-clear-selection" disabled={selectedValues.length === 0}>
<RedoOutlined />
</Button>
</Tooltip>
</div>
</OutsideClickDetectingDiv>
);

View file

@ -240,6 +240,7 @@ function FilterModal(props: Props) {
placeholder={'Search'}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
autoFocus
/>
<div className={'flex gap-2 items-start'}>
<div className={'flex flex-col gap-1'}>