fix(dashboard): improvements for both funnel and conversion chart
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { op } from '@/utils/op';
|
||||
import { useLocation, useRouteContext } from '@tanstack/react-router';
|
||||
import { useRouteContext } from '@tanstack/react-router';
|
||||
import { SparklesIcon } from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
@@ -12,13 +12,15 @@ export function FeedbackButton() {
|
||||
icon={SparklesIcon}
|
||||
onClick={() => {
|
||||
op.track('feedback_button_clicked');
|
||||
if ('uj' in window) {
|
||||
(window.uj as any).identify({
|
||||
if ('uj' in window && window.uj !== undefined) {
|
||||
(window as any).uj.identify({
|
||||
id: context.session?.userId,
|
||||
firstName: context.session?.user?.firstName,
|
||||
email: context.session?.user?.email,
|
||||
});
|
||||
(window.uj as any).showWidget();
|
||||
setTimeout(() => {
|
||||
(window as any).uj.showWidget();
|
||||
}, 10);
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { pushModal } from '@/modals';
|
||||
import type { RouterOutputs } from '@/trpc/client';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import {
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
@@ -62,8 +62,27 @@ export function Chart({ data }: Props) {
|
||||
);
|
||||
|
||||
const xAxisProps = useXAxisProps({ interval, hide: hideXAxis });
|
||||
const number = useNumber();
|
||||
|
||||
// Calculate dynamic Y-axis domain based on max rate
|
||||
const yAxisDomain = useMemo(() => {
|
||||
if (!series.length) return [0, 100];
|
||||
|
||||
const maxRate = Math.max(
|
||||
...series.flatMap((serie) => serie.data.map((item) => item.rate))
|
||||
);
|
||||
|
||||
if (maxRate <= 5) return [0, 10];
|
||||
if (maxRate <= 20) return [0, 30];
|
||||
if (maxRate <= 50) return [0, 60];
|
||||
return [0, 100];
|
||||
}, [series]);
|
||||
|
||||
const yAxisProps = useYAxisProps({
|
||||
hide: hideYAxis,
|
||||
tickFormatter: (value: number) => {
|
||||
return `${number.short(value)}%`;
|
||||
},
|
||||
});
|
||||
|
||||
const averageConversionRate = average(
|
||||
@@ -72,6 +91,9 @@ export function Chart({ data }: Props) {
|
||||
}, 0),
|
||||
);
|
||||
|
||||
// Show dots when we have 30 or fewer data points
|
||||
const showDots = rechartData.length <= 30;
|
||||
|
||||
const handleChartClick = useCallback((e: any) => {
|
||||
if (e?.activePayload?.[0]) {
|
||||
const clickedData = e.activePayload[0].payload;
|
||||
@@ -137,7 +159,7 @@ export function Chart({ data }: Props) {
|
||||
fontSize={10}
|
||||
/>
|
||||
))}
|
||||
<YAxis {...yAxisProps} domain={[0, 100]} />
|
||||
<YAxis {...yAxisProps} domain={yAxisDomain} />
|
||||
<XAxis {...xAxisProps} allowDuplicatedCategory={false} />
|
||||
{series.length > 1 && <Legend content={<CustomLegend />} />}
|
||||
<Tooltip />
|
||||
@@ -166,6 +188,8 @@ export function Chart({ data }: Props) {
|
||||
type={lineType}
|
||||
isAnimationActive={false}
|
||||
strokeWidth={2}
|
||||
dot={showDots ? { r: 3, strokeWidth: 2, fill: 'white' } : false}
|
||||
activeDot={showDots ? { r: 5, strokeWidth: 2 } : { r: 4 }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -176,13 +200,14 @@ export function Chart({ data }: Props) {
|
||||
stroke={getChartColor(series.length)}
|
||||
strokeWidth={2}
|
||||
strokeDasharray="3 3"
|
||||
strokeOpacity={0.5}
|
||||
strokeOpacity={0.6}
|
||||
strokeLinecap="round"
|
||||
label={{
|
||||
value: `Average (${round(averageConversionRate, 2)} %)`,
|
||||
value: `Average (${round(averageConversionRate, 2)}%)`,
|
||||
fill: getChartColor(series.length),
|
||||
position: 'insideBottomRight',
|
||||
fontSize: 12,
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,14 +1,33 @@
|
||||
import type { RouterOutputs } from '@/trpc/client';
|
||||
import React, { useMemo } from 'react';
|
||||
import type React from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
ArrowDownRight,
|
||||
ArrowUpRight,
|
||||
GitBranch,
|
||||
Hash,
|
||||
Percent,
|
||||
Target,
|
||||
Trophy,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Stats, StatsCard } from '@/components/stats';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { formatDate } from '@/utils/date';
|
||||
import { average, getPreviousMetric, sum } from '@openpanel/common';
|
||||
import { ChevronRightIcon } from 'lucide-react';
|
||||
import { PreviousDiffIndicatorPure } from '../common/previous-diff-indicator';
|
||||
import { average, sum } from '@openpanel/common';
|
||||
import { useReportChartContext } from '../context';
|
||||
|
||||
const SUMMARY_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
Flow: GitBranch,
|
||||
'Average conversion rate': Percent,
|
||||
'Total conversions': Target,
|
||||
'Previous period average conversion rate': Percent,
|
||||
'Previous period total conversions': Hash,
|
||||
'Best breakdown (avg)': Trophy,
|
||||
'Worst breakdown (avg)': ArrowDownRight,
|
||||
'Best conversion rate': ArrowUpRight,
|
||||
'Worst conversion rate': ArrowDownRight,
|
||||
};
|
||||
|
||||
interface Props {
|
||||
data: RouterOutputs['chart']['conversion'];
|
||||
}
|
||||
@@ -108,122 +127,99 @@ export function Summary({ data }: Props) {
|
||||
|
||||
const hasManySeries = data.current.length > 1;
|
||||
|
||||
const getConversionRateNode = (
|
||||
item: RouterOutputs['chart']['conversion']['current'][0]['data'][0],
|
||||
) => {
|
||||
const breakdowns = item.serie.breakdowns.join(', ');
|
||||
if (breakdowns) {
|
||||
return (
|
||||
<span className="text-muted-foreground">
|
||||
On{' '}
|
||||
<span className="text-foreground">
|
||||
{item.serie.breakdowns.join(', ')}
|
||||
</span>{' '}
|
||||
with{' '}
|
||||
<span className="text-foreground">
|
||||
{number.formatWithUnit(item.rate / 100, '%')}
|
||||
</span>{' '}
|
||||
at {formatDate(new Date(item.date))}
|
||||
</span>
|
||||
const keyValueData = useMemo(() => {
|
||||
const flowLabel = report.series
|
||||
.filter((item) => item.type === 'event')
|
||||
.map((e) => e.displayName || e.name)
|
||||
.join(' → ');
|
||||
const items: { name: string; value: React.ReactNode }[] = [
|
||||
{ name: 'Flow', value: flowLabel },
|
||||
{
|
||||
name: 'Average conversion rate',
|
||||
value: number.formatWithUnit(averageConversionRate / 100, '%'),
|
||||
},
|
||||
{ name: 'Total conversions', value: sumConversions },
|
||||
];
|
||||
if (data.previous != null) {
|
||||
items.push(
|
||||
{
|
||||
name: 'Previous period average conversion rate',
|
||||
value: number.formatWithUnit(
|
||||
averageConversionRatePrevious / 100,
|
||||
'%',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Previous period total conversions',
|
||||
value: sumConversionsPrevious ?? 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="text-muted-foreground">
|
||||
<span className="text-foreground">
|
||||
{number.formatWithUnit(item.rate / 100, '%')}
|
||||
</span>{' '}
|
||||
at {formatDate(new Date(item.date))}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
if (hasManySeries && bestAverageConversionRateMatch) {
|
||||
items.push({
|
||||
name: 'Best breakdown (avg)',
|
||||
value: `${bestAverageConversionRateMatch.serie?.breakdowns.join(', ')} with ${number.formatWithUnit(bestAverageConversionRateMatch.averageRate / 100, '%')}`,
|
||||
});
|
||||
}
|
||||
if (hasManySeries && worstAverageConversionRateMatch) {
|
||||
items.push({
|
||||
name: 'Worst breakdown (avg)',
|
||||
value: `${worstAverageConversionRateMatch.serie?.breakdowns.join(', ')} with ${number.formatWithUnit(worstAverageConversionRateMatch.averageRate / 100, '%')}`,
|
||||
});
|
||||
}
|
||||
if (bestConversionRate) {
|
||||
const breakdowns = bestConversionRate.serie.breakdowns.join(', ');
|
||||
items.push({
|
||||
name: 'Best conversion rate',
|
||||
value: breakdowns
|
||||
? `${number.formatWithUnit(bestConversionRate.rate / 100, '%')} on ${breakdowns} at ${formatDate(new Date(bestConversionRate.date))}`
|
||||
: `${number.formatWithUnit(bestConversionRate.rate / 100, '%')} at ${formatDate(new Date(bestConversionRate.date))}`,
|
||||
});
|
||||
}
|
||||
if (worstConversionRate) {
|
||||
const breakdowns = worstConversionRate.serie.breakdowns.join(', ');
|
||||
items.push({
|
||||
name: 'Worst conversion rate',
|
||||
value: breakdowns
|
||||
? `${number.formatWithUnit(worstConversionRate.rate / 100, '%')} on ${breakdowns} at ${formatDate(new Date(worstConversionRate.date))}`
|
||||
: `${number.formatWithUnit(worstConversionRate.rate / 100, '%')} at ${formatDate(new Date(worstConversionRate.date))}`,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}, [
|
||||
report.series,
|
||||
averageConversionRate,
|
||||
sumConversions,
|
||||
data.previous,
|
||||
averageConversionRatePrevious,
|
||||
sumConversionsPrevious,
|
||||
hasManySeries,
|
||||
bestAverageConversionRateMatch,
|
||||
worstAverageConversionRateMatch,
|
||||
bestConversionRate,
|
||||
worstConversionRate,
|
||||
number,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Stats className="my-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
|
||||
<StatsCard
|
||||
title="Flow"
|
||||
value={
|
||||
<div className="row flex-wrap gap-1">
|
||||
{report.series
|
||||
.filter((item) => item.type === 'event')
|
||||
.map((event, index) => {
|
||||
return (
|
||||
<div key={event.id} className="row items-center gap-2">
|
||||
{index !== 0 && <ChevronRightIcon className="size-3" />}
|
||||
<span>{event.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{bestAverageConversionRateMatch && hasManySeries && (
|
||||
<StatsCard
|
||||
title="Best breakdown (avg)"
|
||||
value={
|
||||
<span>
|
||||
{bestAverageConversionRateMatch.serie?.breakdowns.join(', ')}{' '}
|
||||
<span className="text-muted-foreground">with</span>{' '}
|
||||
{number.formatWithUnit(
|
||||
bestAverageConversionRateMatch.averageRate / 100,
|
||||
'%',
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{worstAverageConversionRateMatch && hasManySeries && (
|
||||
<StatsCard
|
||||
title="Worst breakdown (avg)"
|
||||
value={
|
||||
<span>
|
||||
{worstAverageConversionRateMatch.serie?.breakdowns.join(', ')}{' '}
|
||||
<span className="text-muted-foreground">with</span>{' '}
|
||||
{number.formatWithUnit(
|
||||
worstAverageConversionRateMatch.averageRate / 100,
|
||||
'%',
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<StatsCard
|
||||
title="Average conversion rate"
|
||||
value={number.formatWithUnit(averageConversionRate / 100, '%')}
|
||||
enhancer={
|
||||
data.previous && (
|
||||
<PreviousDiffIndicatorPure
|
||||
{...getPreviousMetric(
|
||||
averageConversionRate,
|
||||
averageConversionRatePrevious,
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Total conversions"
|
||||
value={number.format(sumConversions)}
|
||||
enhancer={
|
||||
data.previous && (
|
||||
<PreviousDiffIndicatorPure
|
||||
{...getPreviousMetric(sumConversions, sumConversionsPrevious)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
{bestConversionRate && (
|
||||
<StatsCard
|
||||
title="Best conversion rate"
|
||||
value={getConversionRateNode(bestConversionRate)}
|
||||
/>
|
||||
)}
|
||||
{worstConversionRate && (
|
||||
<StatsCard
|
||||
title="Worst conversion rate"
|
||||
value={getConversionRateNode(worstConversionRate)}
|
||||
/>
|
||||
)}
|
||||
</Stats>
|
||||
<div className="my-4 space-y-3">
|
||||
<div className="row flex-wrap gap-2">
|
||||
{keyValueData.map((item) => {
|
||||
const Icon = SUMMARY_ICONS[item.name];
|
||||
return (
|
||||
<div
|
||||
key={item.name}
|
||||
className="card row items-center justify-between p-4 py-3 font-medium gap-4"
|
||||
>
|
||||
<span className="text-muted-foreground row items-center gap-2">
|
||||
{Icon != null && <Icon className="size-4 shrink-0" />}
|
||||
{item.name}
|
||||
</span>
|
||||
<span>{item.value}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
170
apps/start/src/components/report-chart/funnel/breakdown-list.tsx
Normal file
170
apps/start/src/components/report-chart/funnel/breakdown-list.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import type { RouterOutputs } from '@/trpc/client';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Tables } from './chart';
|
||||
|
||||
interface BreakdownListProps {
|
||||
data: RouterOutputs['chart']['funnel'];
|
||||
visibleSeriesIds: string[];
|
||||
setVisibleSeries: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
}
|
||||
|
||||
const COMPACT_THRESHOLD = 4;
|
||||
|
||||
export function BreakdownList({
|
||||
data,
|
||||
visibleSeriesIds,
|
||||
setVisibleSeries,
|
||||
}: BreakdownListProps) {
|
||||
const allBreakdowns = data.current;
|
||||
const previousData = data.previous || [];
|
||||
const isCompact = allBreakdowns.length > COMPACT_THRESHOLD;
|
||||
const hasBreakdowns = allBreakdowns.length > 1;
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||
const number = useNumber();
|
||||
|
||||
const toggleExpanded = (id: string) => {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleVisibility = (id: string) => {
|
||||
setVisibleSeries((prev) => {
|
||||
if (prev.includes(id)) {
|
||||
return prev.filter((s) => s !== id);
|
||||
}
|
||||
return [...prev, id];
|
||||
});
|
||||
};
|
||||
|
||||
// Get the color index for a breakdown based on its position in the
|
||||
// visible series list (so colors match the chart bars)
|
||||
const getVisibleIndex = (id: string) => {
|
||||
return visibleSeriesIds.indexOf(id);
|
||||
};
|
||||
|
||||
if (allBreakdowns.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Detailed mode: <= COMPACT_THRESHOLD breakdowns, show full Tables for each
|
||||
if (!isCompact) {
|
||||
return (
|
||||
<div className="col gap-4">
|
||||
{allBreakdowns.map((item, index) => (
|
||||
<Tables
|
||||
key={item.id}
|
||||
data={{
|
||||
current: item,
|
||||
previous: previousData[index] ?? null,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Compact mode: > COMPACT_THRESHOLD breakdowns, show compact rows with expand
|
||||
return (
|
||||
<div className="col gap-2">
|
||||
{allBreakdowns.map((item, index) => {
|
||||
const isExpanded = expandedIds.has(item.id);
|
||||
const isVisible = visibleSeriesIds.includes(item.id);
|
||||
const visibleIndex = getVisibleIndex(item.id);
|
||||
const previousItem = previousData[index] ?? null;
|
||||
const hasBreakdownName =
|
||||
item.breakdowns && item.breakdowns.length > 0;
|
||||
const color =
|
||||
isVisible && visibleIndex !== -1
|
||||
? getChartColor(visibleIndex)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div key={item.id} className="col">
|
||||
{/* Compact row */}
|
||||
<div
|
||||
className={cn(
|
||||
'card row items-center gap-3 px-4 py-3 text-left w-full',
|
||||
isExpanded && 'rounded-b-none',
|
||||
)}
|
||||
>
|
||||
{/* Chart visibility checkbox */}
|
||||
{hasBreakdowns && (
|
||||
<Checkbox
|
||||
checked={isVisible}
|
||||
onCheckedChange={() => toggleVisibility(item.id)}
|
||||
className="shrink-0"
|
||||
style={{
|
||||
borderColor: color,
|
||||
backgroundColor: isVisible ? color : 'transparent',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Expandable row content */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleExpanded(item.id)}
|
||||
className="flex items-center gap-3 flex-1 min-w-0 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="size-4 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="font-medium truncate">
|
||||
{hasBreakdownName
|
||||
? item.breakdowns.join(' > ')
|
||||
: 'Funnel'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-6 shrink-0">
|
||||
<div className="text-right row gap-2 items-center">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Conversion
|
||||
</div>
|
||||
<div className="font-mono font-semibold text-sm">
|
||||
{number.formatWithUnit(
|
||||
item.lastStep.percent / 100,
|
||||
'%',
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right row gap-2 items-center">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Completed
|
||||
</div>
|
||||
<div className="font-mono font-semibold text-sm">
|
||||
{number.format(item.lastStep.count)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded detailed view */}
|
||||
{isExpanded && (
|
||||
<Tables
|
||||
data={{
|
||||
current: item,
|
||||
previous: previousItem,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,19 +12,24 @@ import { BarShapeBlue, BarShapeProps } from '@/components/charts/common-bar';
|
||||
import { Tooltiper } from '@/components/ui/tooltip';
|
||||
import { WidgetTable } from '@/components/widget-table';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import type { IVisibleFunnelBreakdowns } from '@/hooks/use-visible-funnel-breakdowns';
|
||||
import { getChartColor, getChartTranslucentColor } from '@/utils/theme';
|
||||
import { getPreviousMetric } from '@openpanel/common';
|
||||
import { useCallback } from 'react';
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { useXAxisProps, useYAxisProps } from '../common/axis';
|
||||
import { PreviousDiffIndicatorPure } from '../common/previous-diff-indicator';
|
||||
import { SerieIcon } from '../common/serie-icon';
|
||||
import { SerieName } from '../common/serie-name';
|
||||
import { useReportChartContext } from '../context';
|
||||
|
||||
type Props = {
|
||||
@@ -54,7 +59,11 @@ export const Metric = ({
|
||||
</div>
|
||||
);
|
||||
|
||||
export function Summary({ data }: { data: RouterOutputs['chart']['funnel'] }) {
|
||||
export function Summary({
|
||||
data,
|
||||
}: {
|
||||
data: RouterOutputs['chart']['funnel'];
|
||||
}) {
|
||||
const number = useNumber();
|
||||
const highestConversion = data.current
|
||||
.slice(0)
|
||||
@@ -144,23 +153,23 @@ export function Tables({
|
||||
|
||||
// For funnels, we need to pass the step index so the modal can query
|
||||
// users who completed at least that step in the funnel sequence
|
||||
pushModal('ViewChartUsers', {
|
||||
type: 'funnel',
|
||||
report: {
|
||||
projectId,
|
||||
series: reportSeries,
|
||||
breakdowns: reportBreakdowns || [],
|
||||
interval: interval || 'day',
|
||||
startDate,
|
||||
endDate,
|
||||
range,
|
||||
previous,
|
||||
chartType: 'funnel',
|
||||
metric: 'sum',
|
||||
options: funnelOptions,
|
||||
},
|
||||
stepIndex, // Pass the step index for funnel queries
|
||||
});
|
||||
pushModal('ViewChartUsers', {
|
||||
type: 'funnel',
|
||||
report: {
|
||||
projectId,
|
||||
series: reportSeries,
|
||||
breakdowns: reportBreakdowns || [],
|
||||
interval: interval || 'day',
|
||||
startDate,
|
||||
endDate,
|
||||
range,
|
||||
previous,
|
||||
chartType: 'funnel',
|
||||
metric: 'sum',
|
||||
options: funnelOptions,
|
||||
},
|
||||
stepIndex, // Pass the step index for funnel queries
|
||||
});
|
||||
};
|
||||
return (
|
||||
<div className={cn('col @container divide-y divide-border card')}>
|
||||
@@ -330,25 +339,46 @@ type RechartData = {
|
||||
const useRechartData = ({
|
||||
current,
|
||||
previous,
|
||||
}: RouterOutputs['chart']['funnel']): RechartData[] => {
|
||||
visibleBreakdowns,
|
||||
}: RouterOutputs['chart']['funnel'] & {
|
||||
visibleBreakdowns: RouterOutputs['chart']['funnel']['current'];
|
||||
}): RechartData[] => {
|
||||
const firstFunnel = current[0];
|
||||
// Create a map of original index to visible index
|
||||
const visibleBreakdownIds = new Set(visibleBreakdowns.map((b) => b.id));
|
||||
const originalToVisibleIndex = new Map<number, number>();
|
||||
let visibleIndex = 0;
|
||||
current.forEach((item, originalIndex) => {
|
||||
if (visibleBreakdownIds.has(item.id)) {
|
||||
originalToVisibleIndex.set(originalIndex, visibleIndex);
|
||||
visibleIndex++;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
firstFunnel?.steps.map((step, stepIndex) => {
|
||||
return {
|
||||
id: step?.event.id ?? '',
|
||||
name: step?.event.displayName ?? '',
|
||||
...current.reduce((acc, item, index) => {
|
||||
const diff = previous?.[index];
|
||||
...visibleBreakdowns.reduce((acc, visibleItem, visibleIdx) => {
|
||||
// Find the original index for this visible breakdown
|
||||
const originalIndex = current.findIndex(
|
||||
(item) => item.id === visibleItem.id,
|
||||
);
|
||||
if (originalIndex === -1) return acc;
|
||||
|
||||
const diff = previous?.[originalIndex];
|
||||
return {
|
||||
...acc,
|
||||
[`step:percent:${index}`]: item.steps[stepIndex]?.percent ?? null,
|
||||
[`step:data:${index}`]: {
|
||||
...item,
|
||||
step: item.steps[stepIndex],
|
||||
[`step:percent:${visibleIdx}`]:
|
||||
visibleItem.steps[stepIndex]?.percent ?? null,
|
||||
[`step:data:${visibleIdx}`]: {
|
||||
...visibleItem,
|
||||
step: visibleItem.steps[stepIndex],
|
||||
},
|
||||
[`prev_step:percent:${index}`]:
|
||||
[`prev_step:percent:${visibleIdx}`]:
|
||||
diff?.steps[stepIndex]?.percent ?? null,
|
||||
[`prev_step:data:${index}`]: diff
|
||||
[`prev_step:data:${visibleIdx}`]: diff
|
||||
? {
|
||||
...diff,
|
||||
step: diff?.steps?.[stepIndex],
|
||||
@@ -361,14 +391,51 @@ const useRechartData = ({
|
||||
);
|
||||
};
|
||||
|
||||
export function Chart({ data }: { data: RouterOutputs['chart']['funnel'] }) {
|
||||
const rechartData = useRechartData(data);
|
||||
export function Chart({
|
||||
data,
|
||||
visibleBreakdowns,
|
||||
}: {
|
||||
data: RouterOutputs['chart']['funnel'];
|
||||
visibleBreakdowns: RouterOutputs['chart']['funnel']['current'];
|
||||
}) {
|
||||
const rechartData = useRechartData({ ...data, visibleBreakdowns });
|
||||
const xAxisProps = useXAxisProps();
|
||||
const yAxisProps = useYAxisProps();
|
||||
const hasBreakdowns = data.current.length > 1;
|
||||
const hasVisibleBreakdowns = visibleBreakdowns.length > 1;
|
||||
|
||||
const CustomLegend = useCallback(() => {
|
||||
if (!hasVisibleBreakdowns) return null;
|
||||
return (
|
||||
<div className="flex flex-wrap justify-center gap-x-4 gap-y-1 text-xs mt-4 -mb-2">
|
||||
{visibleBreakdowns.map((breakdown, idx) => (
|
||||
<div
|
||||
className="flex items-center gap-1"
|
||||
key={breakdown.id}
|
||||
style={{
|
||||
color: getChartColor(idx),
|
||||
}}
|
||||
>
|
||||
<SerieIcon name={breakdown.breakdowns ?? []} />
|
||||
<SerieName
|
||||
name={
|
||||
breakdown.breakdowns && breakdown.breakdowns.length > 0
|
||||
? breakdown.breakdowns
|
||||
: ['Funnel']
|
||||
}
|
||||
className="font-semibold"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}, [visibleBreakdowns, hasVisibleBreakdowns]);
|
||||
|
||||
return (
|
||||
<TooltipProvider data={data.current}>
|
||||
<TooltipProvider
|
||||
data={data.current}
|
||||
visibleBreakdownIds={new Set(visibleBreakdowns.map((b) => b.id))}
|
||||
>
|
||||
<div className="aspect-video max-h-[250px] w-full p-4 card pb-1">
|
||||
<ResponsiveContainer>
|
||||
<BarChart data={rechartData}>
|
||||
@@ -395,7 +462,7 @@ export function Chart({ data }: { data: RouterOutputs['chart']['funnel'] }) {
|
||||
/>
|
||||
<YAxis {...yAxisProps} />
|
||||
{hasBreakdowns ? (
|
||||
data.current.map((item, breakdownIndex) => (
|
||||
visibleBreakdowns.map((item, breakdownIndex) => (
|
||||
<Bar
|
||||
key={`step:percent:${item.id}`}
|
||||
dataKey={`step:percent:${breakdownIndex}`}
|
||||
@@ -425,6 +492,7 @@ export function Chart({ data }: { data: RouterOutputs['chart']['funnel'] }) {
|
||||
))}
|
||||
</Bar>
|
||||
)}
|
||||
{hasVisibleBreakdowns && <Legend content={<CustomLegend />} />}
|
||||
<Tooltip />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
@@ -437,6 +505,7 @@ const { Tooltip, TooltipProvider } = createChartTooltip<
|
||||
RechartData,
|
||||
{
|
||||
data: RouterOutputs['chart']['funnel']['current'];
|
||||
visibleBreakdownIds: Set<string>;
|
||||
}
|
||||
>(({ data: dataArray, context, ...props }) => {
|
||||
const data = dataArray[0]!;
|
||||
@@ -449,24 +518,37 @@ const { Tooltip, TooltipProvider } = createChartTooltip<
|
||||
(step) => step.event.id === (data as any).id,
|
||||
);
|
||||
|
||||
// Filter variants to only show visible breakdowns
|
||||
// The variant object contains the full breakdown item, so we can check its ID directly
|
||||
const visibleVariants = variants.filter((key) => {
|
||||
const variant = data[key];
|
||||
if (!variant) return false;
|
||||
// The variant is the breakdown item itself (with step added), so it has an id property
|
||||
return context.visibleBreakdownIds.has(variant.id);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between gap-8 text-muted-foreground">
|
||||
<div>{data.name}</div>
|
||||
</div>
|
||||
{variants.map((key, breakdownIndex) => {
|
||||
{visibleVariants.map((key, visibleIndex) => {
|
||||
const variant = data[key];
|
||||
const prevVariant = data[`prev_${key}`];
|
||||
if (!variant?.step) {
|
||||
return null;
|
||||
}
|
||||
// Find the original breakdown index for color
|
||||
const originalBreakdownIndex = context.data.findIndex(
|
||||
(b) => b.id === variant.id,
|
||||
);
|
||||
return (
|
||||
<div className="row gap-2" key={key}>
|
||||
<div
|
||||
className="w-[3px] rounded-full"
|
||||
style={{
|
||||
background: getChartColor(
|
||||
variants.length > 1 ? breakdownIndex : index,
|
||||
visibleVariants.length > 1 ? visibleIndex : index,
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -7,7 +7,9 @@ import { ReportChartEmpty } from '../common/empty';
|
||||
import { ReportChartError } from '../common/error';
|
||||
import { ReportChartLoading } from '../common/loading';
|
||||
import { useReportChartContext } from '../context';
|
||||
import { Chart, Summary, Tables } from './chart';
|
||||
import { useVisibleFunnelBreakdowns } from '@/hooks/use-visible-funnel-breakdowns';
|
||||
import { Chart, Summary } from './chart';
|
||||
import { BreakdownList } from './breakdown-list';
|
||||
|
||||
export function ReportFunnelChart() {
|
||||
const { isLazyLoading, report, shareId } = useReportChartContext();
|
||||
@@ -24,6 +26,10 @@ export function ReportFunnelChart() {
|
||||
),
|
||||
);
|
||||
|
||||
// Hook for limiting which breakdowns are shown in the chart only
|
||||
const { breakdowns: visibleBreakdowns, setVisibleSeries } =
|
||||
useVisibleFunnelBreakdowns(res.data?.current ?? [], 10);
|
||||
|
||||
if (isLazyLoading || res.isLoading) {
|
||||
return <Loading />;
|
||||
}
|
||||
@@ -36,19 +42,17 @@ export function ReportFunnelChart() {
|
||||
return <Empty />;
|
||||
}
|
||||
|
||||
const hasBreakdowns = res.data.current.length > 1;
|
||||
|
||||
return (
|
||||
<div className="col gap-4">
|
||||
{res.data.current.length > 1 && <Summary data={res.data} />}
|
||||
<Chart data={res.data} />
|
||||
{res.data.current.map((item, index) => (
|
||||
<Tables
|
||||
key={item.id}
|
||||
data={{
|
||||
current: item,
|
||||
previous: res.data.previous?.[index] ?? null,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{hasBreakdowns && <Summary data={res.data} />}
|
||||
<Chart data={res.data} visibleBreakdowns={visibleBreakdowns} />
|
||||
<BreakdownList
|
||||
data={res.data}
|
||||
visibleSeriesIds={visibleBreakdowns.map((b) => b.id)}
|
||||
setVisibleSeries={setVisibleSeries}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { useAppParams } from '@/hooks/use-app-params';
|
||||
import { useEventProperties } from '@/hooks/use-event-properties';
|
||||
import { useDispatch } from '@/redux';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { DatabaseIcon } from 'lucide-react';
|
||||
|
||||
import type { IChartEvent } from '@openpanel/validation';
|
||||
|
||||
import { changeEvent } from '../reportSlice';
|
||||
|
||||
interface EventPropertiesComboboxProps {
|
||||
event: IChartEvent;
|
||||
}
|
||||
|
||||
export function EventPropertiesCombobox({
|
||||
event,
|
||||
}: EventPropertiesComboboxProps) {
|
||||
const dispatch = useDispatch();
|
||||
const { projectId } = useAppParams();
|
||||
const properties = useEventProperties(
|
||||
{
|
||||
event: event.name,
|
||||
projectId,
|
||||
},
|
||||
{
|
||||
enabled: !!event.name,
|
||||
},
|
||||
).map((item) => ({
|
||||
label: item,
|
||||
value: item,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
searchable
|
||||
placeholder="Select a filter"
|
||||
value=""
|
||||
items={properties}
|
||||
onChange={(value) => {
|
||||
dispatch(
|
||||
changeEvent({
|
||||
...event,
|
||||
property: value,
|
||||
type: 'event',
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded-md border border-border p-1 px-2 text-sm font-medium leading-none',
|
||||
!event.property && 'border-destructive text-destructive',
|
||||
)}
|
||||
>
|
||||
<DatabaseIcon size={12} />{' '}
|
||||
{event.property ? `Property: ${event.property}` : 'Select property'}
|
||||
</button>
|
||||
</Combobox>
|
||||
);
|
||||
}
|
||||
@@ -1,385 +0,0 @@
|
||||
import { ColorSquare } from '@/components/color-square';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ComboboxEvents } from '@/components/ui/combobox-events';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { InputEnter } from '@/components/ui/input-enter';
|
||||
import { useAppParams } from '@/hooks/use-app-params';
|
||||
import { useDebounceFn } from '@/hooks/use-debounce-fn';
|
||||
import { useEventNames } from '@/hooks/use-event-names';
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
import {
|
||||
DndContext,
|
||||
type DragEndEvent,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { shortId } from '@openpanel/common';
|
||||
import { alphabetIds } from '@openpanel/constants';
|
||||
import type { IChartEventItem, IChartFormula } from '@openpanel/validation';
|
||||
import { FilterIcon, HandIcon, PlusIcon } from 'lucide-react';
|
||||
import { ReportSegment } from '../ReportSegment';
|
||||
import {
|
||||
addSerie,
|
||||
changeEvent,
|
||||
duplicateEvent,
|
||||
removeEvent,
|
||||
reorderEvents,
|
||||
} from '../reportSlice';
|
||||
import { EventPropertiesCombobox } from './EventPropertiesCombobox';
|
||||
import { PropertiesCombobox } from './PropertiesCombobox';
|
||||
import type { ReportEventMoreProps } from './ReportEventMore';
|
||||
import { ReportEventMore } from './ReportEventMore';
|
||||
import { FiltersList } from './filters/FiltersList';
|
||||
|
||||
function SortableEvent({
|
||||
event,
|
||||
index,
|
||||
showSegment,
|
||||
showAddFilter,
|
||||
isSelectManyEvents,
|
||||
...props
|
||||
}: {
|
||||
event: IChartEventItem;
|
||||
index: number;
|
||||
showSegment: boolean;
|
||||
showAddFilter: boolean;
|
||||
isSelectManyEvents: boolean;
|
||||
} & React.HTMLAttributes<HTMLDivElement>) {
|
||||
const dispatch = useDispatch();
|
||||
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||
useSortable({ id: event.id ?? '' });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
const isEvent = event.type === 'event';
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} {...attributes} {...props}>
|
||||
<div className="flex items-center gap-2 p-2 group">
|
||||
<button className="cursor-grab active:cursor-grabbing" {...listeners}>
|
||||
<ColorSquare className="relative">
|
||||
<HandIcon className="size-3 opacity-0 scale-50 group-hover:opacity-100 group-hover:scale-100 transition-all absolute inset-1" />
|
||||
<span className="block group-hover:opacity-0 group-hover:scale-0 transition-all">
|
||||
{alphabetIds[index]}
|
||||
</span>
|
||||
</ColorSquare>
|
||||
</button>
|
||||
{props.children}
|
||||
</div>
|
||||
|
||||
{/* Segment and Filter buttons - only for events */}
|
||||
{isEvent && (showSegment || showAddFilter) && (
|
||||
<div className="flex gap-2 p-2 pt-0">
|
||||
{showSegment && (
|
||||
<ReportSegment
|
||||
value={event.segment}
|
||||
onChange={(segment) => {
|
||||
dispatch(
|
||||
changeEvent({
|
||||
...event,
|
||||
segment,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showAddFilter && (
|
||||
<PropertiesCombobox
|
||||
event={event}
|
||||
onSelect={(action) => {
|
||||
dispatch(
|
||||
changeEvent({
|
||||
...event,
|
||||
filters: [
|
||||
...event.filters,
|
||||
{
|
||||
id: shortId(),
|
||||
name: action.value,
|
||||
operator: 'is',
|
||||
value: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{(setOpen) => (
|
||||
<button
|
||||
onClick={() => setOpen((p) => !p)}
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded-md border border-border bg-card p-1 px-2 text-sm font-medium leading-none"
|
||||
>
|
||||
<FilterIcon size={12} /> Add filter
|
||||
</button>
|
||||
)}
|
||||
</PropertiesCombobox>
|
||||
)}
|
||||
|
||||
{showSegment && event.segment.startsWith('property_') && (
|
||||
<EventPropertiesCombobox event={event} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters - only for events */}
|
||||
{isEvent && !isSelectManyEvents && <FiltersList event={event} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReportEvents() {
|
||||
const selectedEvents = useSelector((state) => state.report.series);
|
||||
const chartType = useSelector((state) => state.report.chartType);
|
||||
const dispatch = useDispatch();
|
||||
const { projectId } = useAppParams();
|
||||
const eventNames = useEventNames({
|
||||
projectId,
|
||||
});
|
||||
|
||||
const showSegment = !['retention', 'funnel'].includes(chartType);
|
||||
const showAddFilter = !['retention'].includes(chartType);
|
||||
const showDisplayNameInput = !['retention'].includes(chartType);
|
||||
const isAddEventDisabled =
|
||||
(chartType === 'retention' || chartType === 'conversion') &&
|
||||
selectedEvents.length >= 2;
|
||||
const dispatchChangeEvent = useDebounceFn((event: IChartEventItem) => {
|
||||
dispatch(changeEvent(event));
|
||||
});
|
||||
const isSelectManyEvents = chartType === 'retention';
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (over && active.id !== over.id) {
|
||||
const oldIndex = selectedEvents.findIndex((e) => e.id === active.id);
|
||||
const newIndex = selectedEvents.findIndex((e) => e.id === over.id);
|
||||
|
||||
dispatch(reorderEvents({ fromIndex: oldIndex, toIndex: newIndex }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleMore = (event: IChartEventItem) => {
|
||||
const callback: ReportEventMoreProps['onClick'] = (action) => {
|
||||
switch (action) {
|
||||
case 'remove': {
|
||||
return dispatch(
|
||||
removeEvent({
|
||||
id: event.id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
case 'duplicate': {
|
||||
return dispatch(duplicateEvent(event));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return callback;
|
||||
};
|
||||
|
||||
const dispatchChangeFormula = useDebounceFn((formula: IChartFormula) => {
|
||||
dispatch(changeEvent(formula));
|
||||
});
|
||||
|
||||
const showFormula =
|
||||
chartType !== 'conversion' &&
|
||||
chartType !== 'funnel' &&
|
||||
chartType !== 'retention';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-2 font-medium">Metrics</h3>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={selectedEvents.map((e) => ({ id: e.id! }))}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{selectedEvents.map((event, index) => {
|
||||
const isFormula = event.type === 'formula';
|
||||
|
||||
return (
|
||||
<SortableEvent
|
||||
key={event.id}
|
||||
event={event}
|
||||
index={index}
|
||||
showSegment={showSegment}
|
||||
showAddFilter={showAddFilter}
|
||||
isSelectManyEvents={isSelectManyEvents}
|
||||
className="rounded-lg border bg-def-100"
|
||||
>
|
||||
{isFormula ? (
|
||||
<>
|
||||
<div className="flex-1 flex flex-col gap-2">
|
||||
<InputEnter
|
||||
placeholder="eg: A+B, A/B"
|
||||
value={event.formula}
|
||||
onChangeValue={(value) => {
|
||||
dispatchChangeFormula({
|
||||
...event,
|
||||
formula: value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{showDisplayNameInput && (
|
||||
<Input
|
||||
placeholder={`Formula (${alphabetIds[index]})`}
|
||||
defaultValue={event.displayName}
|
||||
onChange={(e) => {
|
||||
dispatchChangeFormula({
|
||||
...event,
|
||||
displayName: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ReportEventMore onClick={handleMore(event)} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ComboboxEvents
|
||||
className="flex-1"
|
||||
searchable
|
||||
multiple={isSelectManyEvents as false}
|
||||
value={
|
||||
isSelectManyEvents
|
||||
? (event.filters[0]?.value ?? [])
|
||||
: (event.name as any)
|
||||
}
|
||||
onChange={(value) => {
|
||||
dispatch(
|
||||
changeEvent(
|
||||
Array.isArray(value)
|
||||
? {
|
||||
id: event.id,
|
||||
type: 'event',
|
||||
segment: 'user',
|
||||
filters: [
|
||||
{
|
||||
name: 'name',
|
||||
operator: 'is',
|
||||
value: value,
|
||||
},
|
||||
],
|
||||
name: '*',
|
||||
}
|
||||
: {
|
||||
...event,
|
||||
type: 'event',
|
||||
name: value,
|
||||
filters: [],
|
||||
},
|
||||
),
|
||||
);
|
||||
}}
|
||||
items={eventNames}
|
||||
placeholder="Select event"
|
||||
/>
|
||||
{showDisplayNameInput && (
|
||||
<Input
|
||||
placeholder={
|
||||
event.name
|
||||
? `${event.name} (${alphabetIds[index]})`
|
||||
: 'Display name'
|
||||
}
|
||||
defaultValue={event.displayName}
|
||||
onChange={(e) => {
|
||||
dispatchChangeEvent({
|
||||
...event,
|
||||
displayName: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ReportEventMore onClick={handleMore(event)} />
|
||||
</>
|
||||
)}
|
||||
</SortableEvent>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<ComboboxEvents
|
||||
disabled={isAddEventDisabled}
|
||||
value={''}
|
||||
searchable
|
||||
onChange={(value) => {
|
||||
if (isSelectManyEvents) {
|
||||
dispatch(
|
||||
addSerie({
|
||||
type: 'event',
|
||||
segment: 'user',
|
||||
name: value,
|
||||
filters: [
|
||||
{
|
||||
name: 'name',
|
||||
operator: 'is',
|
||||
value: [value],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
dispatch(
|
||||
addSerie({
|
||||
type: 'event',
|
||||
name: value,
|
||||
segment: 'event',
|
||||
filters: [],
|
||||
}),
|
||||
);
|
||||
}
|
||||
}}
|
||||
placeholder="Select event"
|
||||
items={eventNames}
|
||||
/>
|
||||
{showFormula && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
icon={PlusIcon}
|
||||
onClick={() => {
|
||||
dispatch(
|
||||
addSerie({
|
||||
type: 'formula',
|
||||
formula: '',
|
||||
displayName: '',
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
Add Formula
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,10 +3,9 @@ import { useDispatch } from '@/redux';
|
||||
import { shortId } from '@openpanel/common';
|
||||
import { alphabetIds } from '@openpanel/constants';
|
||||
import type { IChartEvent, IChartEventItem } from '@openpanel/validation';
|
||||
import { FilterIcon } from 'lucide-react';
|
||||
import { DatabaseIcon, FilterIcon, type LucideIcon } from 'lucide-react';
|
||||
import { ReportSegment } from '../ReportSegment';
|
||||
import { changeEvent } from '../reportSlice';
|
||||
import { EventPropertiesCombobox } from './EventPropertiesCombobox';
|
||||
import { PropertiesCombobox } from './PropertiesCombobox';
|
||||
import { FiltersList } from './filters/FiltersList';
|
||||
|
||||
@@ -90,19 +89,40 @@ export function ReportSeriesItem({
|
||||
}}
|
||||
>
|
||||
{(setOpen) => (
|
||||
<button
|
||||
<SmallButton
|
||||
onClick={() => setOpen((p) => !p)}
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded-md border border-border bg-card p-1 px-2 text-sm font-medium leading-none"
|
||||
icon={FilterIcon}
|
||||
>
|
||||
<FilterIcon size={12} /> Add filter
|
||||
</button>
|
||||
Add filter
|
||||
</SmallButton>
|
||||
)}
|
||||
</PropertiesCombobox>
|
||||
)}
|
||||
|
||||
{showSegment && chartEvent.segment.startsWith('property_') && (
|
||||
<EventPropertiesCombobox event={chartEvent} />
|
||||
<PropertiesCombobox
|
||||
event={chartEvent}
|
||||
onSelect={(item) => {
|
||||
dispatch(
|
||||
changeEvent({
|
||||
...chartEvent,
|
||||
property: item.value,
|
||||
type: 'event',
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{(setOpen) => (
|
||||
<SmallButton
|
||||
icon={DatabaseIcon}
|
||||
onClick={() => setOpen((p) => !p)}
|
||||
>
|
||||
{chartEvent.property
|
||||
? `Property: ${chartEvent.property}`
|
||||
: 'Select property'}
|
||||
</SmallButton>
|
||||
)}
|
||||
</PropertiesCombobox>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -112,3 +132,23 @@ export function ReportSeriesItem({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SmallButton({
|
||||
children,
|
||||
icon: Icon,
|
||||
...props
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
icon: LucideIcon;
|
||||
} & React.ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded-md border border-border bg-card p-1 px-2 text-sm font-medium leading-none text-left min-w-0"
|
||||
{...props}
|
||||
>
|
||||
<Icon size={12} className="shrink-0" />
|
||||
<span className="truncate">{children}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,12 +21,40 @@ export function StatsCard({
|
||||
title,
|
||||
value,
|
||||
enhancer,
|
||||
}: { title: string; value: React.ReactNode; enhancer?: React.ReactNode }) {
|
||||
className,
|
||||
size = 'default',
|
||||
}: {
|
||||
title: string;
|
||||
value: React.ReactNode;
|
||||
enhancer?: React.ReactNode;
|
||||
className?: string;
|
||||
size?: 'default' | 'sm';
|
||||
}) {
|
||||
return (
|
||||
<div className="col gap-2 p-4 ring-[0.5px] ring-border">
|
||||
<div className="text-muted-foreground text-sm">{title}</div>
|
||||
<div
|
||||
className={cn(
|
||||
'col ring-[0.5px] ring-border',
|
||||
size === 'sm' ? 'gap-1 p-3' : 'gap-2 p-4',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'text-muted-foreground',
|
||||
size === 'sm' ? 'text-xs' : 'text-sm',
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
<div className="row justify-between gap-4">
|
||||
<div className="font-mono text-lg font-bold leading-snug">{value}</div>
|
||||
<div
|
||||
className={cn(
|
||||
'font-mono leading-snug',
|
||||
size === 'sm' ? 'text-sm font-medium' : 'text-lg font-bold',
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
<div>{enhancer}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user