feature(dashboard): add new retention chart type
This commit is contained in:
committed by
Carl-Gerhard Lindesvärd
parent
e2065da16e
commit
f977c5454a
@@ -40,8 +40,8 @@ export function OverviewFiltersDrawerContent({
|
||||
const { interval, range, startDate, endDate } = useOverviewOptions();
|
||||
const [filters, setFilter] = useEventQueryFilters(nuqsOptions);
|
||||
const [event, setEvent] = useEventQueryNamesFilter(nuqsOptions);
|
||||
const eventNames = useEventNames({ projectId, interval, range });
|
||||
const eventProperties = useEventProperties({ projectId, interval, range });
|
||||
const eventNames = useEventNames({ projectId });
|
||||
const eventProperties = useEventProperties({ projectId, event: event[0] });
|
||||
const profileProperties = useProfileProperties(projectId);
|
||||
const properties = mode === 'events' ? eventProperties : profileProperties;
|
||||
|
||||
@@ -94,8 +94,6 @@ export function OverviewFiltersDrawerContent({
|
||||
eventName="screen_view"
|
||||
key={filter.name}
|
||||
filter={filter}
|
||||
range={range}
|
||||
interval={interval}
|
||||
onRemove={() => {
|
||||
setFilter(filter.name, [], filter.operator);
|
||||
}}
|
||||
@@ -105,8 +103,6 @@ export function OverviewFiltersDrawerContent({
|
||||
onChangeOperator={(operator) => {
|
||||
setFilter(filter.name, filter.value, operator);
|
||||
}}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
/>
|
||||
) : /* TODO: Implement profile filters */
|
||||
null;
|
||||
@@ -128,13 +124,10 @@ export function FilterOptionEvent({
|
||||
operator: IChartEventFilterOperator,
|
||||
) => void;
|
||||
}) {
|
||||
const { interval, range } = useOverviewOptions();
|
||||
const values = usePropertyValues({
|
||||
projectId,
|
||||
event: filter.name === 'path' ? 'screen_view' : 'session_start',
|
||||
property: filter.name,
|
||||
interval,
|
||||
range,
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -25,9 +25,11 @@ export function getYAxisWidth(value: string | undefined | null) {
|
||||
export const useYAxisProps = ({
|
||||
data,
|
||||
hide,
|
||||
tickFormatter,
|
||||
}: {
|
||||
data: number[];
|
||||
hide?: boolean;
|
||||
tickFormatter?: (value: number) => string;
|
||||
}) => {
|
||||
const [width, setWidth] = useState(24);
|
||||
const setWidthDebounced = useDebounceFn(setWidth, 100);
|
||||
@@ -41,7 +43,7 @@ export const useYAxisProps = ({
|
||||
tickLine: false,
|
||||
allowDecimals: false,
|
||||
tickFormatter: (value: number) => {
|
||||
const tick = number.short(value);
|
||||
const tick = tickFormatter ? tickFormatter(value) : number.short(value);
|
||||
const newWidth = getYAxisWidth(tick);
|
||||
ref.current.push(newWidth);
|
||||
setWidthDebounced(Math.max(...ref.current));
|
||||
|
||||
@@ -1,13 +1,62 @@
|
||||
import { BirdIcon } from 'lucide-react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import {
|
||||
ArrowUpLeftIcon,
|
||||
BirdIcon,
|
||||
CornerLeftUpIcon,
|
||||
Forklift,
|
||||
ForkliftIcon,
|
||||
} from 'lucide-react';
|
||||
import { useReportChartContext } from '../context';
|
||||
|
||||
export function ReportChartEmpty({
|
||||
title = 'No data',
|
||||
children,
|
||||
}: {
|
||||
title?: string;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const {
|
||||
isEditMode,
|
||||
report: { events },
|
||||
} = useReportChartContext();
|
||||
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<div className="card p-4 center-center h-full w-full flex-col relative">
|
||||
<div className="row gap-2 items-end absolute top-4 left-4">
|
||||
<CornerLeftUpIcon
|
||||
strokeWidth={1.2}
|
||||
className="size-8 animate-pulse text-muted-foreground"
|
||||
/>
|
||||
<div className="text-muted-foreground">Start here</div>
|
||||
</div>
|
||||
<ForkliftIcon
|
||||
strokeWidth={1.2}
|
||||
className="mb-4 size-1/3 animate-pulse text-muted-foreground"
|
||||
/>
|
||||
<div className="font-medium text-muted-foreground">
|
||||
Ready when you're
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-2">
|
||||
Pick atleast one event to start visualize
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReportChartEmpty() {
|
||||
return (
|
||||
<div className="center-center h-full w-full flex-col">
|
||||
<div
|
||||
className={cn(
|
||||
'center-center h-full w-full flex-col',
|
||||
isEditMode && 'card p-4',
|
||||
)}
|
||||
>
|
||||
<BirdIcon
|
||||
strokeWidth={1.2}
|
||||
className="mb-4 size-10 animate-pulse text-muted-foreground"
|
||||
className="mb-4 size-1/3 animate-pulse text-muted-foreground"
|
||||
/>
|
||||
<div className="text-sm font-medium text-muted-foreground">No data</div>
|
||||
<div className="font-medium text-muted-foreground">{title}</div>
|
||||
<div className="text-muted-foreground mt-2">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { cn } from '@/utils/cn';
|
||||
import { ServerCrashIcon } from 'lucide-react';
|
||||
import { useReportChartContext } from '../context';
|
||||
|
||||
export function ReportChartError() {
|
||||
const { isEditMode } = useReportChartContext();
|
||||
return (
|
||||
<div className="center-center h-full w-full flex-col">
|
||||
<div
|
||||
className={cn(
|
||||
'center-center h-full w-full flex-col',
|
||||
isEditMode && 'card p-4',
|
||||
)}
|
||||
>
|
||||
<ServerCrashIcon
|
||||
strokeWidth={1.2}
|
||||
className="mb-4 size-10 animate-pulse text-muted-foreground"
|
||||
|
||||
@@ -1,3 +1,88 @@
|
||||
export function ReportChartLoading() {
|
||||
return <div className="h-full w-full animate-pulse rounded bg-def-100" />;
|
||||
import { cn } from '@/utils/cn';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import {
|
||||
ActivityIcon,
|
||||
AlarmClockIcon,
|
||||
BarChart2Icon,
|
||||
BarChartIcon,
|
||||
ChartLineIcon,
|
||||
ChartPieIcon,
|
||||
LineChartIcon,
|
||||
MessagesSquareIcon,
|
||||
PieChartIcon,
|
||||
TrendingUpIcon,
|
||||
} from 'lucide-react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useReportChartContext } from '../context';
|
||||
|
||||
const icons = [
|
||||
{ Icon: ActivityIcon, color: 'text-chart-6' },
|
||||
{ Icon: BarChart2Icon, color: 'text-chart-9' },
|
||||
{ Icon: ChartLineIcon, color: 'text-chart-0' },
|
||||
{ Icon: AlarmClockIcon, color: 'text-chart-1' },
|
||||
{ Icon: ChartPieIcon, color: 'text-chart-2' },
|
||||
{ Icon: MessagesSquareIcon, color: 'text-chart-3' },
|
||||
{ Icon: BarChartIcon, color: 'text-chart-4' },
|
||||
{ Icon: TrendingUpIcon, color: 'text-chart-5' },
|
||||
{ Icon: PieChartIcon, color: 'text-chart-7' },
|
||||
{ Icon: LineChartIcon, color: 'text-chart-8' },
|
||||
];
|
||||
|
||||
export function ReportChartLoading({ things }: { things?: boolean }) {
|
||||
const { isEditMode } = useReportChartContext();
|
||||
const [currentIconIndex, setCurrentIconIndex] = React.useState(0);
|
||||
const [isSlow, setSlow] = useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setCurrentIconIndex((prevIndex) => (prevIndex + 1) % icons.length);
|
||||
}, 1500);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentIconIndex >= 3) {
|
||||
setSlow(true);
|
||||
}
|
||||
}, [currentIconIndex]);
|
||||
|
||||
const { Icon, color } = icons[currentIconIndex]!;
|
||||
|
||||
return (
|
||||
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
|
||||
<div
|
||||
className={
|
||||
'relative h-full w-full rounded bg-def-100 overflow-hidden center-center flex'
|
||||
}
|
||||
>
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
<motion.div
|
||||
key={currentIconIndex}
|
||||
initial={{ x: '100%', opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
exit={{ x: '-100%', opacity: 0 }}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 500,
|
||||
damping: 30,
|
||||
duration: 0.5,
|
||||
}}
|
||||
className={cn('absolute size-1/3', color)}
|
||||
>
|
||||
<Icon className="w-full h-full" />
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-3/4 opacity-0 transition-opacity text-muted-foreground',
|
||||
isSlow && 'opacity-100',
|
||||
)}
|
||||
>
|
||||
Stay calm, its coming 🙄
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { IChartData } from '@/trpc/client';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import type * as React from 'react';
|
||||
|
||||
import { logDependencies } from 'mathjs';
|
||||
import { PreviousDiffIndicator } from './previous-diff-indicator';
|
||||
import { SerieName } from './serie-name';
|
||||
|
||||
@@ -80,7 +81,7 @@ export function ReportTable({
|
||||
);
|
||||
|
||||
return (
|
||||
<TableRow key={serie.id}>
|
||||
<TableRow key={`${serie.id}-1`}>
|
||||
{serie.names.map((name, nameIndex) => {
|
||||
return (
|
||||
<TableCell className="h-10" key={name}>
|
||||
@@ -140,7 +141,7 @@ export function ReportTable({
|
||||
<TableBody>
|
||||
{paginate(data.series).map((serie) => {
|
||||
return (
|
||||
<TableRow key={serie.id}>
|
||||
<TableRow key={`${serie.id}-2`}>
|
||||
<TableCell className="h-10">
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
{number.format(serie.metrics.sum)}
|
||||
|
||||
@@ -3,10 +3,8 @@
|
||||
import { ColorSquare } from '@/components/color-square';
|
||||
import { TooltipComplete } from '@/components/tooltip-complete';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Widget, WidgetBody } from '@/components/widget';
|
||||
import type { RouterOutputs } from '@/trpc/client';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import { AlertCircleIcon } from 'lucide-react';
|
||||
import { last } from 'ramda';
|
||||
|
||||
@@ -70,7 +68,7 @@ export function Chart({
|
||||
/>
|
||||
<MetricCardNumber
|
||||
label="Percent"
|
||||
value={`${round((lastStep.count / totalSessions) * 100, 1)}%`}
|
||||
value={`${totalSessions ? round((lastStep.count / totalSessions) * 100, 1) : 0}%`}
|
||||
enhancer={
|
||||
<PreviousDiffIndicator
|
||||
size="lg"
|
||||
|
||||
@@ -14,6 +14,7 @@ import { ReportLineChart } from './line';
|
||||
import { ReportMapChart } from './map';
|
||||
import { ReportMetricChart } from './metric';
|
||||
import { ReportPieChart } from './pie';
|
||||
import { ReportRetentionChart } from './retention';
|
||||
|
||||
export function ReportChart(props: ReportChartProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
@@ -48,6 +49,8 @@ export function ReportChart(props: ReportChartProps) {
|
||||
return <ReportMetricChart />;
|
||||
case 'funnel':
|
||||
return <ReportFunnelChart />;
|
||||
case 'retention':
|
||||
return <ReportRetentionChart />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { api } from '@/trpc/client';
|
||||
|
||||
import { cn } from '@/utils/cn';
|
||||
import { AspectContainer } from '../aspect-container';
|
||||
import { ReportChartEmpty } from '../common/empty';
|
||||
import { ReportChartError } from '../common/error';
|
||||
|
||||
113
apps/dashboard/src/components/report-chart/retention/chart.tsx
Normal file
113
apps/dashboard/src/components/report-chart/retention/chart.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
'use client';
|
||||
|
||||
import type { RouterOutputs } from '@/trpc/client';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import {
|
||||
Area,
|
||||
CartesianGrid,
|
||||
ComposedChart,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
import { average, round } from '@openpanel/common';
|
||||
import { fix } from 'mathjs';
|
||||
import { useXAxisProps, useYAxisProps } from '../common/axis';
|
||||
import { useReportChartContext } from '../context';
|
||||
import { RetentionTooltip } from './tooltip';
|
||||
|
||||
interface Props {
|
||||
data: RouterOutputs['chart']['cohort'];
|
||||
}
|
||||
|
||||
export function Chart({ data }: Props) {
|
||||
const {
|
||||
report: { interval },
|
||||
isEditMode,
|
||||
options: { hideXAxis, hideYAxis },
|
||||
} = useReportChartContext();
|
||||
|
||||
const xAxisProps = useXAxisProps({ interval, hide: hideXAxis });
|
||||
const yAxisProps = useYAxisProps({
|
||||
data: [100],
|
||||
hide: hideYAxis,
|
||||
tickFormatter: (value) => `${value}%`,
|
||||
});
|
||||
const averageRow = data[0];
|
||||
const averageRetentionRate = average(averageRow?.percentages || [], true);
|
||||
const rechartData = averageRow?.percentages.map((item, index, list) => ({
|
||||
days: index,
|
||||
percentage: item,
|
||||
value: averageRow.values[index],
|
||||
sum: averageRow.sum,
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
|
||||
<ResponsiveContainer>
|
||||
<ComposedChart data={rechartData}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
horizontal={true}
|
||||
vertical={true}
|
||||
className="stroke-border"
|
||||
/>
|
||||
<YAxis {...yAxisProps} dataKey="retentionRate" domain={[0, 100]} />
|
||||
<XAxis
|
||||
{...xAxisProps}
|
||||
dataKey="days"
|
||||
allowDuplicatedCategory
|
||||
scale="linear"
|
||||
tickFormatter={(value) => value.toString()}
|
||||
tickCount={31}
|
||||
interval={0}
|
||||
/>
|
||||
<Tooltip content={<RetentionTooltip />} />
|
||||
<defs>
|
||||
<linearGradient id={'color'} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor={getChartColor(0)}
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor={getChartColor(0)}
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<ReferenceLine
|
||||
y={averageRetentionRate}
|
||||
stroke={getChartColor(1)}
|
||||
strokeWidth={2}
|
||||
strokeDasharray="3 3"
|
||||
strokeOpacity={0.5}
|
||||
strokeLinecap="round"
|
||||
label={{
|
||||
value: `Average (${round(averageRetentionRate, 2)} %)`,
|
||||
fill: getChartColor(1),
|
||||
position: 'insideBottomRight',
|
||||
fontSize: 12,
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
dataKey="percentage"
|
||||
fill={'url(#color)'}
|
||||
type={'monotone'}
|
||||
isAnimationActive={false}
|
||||
strokeWidth={2}
|
||||
stroke={getChartColor(0)}
|
||||
fillOpacity={0.1}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
103
apps/dashboard/src/components/report-chart/retention/index.tsx
Normal file
103
apps/dashboard/src/components/report-chart/retention/index.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
'use client';
|
||||
|
||||
import { api } from '@/trpc/client';
|
||||
|
||||
import { AspectContainer } from '../aspect-container';
|
||||
import { ReportChartEmpty } from '../common/empty';
|
||||
import { ReportChartError } from '../common/error';
|
||||
import { ReportChartLoading } from '../common/loading';
|
||||
import { useReportChartContext } from '../context';
|
||||
import { Chart } from './chart';
|
||||
import CohortTable from './table';
|
||||
|
||||
export function ReportRetentionChart() {
|
||||
const {
|
||||
report: {
|
||||
events,
|
||||
range,
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
criteria,
|
||||
interval,
|
||||
},
|
||||
isLazyLoading,
|
||||
} = useReportChartContext();
|
||||
const firstEvent = (events[0]?.filters[0]?.value ?? []).map(String);
|
||||
const secondEvent = (events[1]?.filters[0]?.value ?? []).map(String);
|
||||
const isEnabled = firstEvent.length > 0 && secondEvent.length > 0;
|
||||
const res = api.chart.cohort.useQuery(
|
||||
{
|
||||
firstEvent,
|
||||
secondEvent,
|
||||
projectId,
|
||||
range,
|
||||
startDate,
|
||||
endDate,
|
||||
criteria,
|
||||
interval,
|
||||
},
|
||||
{
|
||||
enabled: isEnabled,
|
||||
},
|
||||
);
|
||||
|
||||
if (!isEnabled) {
|
||||
return <Disabled />;
|
||||
}
|
||||
|
||||
if (isLazyLoading || res.isLoading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (res.isError) {
|
||||
return <Error />;
|
||||
}
|
||||
|
||||
if (res.data.length === 0) {
|
||||
return <Empty />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="col gap-4">
|
||||
<AspectContainer>
|
||||
<Chart data={res.data} />
|
||||
</AspectContainer>
|
||||
<CohortTable data={res.data} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Loading() {
|
||||
return (
|
||||
<AspectContainer>
|
||||
<ReportChartLoading />
|
||||
</AspectContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function Error() {
|
||||
return (
|
||||
<AspectContainer>
|
||||
<ReportChartError />
|
||||
</AspectContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function Empty() {
|
||||
return (
|
||||
<AspectContainer>
|
||||
<ReportChartEmpty />
|
||||
</AspectContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function Disabled() {
|
||||
return (
|
||||
<AspectContainer>
|
||||
<ReportChartEmpty title="Select 2 events">
|
||||
We need two events to determine the retention rate.
|
||||
</ReportChartEmpty>
|
||||
</AspectContainer>
|
||||
);
|
||||
}
|
||||
136
apps/dashboard/src/components/report-chart/retention/table.tsx
Normal file
136
apps/dashboard/src/components/report-chart/retention/table.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { ProjectLink } from '@/components/links';
|
||||
import { Tooltiper } from '@/components/ui/tooltip';
|
||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||
import type { RouterOutputs } from '@/trpc/client';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { max, min, sum } from '@openpanel/common';
|
||||
import { intervals } from '@openpanel/constants';
|
||||
import type React from 'react';
|
||||
import { useReportChartContext } from '../context';
|
||||
|
||||
type CohortData = RouterOutputs['chart']['cohort'];
|
||||
|
||||
type CohortTableProps = {
|
||||
data: CohortData;
|
||||
};
|
||||
|
||||
const CohortTable: React.FC<CohortTableProps> = ({ data }) => {
|
||||
const {
|
||||
report: { unit, interval },
|
||||
} = useReportChartContext();
|
||||
const isPercentage = unit === '%';
|
||||
const number = useNumber();
|
||||
const highestValue = max(data.map((row) => max(row.values)));
|
||||
const lowestValue = min(data.map((row) => min(row.values)));
|
||||
const rowWithHigestSum = data.find(
|
||||
(row) => row.sum === max(data.map((row) => row.sum)),
|
||||
);
|
||||
|
||||
const getBackground = (value: number | undefined) => {
|
||||
if (!value)
|
||||
return {
|
||||
backgroundClassName: '',
|
||||
opacity: 0,
|
||||
};
|
||||
|
||||
const percentage = isPercentage
|
||||
? value / 100
|
||||
: (value - lowestValue) / (highestValue - lowestValue);
|
||||
const opacity = Math.max(0.05, percentage);
|
||||
|
||||
return {
|
||||
backgroundClassName: 'bg-highlight dark:bg-emerald-700',
|
||||
opacity,
|
||||
};
|
||||
};
|
||||
|
||||
const thClassName =
|
||||
'h-10 align-top pt-3 whitespace-nowrap font-semibold text-muted-foreground';
|
||||
|
||||
return (
|
||||
<div className="relative card overflow-hidden">
|
||||
<div
|
||||
className={'h-10 absolute left-0 right-0 top-px bg-def-100 border-b'}
|
||||
/>
|
||||
<div className="w-full overflow-x-auto hide-scrollbar">
|
||||
<div className="min-w-full relative">
|
||||
<table className="w-full table-auto whitespace-nowrap">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={cn(thClassName, 'sticky left-0 z-10')}>
|
||||
<div className="bg-def-100">
|
||||
<div className="h-10 center-center -mt-3">Date</div>
|
||||
</div>
|
||||
</th>
|
||||
<th className={cn(thClassName, 'pr-1')}>Total profiles</th>
|
||||
{data[0]?.values.map((column, index) => (
|
||||
<th
|
||||
key={index.toString()}
|
||||
className={cn(thClassName, 'capitalize')}
|
||||
>
|
||||
{index === 0 ? `< ${interval} 1` : `${interval} ${index}`}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((row) => {
|
||||
const values = isPercentage ? row.percentages : row.values;
|
||||
return (
|
||||
<tr key={row.cohort_interval}>
|
||||
<td className="sticky left-0 bg-card z-10 w-36 p-0">
|
||||
<div className="h-10 center-center font-medium text-muted-foreground px-4">
|
||||
{row.cohort_interval}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-0 min-w-12">
|
||||
<div className={cn('font-mono rounded px-3 font-medium')}>
|
||||
{number.format(row?.sum)}
|
||||
{row.cohort_interval ===
|
||||
rowWithHigestSum?.cohort_interval && ' 🚀'}
|
||||
</div>
|
||||
</td>
|
||||
{values.map((value, index) => {
|
||||
const { opacity, backgroundClassName } =
|
||||
getBackground(value);
|
||||
return (
|
||||
<td
|
||||
key={row.cohort_interval + index.toString()}
|
||||
className="p-0 min-w-24"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'h-10 center-center font-mono hover:shadow-[inset_0_0_0_2px_rgb(255,255,255)] relative',
|
||||
opacity > 0.7 &&
|
||||
'text-white [text-shadow:_0_0_3px_rgb(0_0_0_/_20%)]',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
backgroundClassName,
|
||||
'w-full h-full inset-0 absolute',
|
||||
)}
|
||||
style={{
|
||||
opacity,
|
||||
}}
|
||||
/>
|
||||
<div className="relative">
|
||||
{number.formatWithUnit(value, unit)}
|
||||
{value === highestValue && ' 🚀'}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CohortTable;
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||
import type { RouterOutputs } from '@/trpc/client';
|
||||
import { useReportChartContext } from '../context';
|
||||
|
||||
type Props = {
|
||||
active?: boolean;
|
||||
payload?: Array<{
|
||||
payload: any;
|
||||
}>;
|
||||
};
|
||||
export function RetentionTooltip({ active, payload }: Props) {
|
||||
const {
|
||||
report: { interval },
|
||||
} = useReportChartContext();
|
||||
const number = useNumber();
|
||||
if (!active) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!payload?.[0]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { days, percentage, value, sum } = payload[0].payload;
|
||||
|
||||
return (
|
||||
<div className="flex min-w-[200px] flex-col gap-2 rounded-xl border bg-card p-3 shadow-xl">
|
||||
<h3 className="font-semibold capitalize">
|
||||
{interval} {days}
|
||||
</h3>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Retention Rate:</span>
|
||||
<span className="font-medium">
|
||||
{number.formatWithUnit(percentage, '%')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Retained Users:</span>
|
||||
<span className="font-medium">{number.format(value)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Total Users:</span>
|
||||
<span className="font-medium">{number.format(sum)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,8 @@ export function ReportInterval({ className }: ReportIntervalProps) {
|
||||
chartType !== 'linear' &&
|
||||
chartType !== 'histogram' &&
|
||||
chartType !== 'area' &&
|
||||
chartType !== 'metric'
|
||||
chartType !== 'metric' &&
|
||||
chartType !== 'retention'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,9 @@ import type {
|
||||
IChartRange,
|
||||
IChartType,
|
||||
IInterval,
|
||||
zCriteria,
|
||||
} from '@openpanel/validation';
|
||||
import type { z } from 'zod';
|
||||
|
||||
type InitialState = IChartProps & {
|
||||
dirty: boolean;
|
||||
@@ -53,6 +55,7 @@ const initialState: InitialState = {
|
||||
unit: undefined,
|
||||
metric: 'sum',
|
||||
limit: 500,
|
||||
criteria: 'on_or_after',
|
||||
};
|
||||
|
||||
export const reportSlice = createSlice({
|
||||
@@ -251,6 +254,18 @@ export const reportSlice = createSlice({
|
||||
state.dirty = true;
|
||||
state.formula = action.payload;
|
||||
},
|
||||
|
||||
changeCriteria(state, action: PayloadAction<z.infer<typeof zCriteria>>) {
|
||||
state.dirty = true;
|
||||
state.criteria = action.payload;
|
||||
},
|
||||
|
||||
changeUnit(state, action: PayloadAction<string | undefined>) {
|
||||
console.log('here?!?!', action.payload);
|
||||
|
||||
state.dirty = true;
|
||||
state.unit = action.payload || undefined;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -276,6 +291,8 @@ export const {
|
||||
resetDirty,
|
||||
changeFormula,
|
||||
changePrevious,
|
||||
changeCriteria,
|
||||
changeUnit,
|
||||
} = reportSlice.actions;
|
||||
|
||||
export default reportSlice.reducer;
|
||||
|
||||
@@ -19,14 +19,10 @@ export function EventPropertiesCombobox({
|
||||
}: EventPropertiesComboboxProps) {
|
||||
const dispatch = useDispatch();
|
||||
const { projectId } = useAppParams();
|
||||
const range = useSelector((state) => state.report.range);
|
||||
const interval = useSelector((state) => state.report.interval);
|
||||
const properties = useEventProperties(
|
||||
{
|
||||
event: event.name,
|
||||
projectId,
|
||||
range,
|
||||
interval,
|
||||
},
|
||||
{
|
||||
enabled: !!event.name,
|
||||
|
||||
@@ -16,14 +16,10 @@ import type { ReportEventMoreProps } from './ReportEventMore';
|
||||
export function ReportBreakdowns() {
|
||||
const { projectId } = useAppParams();
|
||||
const selectedBreakdowns = useSelector((state) => state.report.breakdowns);
|
||||
const interval = useSelector((state) => state.report.interval);
|
||||
const range = useSelector((state) => state.report.range);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const properties = useEventProperties({
|
||||
projectId,
|
||||
range,
|
||||
interval,
|
||||
}).map((item) => ({
|
||||
value: item,
|
||||
label: item, // <RenderDots truncate>{item}</RenderDots>,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { ColorSquare } from '@/components/color-square';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { DropdownMenuComposed } from '@/components/ui/dropdown-menu';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -14,12 +13,8 @@ import { GanttChart, GanttChartIcon, Users } from 'lucide-react';
|
||||
import { alphabetIds } from '@openpanel/constants';
|
||||
import type { IChartEvent } from '@openpanel/validation';
|
||||
|
||||
import {
|
||||
addEvent,
|
||||
changeEvent,
|
||||
changePrevious,
|
||||
removeEvent,
|
||||
} from '../reportSlice';
|
||||
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
|
||||
import { addEvent, changeEvent, removeEvent } from '../reportSlice';
|
||||
import { EventPropertiesCombobox } from './EventPropertiesCombobox';
|
||||
import { ReportEventMore } from './ReportEventMore';
|
||||
import type { ReportEventMoreProps } from './ReportEventMore';
|
||||
@@ -27,25 +22,22 @@ import { FiltersCombobox } from './filters/FiltersCombobox';
|
||||
import { FiltersList } from './filters/FiltersList';
|
||||
|
||||
export function ReportEvents() {
|
||||
const previous = useSelector((state) => state.report.previous);
|
||||
const selectedEvents = useSelector((state) => state.report.events);
|
||||
const startDate = useSelector((state) => state.report.startDate);
|
||||
const endDate = useSelector((state) => state.report.endDate);
|
||||
const range = useSelector((state) => state.report.range);
|
||||
const interval = useSelector((state) => state.report.interval);
|
||||
const chartType = useSelector((state) => state.report.chartType);
|
||||
const dispatch = useDispatch();
|
||||
const { projectId } = useAppParams();
|
||||
const eventNames = useEventNames({
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
range,
|
||||
interval,
|
||||
});
|
||||
|
||||
const showSegment = !['retention', 'funnel'].includes(chartType);
|
||||
const showAddFilter = !['retention'].includes(chartType);
|
||||
const showDisplayNameInput = !['retention'].includes(chartType);
|
||||
const isAddEventDisabled =
|
||||
chartType === 'retention' && selectedEvents.length >= 2;
|
||||
const dispatchChangeEvent = useDebounceFn((event: IChartEvent) => {
|
||||
dispatch(changeEvent(event));
|
||||
});
|
||||
const isSelectManyEvents = chartType === 'retention';
|
||||
|
||||
const handleMore = (event: IChartEvent) => {
|
||||
const callback: ReportEventMoreProps['onClick'] = (action) => {
|
||||
@@ -68,137 +60,173 @@ export function ReportEvents() {
|
||||
<div key={event.id} className="rounded-lg border bg-def-100">
|
||||
<div className="flex items-center gap-2 p-2">
|
||||
<ColorSquare>{alphabetIds[index]}</ColorSquare>
|
||||
<Combobox
|
||||
icon={GanttChartIcon}
|
||||
className="flex-1"
|
||||
searchable
|
||||
value={event.name}
|
||||
onChange={(value) => {
|
||||
dispatch(
|
||||
changeEvent({
|
||||
{isSelectManyEvents ? (
|
||||
<ComboboxAdvanced
|
||||
className="flex-1"
|
||||
value={event.filters[0]?.value ?? []}
|
||||
onChange={(value) => {
|
||||
dispatch(
|
||||
changeEvent({
|
||||
id: event.id,
|
||||
segment: 'user',
|
||||
filters: [
|
||||
{
|
||||
name: 'name',
|
||||
operator: 'is',
|
||||
value: value,
|
||||
},
|
||||
],
|
||||
name: '*',
|
||||
}),
|
||||
);
|
||||
}}
|
||||
items={eventNames.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.name,
|
||||
}))}
|
||||
placeholder="Select event"
|
||||
/>
|
||||
) : (
|
||||
<Combobox
|
||||
icon={GanttChartIcon}
|
||||
className="flex-1"
|
||||
searchable
|
||||
value={event.name}
|
||||
onChange={(value) => {
|
||||
dispatch(
|
||||
changeEvent({
|
||||
...event,
|
||||
name: value,
|
||||
filters: [],
|
||||
}),
|
||||
);
|
||||
}}
|
||||
items={eventNames.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.name,
|
||||
}))}
|
||||
placeholder="Select event"
|
||||
/>
|
||||
)}
|
||||
{showDisplayNameInput && (
|
||||
<Input
|
||||
placeholder={
|
||||
event.name
|
||||
? `${event.name} (${alphabetIds[index]})`
|
||||
: 'Display name'
|
||||
}
|
||||
defaultValue={event.displayName}
|
||||
onChange={(e) => {
|
||||
dispatchChangeEvent({
|
||||
...event,
|
||||
name: value,
|
||||
filters: [],
|
||||
}),
|
||||
);
|
||||
}}
|
||||
items={eventNames.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.name,
|
||||
}))}
|
||||
placeholder="Select event"
|
||||
/>
|
||||
<Input
|
||||
placeholder={
|
||||
event.name
|
||||
? `${event.name} (${alphabetIds[index]})`
|
||||
: 'Display name'
|
||||
}
|
||||
defaultValue={event.displayName}
|
||||
onChange={(e) => {
|
||||
dispatchChangeEvent({
|
||||
...event,
|
||||
displayName: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
displayName: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ReportEventMore onClick={handleMore(event)} />
|
||||
</div>
|
||||
|
||||
{/* Segment and Filter buttons */}
|
||||
<div className="flex gap-2 p-2 pt-0 ">
|
||||
<DropdownMenuComposed
|
||||
onChange={(segment) => {
|
||||
dispatch(
|
||||
changeEvent({
|
||||
...event,
|
||||
segment,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
items={[
|
||||
{
|
||||
value: 'event',
|
||||
label: 'All events',
|
||||
},
|
||||
{
|
||||
value: 'user',
|
||||
label: 'Unique users',
|
||||
},
|
||||
{
|
||||
value: 'session',
|
||||
label: 'Unique sessions',
|
||||
},
|
||||
{
|
||||
value: 'user_average',
|
||||
label: 'Average event per user',
|
||||
},
|
||||
{
|
||||
value: 'one_event_per_user',
|
||||
label: 'One event per user',
|
||||
},
|
||||
{
|
||||
value: 'property_sum',
|
||||
label: 'Sum of property',
|
||||
},
|
||||
{
|
||||
value: 'property_average',
|
||||
label: 'Average of property',
|
||||
},
|
||||
]}
|
||||
label="Segment"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
{event.segment === 'user' ? (
|
||||
<>
|
||||
<Users size={12} /> Unique users
|
||||
</>
|
||||
) : event.segment === 'session' ? (
|
||||
<>
|
||||
<Users size={12} /> Unique sessions
|
||||
</>
|
||||
) : event.segment === 'user_average' ? (
|
||||
<>
|
||||
<Users size={12} /> Average event per user
|
||||
</>
|
||||
) : event.segment === 'one_event_per_user' ? (
|
||||
<>
|
||||
<Users size={12} /> One event per user
|
||||
</>
|
||||
) : event.segment === 'property_sum' ? (
|
||||
<>
|
||||
<Users size={12} /> Sum of property
|
||||
</>
|
||||
) : event.segment === 'property_average' ? (
|
||||
<>
|
||||
<Users size={12} /> Average of property
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GanttChart size={12} /> All events
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuComposed>
|
||||
{/* */}
|
||||
<FiltersCombobox event={event} />
|
||||
{(showSegment || showAddFilter) && (
|
||||
<div className="flex gap-2 p-2 pt-0 ">
|
||||
{showSegment && (
|
||||
<DropdownMenuComposed
|
||||
onChange={(segment) => {
|
||||
dispatch(
|
||||
changeEvent({
|
||||
...event,
|
||||
segment,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
items={[
|
||||
{
|
||||
value: 'event',
|
||||
label: 'All events',
|
||||
},
|
||||
{
|
||||
value: 'user',
|
||||
label: 'Unique users',
|
||||
},
|
||||
{
|
||||
value: 'session',
|
||||
label: 'Unique sessions',
|
||||
},
|
||||
{
|
||||
value: 'user_average',
|
||||
label: 'Average event per user',
|
||||
},
|
||||
{
|
||||
value: 'one_event_per_user',
|
||||
label: 'One event per user',
|
||||
},
|
||||
{
|
||||
value: 'property_sum',
|
||||
label: 'Sum of property',
|
||||
},
|
||||
{
|
||||
value: 'property_average',
|
||||
label: 'Average of property',
|
||||
},
|
||||
]}
|
||||
label="Segment"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
{event.segment === 'user' ? (
|
||||
<>
|
||||
<Users size={12} /> Unique users
|
||||
</>
|
||||
) : event.segment === 'session' ? (
|
||||
<>
|
||||
<Users size={12} /> Unique sessions
|
||||
</>
|
||||
) : event.segment === 'user_average' ? (
|
||||
<>
|
||||
<Users size={12} /> Average event per user
|
||||
</>
|
||||
) : event.segment === 'one_event_per_user' ? (
|
||||
<>
|
||||
<Users size={12} /> One event per user
|
||||
</>
|
||||
) : event.segment === 'property_sum' ? (
|
||||
<>
|
||||
<Users size={12} /> Sum of property
|
||||
</>
|
||||
) : event.segment === 'property_average' ? (
|
||||
<>
|
||||
<Users size={12} /> Average of property
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GanttChart size={12} /> All events
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuComposed>
|
||||
)}
|
||||
{/* */}
|
||||
{showAddFilter && <FiltersCombobox event={event} />}
|
||||
|
||||
{(event.segment === 'property_average' ||
|
||||
event.segment === 'property_sum') && (
|
||||
<EventPropertiesCombobox event={event} />
|
||||
)}
|
||||
</div>
|
||||
{showSegment &&
|
||||
(event.segment === 'property_average' ||
|
||||
event.segment === 'property_sum') && (
|
||||
<EventPropertiesCombobox event={event} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<FiltersList event={event} />
|
||||
{!isSelectManyEvents && <FiltersList event={event} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<Combobox
|
||||
disabled={isAddEventDisabled}
|
||||
icon={GanttChartIcon}
|
||||
value={''}
|
||||
searchable
|
||||
@@ -218,17 +246,6 @@ export function ReportEvents() {
|
||||
placeholder="Select event"
|
||||
/>
|
||||
</div>
|
||||
<label
|
||||
className="mt-4 flex cursor-pointer select-none items-center gap-2 font-medium"
|
||||
htmlFor="previous"
|
||||
>
|
||||
<Checkbox
|
||||
id="previous"
|
||||
checked={previous}
|
||||
onCheckedChange={(val) => dispatch(changePrevious(!!val))}
|
||||
/>
|
||||
Show previous / Compare
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
'use client';
|
||||
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useMemo } from 'react';
|
||||
import { changeCriteria, changePrevious, changeUnit } from '../reportSlice';
|
||||
|
||||
export function ReportSettings() {
|
||||
const chartType = useSelector((state) => state.report.chartType);
|
||||
const previous = useSelector((state) => state.report.previous);
|
||||
const criteria = useSelector((state) => state.report.criteria);
|
||||
const unit = useSelector((state) => state.report.unit);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const fields = useMemo(() => {
|
||||
const fields = [];
|
||||
|
||||
if (chartType !== 'retention') {
|
||||
fields.push('previous');
|
||||
}
|
||||
|
||||
if (chartType === 'retention') {
|
||||
fields.push('criteria');
|
||||
fields.push('unit');
|
||||
}
|
||||
|
||||
return fields;
|
||||
}, [chartType]);
|
||||
|
||||
if (fields.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-2 font-medium">Settings</h3>
|
||||
<div className="col rounded-lg border bg-def-100 p-4 gap-2">
|
||||
{fields.includes('previous') && (
|
||||
<Label className="flex items-center justify-between mb-0">
|
||||
<span>Compare to previous period</span>
|
||||
<Switch
|
||||
checked={previous}
|
||||
onCheckedChange={(val) => dispatch(changePrevious(!!val))}
|
||||
/>
|
||||
</Label>
|
||||
)}
|
||||
{fields.includes('criteria') && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Criteria</span>
|
||||
<Combobox
|
||||
align="end"
|
||||
placeholder="Select criteria"
|
||||
value={criteria}
|
||||
onChange={(val) => dispatch(changeCriteria(val))}
|
||||
items={[
|
||||
{
|
||||
label: 'On or After',
|
||||
value: 'on_or_after',
|
||||
},
|
||||
{
|
||||
label: 'On',
|
||||
value: 'on',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{fields.includes('unit') && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Unit</span>
|
||||
<Combobox
|
||||
align="end"
|
||||
placeholder="Unit"
|
||||
value={unit || 'count'}
|
||||
onChange={(val) => {
|
||||
dispatch(changeUnit(val === 'count' ? undefined : val));
|
||||
}}
|
||||
items={[
|
||||
{
|
||||
label: 'Count',
|
||||
value: 'count',
|
||||
},
|
||||
{
|
||||
label: '%',
|
||||
value: '%',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,15 +5,17 @@ import { useSelector } from '@/redux';
|
||||
import { ReportBreakdowns } from './ReportBreakdowns';
|
||||
import { ReportEvents } from './ReportEvents';
|
||||
import { ReportFormula } from './ReportFormula';
|
||||
import { ReportSettings } from './ReportSettings';
|
||||
|
||||
export function ReportSidebar() {
|
||||
const { chartType } = useSelector((state) => state.report);
|
||||
const showFormula = chartType !== 'funnel';
|
||||
const showBreakdown = chartType !== 'funnel';
|
||||
const showFormula = chartType !== 'funnel' && chartType !== 'retention';
|
||||
const showBreakdown = chartType !== 'funnel' && chartType !== 'retention';
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-8">
|
||||
<ReportEvents />
|
||||
<ReportSettings />
|
||||
{showFormula && <ReportFormula />}
|
||||
{showBreakdown && <ReportBreakdowns />}
|
||||
</div>
|
||||
|
||||
@@ -34,10 +34,6 @@ interface FilterProps {
|
||||
interface PureFilterProps {
|
||||
eventName: string;
|
||||
filter: IChartEventFilter;
|
||||
range: IChartRange;
|
||||
startDate: string | null;
|
||||
endDate: string | null;
|
||||
interval: IInterval;
|
||||
onRemove: (filter: IChartEventFilter) => void;
|
||||
onChangeValue: (
|
||||
value: IChartEventFilterValue[],
|
||||
@@ -111,10 +107,6 @@ export function FilterItem({ filter, event }: FilterProps) {
|
||||
<PureFilterItem
|
||||
filter={filter}
|
||||
eventName={event.name}
|
||||
range={range}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
interval={interval}
|
||||
onRemove={onRemove}
|
||||
onChangeValue={onChangeValue}
|
||||
onChangeOperator={onChangeOperator}
|
||||
@@ -126,10 +118,6 @@ export function FilterItem({ filter, event }: FilterProps) {
|
||||
export function PureFilterItem({
|
||||
filter,
|
||||
eventName,
|
||||
range,
|
||||
startDate,
|
||||
endDate,
|
||||
interval,
|
||||
onRemove,
|
||||
onChangeValue,
|
||||
onChangeOperator,
|
||||
@@ -142,10 +130,6 @@ export function PureFilterItem({
|
||||
event: eventName,
|
||||
property: filter.name,
|
||||
projectId,
|
||||
range,
|
||||
interval,
|
||||
startDate,
|
||||
endDate,
|
||||
});
|
||||
|
||||
const valuesCombobox =
|
||||
@@ -188,11 +172,7 @@ export function PureFilterItem({
|
||||
}))}
|
||||
label="Operator"
|
||||
>
|
||||
<Button
|
||||
variant={'outline'}
|
||||
className="whitespace-nowrap"
|
||||
size="default"
|
||||
>
|
||||
<Button variant={'outline'} className="whitespace-nowrap">
|
||||
{operators[filter.operator]}
|
||||
</Button>
|
||||
</DropdownMenuComposed>
|
||||
|
||||
@@ -15,20 +15,12 @@ interface FiltersComboboxProps {
|
||||
|
||||
export function FiltersCombobox({ event }: FiltersComboboxProps) {
|
||||
const dispatch = useDispatch();
|
||||
const interval = useSelector((state) => state.report.interval);
|
||||
const range = useSelector((state) => state.report.range);
|
||||
const startDate = useSelector((state) => state.report.startDate);
|
||||
const endDate = useSelector((state) => state.report.endDate);
|
||||
const { projectId } = useAppParams();
|
||||
|
||||
const properties = useEventProperties(
|
||||
{
|
||||
event: event.name,
|
||||
projectId,
|
||||
range,
|
||||
interval,
|
||||
startDate,
|
||||
endDate,
|
||||
},
|
||||
{
|
||||
enabled: !!event.name,
|
||||
|
||||
@@ -45,6 +45,26 @@ export interface ButtonProps
|
||||
loading?: boolean;
|
||||
icon?: LucideIcon;
|
||||
responsive?: boolean;
|
||||
autoHeight?: boolean;
|
||||
}
|
||||
|
||||
function fixHeight({
|
||||
autoHeight,
|
||||
size,
|
||||
}: { autoHeight?: boolean; size: ButtonProps['size'] }) {
|
||||
if (autoHeight) {
|
||||
switch (size) {
|
||||
case 'lg':
|
||||
return 'h-auto min-h-11 py-2';
|
||||
case 'icon':
|
||||
return 'h-auto min-h-8 py-1';
|
||||
case 'default':
|
||||
return 'h-auto min-h-10 py-2';
|
||||
default:
|
||||
return 'h-auto min-h-8 py-1';
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
@@ -59,6 +79,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
disabled,
|
||||
icon,
|
||||
responsive,
|
||||
autoHeight,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
@@ -67,7 +88,10 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
const Icon = loading ? Loader2 : (icon ?? null);
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
className={cn(
|
||||
buttonVariants({ variant, size, className }),
|
||||
fixHeight({ autoHeight, size }),
|
||||
)}
|
||||
ref={ref}
|
||||
disabled={loading || disabled}
|
||||
{...props}
|
||||
|
||||
@@ -6,7 +6,7 @@ import VirtualList from 'rc-virtual-list';
|
||||
import * as React from 'react';
|
||||
import { useOnClickOutside } from 'usehooks-ts';
|
||||
|
||||
import { Button } from './button';
|
||||
import { Button, type ButtonProps } from './button';
|
||||
import { Checkbox, DumpCheckbox } from './checkbox';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from './popover';
|
||||
|
||||
@@ -19,6 +19,7 @@ interface ComboboxAdvancedProps {
|
||||
items: IItem[];
|
||||
placeholder: string;
|
||||
className?: string;
|
||||
size?: ButtonProps['size'];
|
||||
}
|
||||
|
||||
export function ComboboxAdvanced({
|
||||
@@ -27,6 +28,7 @@ export function ComboboxAdvanced({
|
||||
onChange,
|
||||
placeholder,
|
||||
className,
|
||||
size,
|
||||
}: ComboboxAdvancedProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [inputValue, setInputValue] = React.useState('');
|
||||
@@ -99,7 +101,9 @@ export function ComboboxAdvanced({
|
||||
<Button
|
||||
variant={'outline'}
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
className={cn('h-auto min-h-10 py-2', className)}
|
||||
className={className}
|
||||
size={size}
|
||||
autoHeight
|
||||
>
|
||||
<div className="flex w-full flex-wrap gap-1">
|
||||
{value.length === 0 && placeholder}
|
||||
|
||||
@@ -38,6 +38,7 @@ export interface ComboboxProps<T> {
|
||||
align?: 'start' | 'end' | 'center';
|
||||
portal?: boolean;
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export type ExtendedComboboxProps<T> = Omit<
|
||||
@@ -61,6 +62,7 @@ export function Combobox<T extends string>({
|
||||
align = 'start',
|
||||
portal,
|
||||
error,
|
||||
disabled,
|
||||
}: ComboboxProps<T>) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [search, setSearch] = React.useState('');
|
||||
@@ -75,6 +77,7 @@ export function Combobox<T extends string>({
|
||||
<PopoverTrigger asChild>
|
||||
{children ?? (
|
||||
<Button
|
||||
disabled={disabled}
|
||||
size={size}
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { cn } from '@/utils/cn';
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||
import { Check, ChevronRight, Circle } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
import { ButtonProps } from './button';
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ interface TooltiperProps {
|
||||
content: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
tooltipClassName?: string;
|
||||
onClick?: () => void;
|
||||
side?: 'top' | 'right' | 'bottom' | 'left';
|
||||
delayDuration?: number;
|
||||
@@ -49,6 +50,7 @@ export function Tooltiper({
|
||||
content,
|
||||
children,
|
||||
className,
|
||||
tooltipClassName,
|
||||
onClick,
|
||||
side,
|
||||
delayDuration = 0,
|
||||
@@ -62,7 +64,11 @@ export function Tooltiper({
|
||||
{children}
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent sideOffset={sideOffset} side={side}>
|
||||
<TooltipContent
|
||||
sideOffset={sideOffset}
|
||||
side={side}
|
||||
className={tooltipClassName}
|
||||
>
|
||||
{content}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
|
||||
Reference in New Issue
Block a user